summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/services/CommandService.test.ts110
-rw-r--r--packages/cli/src/services/CommandService.ts36
-rw-r--r--packages/cli/src/test-utils/mockCommandContext.test.ts62
-rw-r--r--packages/cli/src/test-utils/mockCommandContext.ts94
-rw-r--r--packages/cli/src/ui/App.test.tsx1
-rw-r--r--packages/cli/src/ui/App.tsx11
-rw-r--r--packages/cli/src/ui/commands/clearCommand.test.ts78
-rw-r--r--packages/cli/src/ui/commands/clearCommand.ts17
-rw-r--r--packages/cli/src/ui/commands/helpCommand.test.ts40
-rw-r--r--packages/cli/src/ui/commands/helpCommand.ts20
-rw-r--r--packages/cli/src/ui/commands/memoryCommand.test.ts249
-rw-r--r--packages/cli/src/ui/commands/memoryCommand.ts106
-rw-r--r--packages/cli/src/ui/commands/types.ts99
-rw-r--r--packages/cli/src/ui/components/Help.tsx26
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx223
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx292
-rw-r--r--packages/cli/src/ui/contexts/SessionContext.tsx2
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts461
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts395
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts431
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts167
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx110
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts22
-rw-r--r--packages/cli/src/ui/types.ts13
-rw-r--r--packages/core/src/config/config.test.ts42
-rw-r--r--packages/core/src/config/config.ts15
26 files changed, 2416 insertions, 706 deletions
diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts
new file mode 100644
index 00000000..f5a5a835
--- /dev/null
+++ b/packages/cli/src/services/CommandService.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { CommandService } from './CommandService.js';
+import { 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';
+
+// Mock the command modules to isolate the service from the command implementations.
+vi.mock('../ui/commands/memoryCommand.js', () => ({
+ memoryCommand: { name: 'memory', description: 'Mock Memory' },
+}));
+vi.mock('../ui/commands/helpCommand.js', () => ({
+ helpCommand: { name: 'help', description: 'Mock Help' },
+}));
+vi.mock('../ui/commands/clearCommand.js', () => ({
+ clearCommand: { name: 'clear', description: 'Mock Clear' },
+}));
+
+describe('CommandService', () => {
+ describe('when using default production loader', () => {
+ let commandService: CommandService;
+
+ beforeEach(() => {
+ commandService = new CommandService();
+ });
+
+ it('should initialize with an empty command tree', () => {
+ const tree = commandService.getCommands();
+ expect(tree).toBeInstanceOf(Array);
+ expect(tree.length).toBe(0);
+ });
+
+ describe('loadCommands', () => {
+ it('should load the built-in commands into the command tree', async () => {
+ // Pre-condition check
+ expect(commandService.getCommands().length).toBe(0);
+
+ // Action
+ await commandService.loadCommands();
+ const tree = commandService.getCommands();
+
+ // Post-condition assertions
+ expect(tree.length).toBe(3);
+
+ const commandNames = tree.map((cmd) => cmd.name);
+ expect(commandNames).toContain('memory');
+ expect(commandNames).toContain('help');
+ expect(commandNames).toContain('clear');
+ });
+
+ it('should overwrite any existing commands when called again', async () => {
+ // Load once
+ await commandService.loadCommands();
+ expect(commandService.getCommands().length).toBe(3);
+
+ // Load again
+ await commandService.loadCommands();
+ const tree = commandService.getCommands();
+
+ // Should not append, but overwrite
+ expect(tree.length).toBe(3);
+ });
+ });
+
+ 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(3);
+ expect(loadedTree).toEqual([clearCommand, helpCommand, memoryCommand]);
+ });
+ });
+ });
+
+ describe('when initialized with an injected loader function', () => {
+ it('should use the provided loader instead of the built-in one', async () => {
+ // Arrange: Create a set of mock commands.
+ const mockCommands: SlashCommand[] = [
+ { name: 'injected-test-1', description: 'injected 1' },
+ { name: 'injected-test-2', description: 'injected 2' },
+ ];
+
+ // Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
+ const mockLoader = vi.fn().mockResolvedValue(mockCommands);
+
+ // Act: Instantiate the service WITH the injected loader function.
+ const commandService = new CommandService(mockLoader);
+ await commandService.loadCommands();
+ const tree = commandService.getCommands();
+
+ // Assert: The tree should contain ONLY our injected commands.
+ expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
+ expect(tree.length).toBe(2);
+ expect(tree).toEqual(mockCommands);
+
+ const commandNames = tree.map((cmd) => cmd.name);
+ expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
+ });
+ });
+});
diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts
new file mode 100644
index 00000000..588eabf7
--- /dev/null
+++ b/packages/cli/src/services/CommandService.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { 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';
+
+const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
+ clearCommand,
+ helpCommand,
+ memoryCommand,
+];
+
+export class CommandService {
+ private commands: SlashCommand[] = [];
+
+ constructor(
+ private commandLoader: () => Promise<SlashCommand[]> = loadBuiltInCommands,
+ ) {
+ // The constructor can be used for dependency injection in the future.
+ }
+
+ async loadCommands(): Promise<void> {
+ // For now, we only load the built-in commands.
+ // File-based and remote commands will be added later.
+ this.commands = await this.commandLoader();
+ }
+
+ getCommands(): SlashCommand[] {
+ return this.commands;
+ }
+}
diff --git a/packages/cli/src/test-utils/mockCommandContext.test.ts b/packages/cli/src/test-utils/mockCommandContext.test.ts
new file mode 100644
index 00000000..310bf748
--- /dev/null
+++ b/packages/cli/src/test-utils/mockCommandContext.test.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect } from 'vitest';
+import { createMockCommandContext } from './mockCommandContext.js';
+
+describe('createMockCommandContext', () => {
+ it('should return a valid CommandContext object with default mocks', () => {
+ const context = createMockCommandContext();
+
+ // Just a few spot checks to ensure the structure is correct
+ // and functions are mocks.
+ expect(context).toBeDefined();
+ expect(context.ui.addItem).toBeInstanceOf(Function);
+ expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
+ });
+
+ it('should apply top-level overrides correctly', () => {
+ const mockClear = vi.fn();
+ const overrides = {
+ ui: {
+ clear: mockClear,
+ },
+ };
+
+ const context = createMockCommandContext(overrides);
+
+ // Call the function to see if the override was used
+ context.ui.clear();
+
+ // Assert that our specific mock was called, not the default
+ expect(mockClear).toHaveBeenCalled();
+ // And that other defaults are still in place
+ expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
+ });
+
+ it('should apply deeply nested overrides correctly', () => {
+ // This is the most important test for factory's logic.
+ const mockConfig = {
+ getProjectRoot: () => '/test/project',
+ getModel: () => 'gemini-pro',
+ };
+
+ const overrides = {
+ services: {
+ config: mockConfig,
+ },
+ };
+
+ const context = createMockCommandContext(overrides);
+
+ expect(context.services.config).toBeDefined();
+ expect(context.services.config?.getModel()).toBe('gemini-pro');
+ expect(context.services.config?.getProjectRoot()).toBe('/test/project');
+
+ // Verify a default property on the same nested object is still there
+ expect(context.services.logger).toBeDefined();
+ });
+});
diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts
new file mode 100644
index 00000000..bf7d814d
--- /dev/null
+++ b/packages/cli/src/test-utils/mockCommandContext.ts
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi } from 'vitest';
+import { CommandContext } from '../ui/commands/types.js';
+import { LoadedSettings } from '../config/settings.js';
+import { GitService } from '@google/gemini-cli-core';
+import { SessionStatsState } from '../ui/contexts/SessionContext.js';
+
+// A utility type to make all properties of an object, and its nested objects, partial.
+type DeepPartial<T> = T extends object
+ ? {
+ [P in keyof T]?: DeepPartial<T[P]>;
+ }
+ : T;
+
+/**
+ * Creates a deep, fully-typed mock of the CommandContext for use in tests.
+ * All functions are pre-mocked with `vi.fn()`.
+ *
+ * @param overrides - A deep partial object to override any default mock values.
+ * @returns A complete, mocked CommandContext object.
+ */
+export const createMockCommandContext = (
+ overrides: DeepPartial<CommandContext> = {},
+): CommandContext => {
+ const defaultMocks: CommandContext = {
+ services: {
+ config: null,
+ settings: { merged: {} } as LoadedSettings,
+ git: undefined as GitService | undefined,
+ logger: {
+ log: vi.fn(),
+ logMessage: vi.fn(),
+ saveCheckpoint: vi.fn(),
+ loadCheckpoint: vi.fn().mockResolvedValue([]),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any, // Cast because Logger is a class.
+ },
+ ui: {
+ addItem: vi.fn(),
+ clear: vi.fn(),
+ setDebugMessage: vi.fn(),
+ },
+ session: {
+ stats: {
+ sessionStartTime: new Date(),
+ lastPromptTokenCount: 0,
+ metrics: {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ },
+ } as SessionStatsState,
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const merge = (target: any, source: any): any => {
+ const output = { ...target };
+
+ for (const key in source) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ const sourceValue = source[key];
+ const targetValue = output[key];
+
+ if (
+ sourceValue &&
+ typeof sourceValue === 'object' &&
+ !Array.isArray(sourceValue) &&
+ targetValue &&
+ typeof targetValue === 'object' &&
+ !Array.isArray(targetValue)
+ ) {
+ output[key] = merge(targetValue, sourceValue);
+ } else {
+ output[key] = sourceValue;
+ }
+ }
+ }
+ return output;
+ };
+
+ return merge(defaultMocks, overrides);
+};
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index ecd56f5e..8390dac1 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -128,6 +128,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
setFlashFallbackHandler: vi.fn(),
+ getSessionId: vi.fn(() => 'test-session-id'),
};
});
return {
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 98d6a150..feb132ae 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -265,6 +265,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
handleSlashCommand,
slashCommands,
pendingHistoryItems: pendingSlashCommandHistoryItems,
+ commandContext,
} = useSlashCommandProcessor(
config,
settings,
@@ -278,7 +279,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
openThemeDialog,
openAuthDialog,
openEditorDialog,
- performMemoryRefresh,
toggleCorgiMode,
showToolDescriptions,
setQuittingMessages,
@@ -326,9 +326,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const quitCommand = slashCommands.find(
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
);
- if (quitCommand) {
- quitCommand.action('quit', '', '');
+ if (quitCommand && quitCommand.action) {
+ quitCommand.action(commandContext, '');
} else {
+ // This is unlikely to be needed but added for an additional fallback.
process.exit(0);
}
} else {
@@ -339,7 +340,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
}, CTRL_EXIT_PROMPT_DURATION_MS);
}
},
- [slashCommands],
+ // Add commandContext to the dependency array here!
+ [slashCommands, commandContext],
);
useInput((input: string, key: InkKeyType) => {
@@ -775,6 +777,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
onClearScreen={handleClearScreen}
config={config}
slashCommands={slashCommands}
+ commandContext={commandContext}
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
/>
diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts
new file mode 100644
index 00000000..8019dd68
--- /dev/null
+++ b/packages/cli/src/ui/commands/clearCommand.test.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import { clearCommand } from './clearCommand.js';
+import { type CommandContext } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { GeminiClient } from '@google/gemini-cli-core';
+
+describe('clearCommand', () => {
+ let mockContext: CommandContext;
+ let mockResetChat: ReturnType<typeof vi.fn>;
+
+ beforeEach(() => {
+ mockResetChat = vi.fn().mockResolvedValue(undefined);
+
+ mockContext = createMockCommandContext({
+ services: {
+ config: {
+ getGeminiClient: () =>
+ ({
+ resetChat: mockResetChat,
+ }) as unknown as GeminiClient,
+ },
+ },
+ });
+ });
+
+ it('should set debug message, reset chat, and clear UI when config is available', async () => {
+ if (!clearCommand.action) {
+ throw new Error('clearCommand must have an action.');
+ }
+
+ await clearCommand.action(mockContext, '');
+
+ expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
+ 'Clearing terminal and resetting chat.',
+ );
+ expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
+
+ expect(mockResetChat).toHaveBeenCalledTimes(1);
+
+ expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
+
+ // Check the order of operations.
+ const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
+ .invocationCallOrder[0];
+ const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
+ const clearOrder = (mockContext.ui.clear as Mock).mock
+ .invocationCallOrder[0];
+
+ expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
+ expect(resetChatOrder).toBeLessThan(clearOrder);
+ });
+
+ it('should not attempt to reset chat if config service is not available', async () => {
+ if (!clearCommand.action) {
+ throw new Error('clearCommand must have an action.');
+ }
+
+ const nullConfigContext = createMockCommandContext({
+ services: {
+ config: null,
+ },
+ });
+
+ await clearCommand.action(nullConfigContext, '');
+
+ expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
+ 'Clearing terminal and resetting chat.',
+ );
+ expect(mockResetChat).not.toHaveBeenCalled();
+ expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts
new file mode 100644
index 00000000..e5473b5b
--- /dev/null
+++ b/packages/cli/src/ui/commands/clearCommand.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SlashCommand } from './types.js';
+
+export const clearCommand: SlashCommand = {
+ name: 'clear',
+ description: 'clear the screen and conversation history',
+ action: async (context, _args) => {
+ context.ui.setDebugMessage('Clearing terminal and resetting chat.');
+ await context.services.config?.getGeminiClient()?.resetChat();
+ context.ui.clear();
+ },
+};
diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts
new file mode 100644
index 00000000..a6b19c05
--- /dev/null
+++ b/packages/cli/src/ui/commands/helpCommand.test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { helpCommand } from './helpCommand.js';
+import { type CommandContext } from './types.js';
+
+describe('helpCommand', () => {
+ let mockContext: CommandContext;
+
+ beforeEach(() => {
+ mockContext = {} as unknown as CommandContext;
+ });
+
+ it("should return a dialog action and log a debug message for '/help'", () => {
+ const consoleDebugSpy = vi
+ .spyOn(console, 'debug')
+ .mockImplementation(() => {});
+ if (!helpCommand.action) {
+ throw new Error('Help command has no action');
+ }
+ const result = helpCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'dialog',
+ dialog: 'help',
+ });
+ expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
+ });
+
+ it("should also be triggered by its alternative name '?'", () => {
+ // This test is more conceptual. The routing of altName to the command
+ // is handled by the slash command processor, but we can assert the
+ // altName is correctly defined on the command object itself.
+ expect(helpCommand.altName).toBe('?');
+ });
+});
diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts
new file mode 100644
index 00000000..82d0d536
--- /dev/null
+++ b/packages/cli/src/ui/commands/helpCommand.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { OpenDialogActionReturn, SlashCommand } from './types.js';
+
+export const helpCommand: SlashCommand = {
+ name: 'help',
+ altName: '?',
+ description: 'for help on gemini-cli',
+ action: (_context, _args): OpenDialogActionReturn => {
+ console.debug('Opening help UI ...');
+ return {
+ type: 'dialog',
+ dialog: 'help',
+ };
+ },
+};
diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts
new file mode 100644
index 00000000..47d098b1
--- /dev/null
+++ b/packages/cli/src/ui/commands/memoryCommand.test.ts
@@ -0,0 +1,249 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import { memoryCommand } from './memoryCommand.js';
+import { type CommandContext, SlashCommand } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { MessageType } from '../types.js';
+import { getErrorMessage } from '@google/gemini-cli-core';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
+ return {
+ ...original,
+ getErrorMessage: vi.fn((error: unknown) => {
+ if (error instanceof Error) return error.message;
+ return String(error);
+ }),
+ };
+});
+
+describe('memoryCommand', () => {
+ let mockContext: CommandContext;
+
+ const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => {
+ const subCommand = memoryCommand.subCommands?.find(
+ (cmd) => cmd.name === name,
+ );
+ if (!subCommand) {
+ throw new Error(`/memory ${name} command not found.`);
+ }
+ return subCommand;
+ };
+
+ describe('/memory show', () => {
+ let showCommand: SlashCommand;
+ let mockGetUserMemory: Mock;
+ let mockGetGeminiMdFileCount: Mock;
+
+ beforeEach(() => {
+ showCommand = getSubCommand('show');
+
+ mockGetUserMemory = vi.fn();
+ mockGetGeminiMdFileCount = vi.fn();
+
+ mockContext = createMockCommandContext({
+ services: {
+ config: {
+ getUserMemory: mockGetUserMemory,
+ getGeminiMdFileCount: mockGetGeminiMdFileCount,
+ },
+ },
+ });
+ });
+
+ it('should display a message if memory is empty', async () => {
+ if (!showCommand.action) throw new Error('Command has no action');
+
+ mockGetUserMemory.mockReturnValue('');
+ mockGetGeminiMdFileCount.mockReturnValue(0);
+
+ await showCommand.action(mockContext, '');
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Memory is currently empty.',
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('should display the memory content and file count if it exists', async () => {
+ if (!showCommand.action) throw new Error('Command has no action');
+
+ const memoryContent = 'This is a test memory.';
+
+ mockGetUserMemory.mockReturnValue(memoryContent);
+ mockGetGeminiMdFileCount.mockReturnValue(1);
+
+ await showCommand.action(mockContext, '');
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`,
+ },
+ expect.any(Number),
+ );
+ });
+ });
+
+ describe('/memory add', () => {
+ let addCommand: SlashCommand;
+
+ beforeEach(() => {
+ addCommand = getSubCommand('add');
+ mockContext = createMockCommandContext();
+ });
+
+ it('should return an error message if no arguments are provided', () => {
+ if (!addCommand.action) throw new Error('Command has no action');
+
+ const result = addCommand.action(mockContext, ' ');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'Usage: /memory add <text to remember>',
+ });
+
+ expect(mockContext.ui.addItem).not.toHaveBeenCalled();
+ });
+
+ it('should return a tool action and add an info message when arguments are provided', () => {
+ if (!addCommand.action) throw new Error('Command has no action');
+
+ const fact = 'remember this';
+ const result = addCommand.action(mockContext, ` ${fact} `);
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: `Attempting to save to memory: "${fact}"`,
+ },
+ expect.any(Number),
+ );
+
+ expect(result).toEqual({
+ type: 'tool',
+ toolName: 'save_memory',
+ toolArgs: { fact },
+ });
+ });
+ });
+
+ describe('/memory refresh', () => {
+ let refreshCommand: SlashCommand;
+ let mockRefreshMemory: Mock;
+
+ beforeEach(() => {
+ refreshCommand = getSubCommand('refresh');
+ mockRefreshMemory = vi.fn();
+ mockContext = createMockCommandContext({
+ services: {
+ config: {
+ refreshMemory: mockRefreshMemory,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any,
+ },
+ });
+ });
+
+ it('should display success message when memory is refreshed with content', async () => {
+ if (!refreshCommand.action) throw new Error('Command has no action');
+
+ const refreshResult = {
+ memoryContent: 'new memory content',
+ fileCount: 2,
+ };
+ mockRefreshMemory.mockResolvedValue(refreshResult);
+
+ await refreshCommand.action(mockContext, '');
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Refreshing memory from source files...',
+ },
+ expect.any(Number),
+ );
+
+ expect(mockRefreshMemory).toHaveBeenCalledOnce();
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('should display success message when memory is refreshed with no content', async () => {
+ if (!refreshCommand.action) throw new Error('Command has no action');
+
+ const refreshResult = { memoryContent: '', fileCount: 0 };
+ mockRefreshMemory.mockResolvedValue(refreshResult);
+
+ await refreshCommand.action(mockContext, '');
+
+ expect(mockRefreshMemory).toHaveBeenCalledOnce();
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Memory refreshed successfully. No memory content found.',
+ },
+ expect.any(Number),
+ );
+ });
+
+ it('should display an error message if refreshing fails', async () => {
+ if (!refreshCommand.action) throw new Error('Command has no action');
+
+ const error = new Error('Failed to read memory files.');
+ mockRefreshMemory.mockRejectedValue(error);
+
+ await refreshCommand.action(mockContext, '');
+
+ expect(mockRefreshMemory).toHaveBeenCalledOnce();
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.ERROR,
+ text: `Error refreshing memory: ${error.message}`,
+ },
+ expect.any(Number),
+ );
+
+ expect(getErrorMessage).toHaveBeenCalledWith(error);
+ });
+
+ it('should not throw if config service is unavailable', async () => {
+ if (!refreshCommand.action) throw new Error('Command has no action');
+
+ const nullConfigContext = createMockCommandContext({
+ services: { config: null },
+ });
+
+ await expect(
+ refreshCommand.action(nullConfigContext, ''),
+ ).resolves.toBeUndefined();
+
+ expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Refreshing memory from source files...',
+ },
+ expect.any(Number),
+ );
+
+ expect(mockRefreshMemory).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts
new file mode 100644
index 00000000..18ca96bb
--- /dev/null
+++ b/packages/cli/src/ui/commands/memoryCommand.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { getErrorMessage } from '@google/gemini-cli-core';
+import { MessageType } from '../types.js';
+import { SlashCommand, SlashCommandActionReturn } from './types.js';
+
+export const memoryCommand: SlashCommand = {
+ name: 'memory',
+ description: 'Commands for interacting with memory.',
+ subCommands: [
+ {
+ name: 'show',
+ description: 'Show the current memory contents.',
+ action: async (context) => {
+ const memoryContent = context.services.config?.getUserMemory() || '';
+ const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
+
+ const messageContent =
+ memoryContent.length > 0
+ ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
+ : 'Memory is currently empty.';
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: messageContent,
+ },
+ Date.now(),
+ );
+ },
+ },
+ {
+ name: 'add',
+ description: 'Add content to the memory.',
+ action: (context, args): SlashCommandActionReturn | void => {
+ if (!args || args.trim() === '') {
+ return {
+ type: 'message',
+ messageType: 'error',
+ content: 'Usage: /memory add <text to remember>',
+ };
+ }
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Attempting to save to memory: "${args.trim()}"`,
+ },
+ Date.now(),
+ );
+
+ return {
+ type: 'tool',
+ toolName: 'save_memory',
+ toolArgs: { fact: args.trim() },
+ };
+ },
+ },
+ {
+ name: 'refresh',
+ description: 'Refresh the memory from the source.',
+ action: async (context) => {
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: 'Refreshing memory from source files...',
+ },
+ Date.now(),
+ );
+
+ try {
+ const result = await context.services.config?.refreshMemory();
+
+ if (result) {
+ const { memoryContent, fileCount } = result;
+ const successMessage =
+ memoryContent.length > 0
+ ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
+ : 'Memory refreshed successfully. No memory content found.';
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: successMessage,
+ },
+ Date.now(),
+ );
+ }
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ context.ui.addItem(
+ {
+ type: MessageType.ERROR,
+ text: `Error refreshing memory: ${errorMessage}`,
+ },
+ Date.now(),
+ );
+ }
+ },
+ },
+ ],
+};
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
new file mode 100644
index 00000000..09682d7a
--- /dev/null
+++ b/packages/cli/src/ui/commands/types.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Config, GitService, Logger } from '@google/gemini-cli-core';
+import { LoadedSettings } from '../../config/settings.js';
+import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
+import { SessionStatsState } from '../contexts/SessionContext.js';
+
+// Grouped dependencies for clarity and easier mocking
+export interface CommandContext {
+ // Core services and configuration
+ services: {
+ // TODO(abhipatel12): Ensure that config is never null.
+ config: Config | null;
+ settings: LoadedSettings;
+ git: GitService | undefined;
+ logger: Logger;
+ };
+ // UI state and history management
+ ui: {
+ // TODO - As more commands are add some additions may be needed or reworked using this new context.
+ // Ex.
+ // history: HistoryItem[];
+ // pendingHistoryItems: HistoryItemWithoutId[];
+
+ /** Adds a new item to the history display. */
+ addItem: UseHistoryManagerReturn['addItem'];
+ /** Clears all history items and the console screen. */
+ clear: () => void;
+ /**
+ * Sets the transient debug message displayed in the application footer in debug mode.
+ */
+ setDebugMessage: (message: string) => void;
+ };
+ // Session-specific data
+ session: {
+ stats: SessionStatsState;
+ };
+}
+
+/**
+ * The return type for a command action that results in scheduling a tool call.
+ */
+export interface ToolActionReturn {
+ type: 'tool';
+ toolName: string;
+ toolArgs: Record<string, unknown>;
+}
+
+/**
+ * The return type for a command action that results in a simple message
+ * being displayed to the user.
+ */
+export interface MessageActionReturn {
+ type: 'message';
+ messageType: 'info' | 'error';
+ content: string;
+}
+
+/**
+ * The return type for a command action that needs to open a dialog.
+ */
+export interface OpenDialogActionReturn {
+ type: 'dialog';
+ // TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
+ dialog: 'help';
+}
+
+export type SlashCommandActionReturn =
+ | ToolActionReturn
+ | MessageActionReturn
+ | OpenDialogActionReturn;
+
+// The standardized contract for any command in the system.
+export interface SlashCommand {
+ name: string;
+ altName?: string;
+ description?: string;
+
+ // The action to run. Optional for parent commands that only group sub-commands.
+ action?: (
+ context: CommandContext,
+ args: string,
+ ) =>
+ | void
+ | SlashCommandActionReturn
+ | Promise<void | SlashCommandActionReturn>;
+
+ // Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
+ completion?: (
+ context: CommandContext,
+ partialArg: string,
+ ) => Promise<string[]>;
+
+ subCommands?: SlashCommand[];
+}
diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx
index 3a3d7bd1..5a04514a 100644
--- a/packages/cli/src/ui/components/Help.tsx
+++ b/packages/cli/src/ui/components/Help.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
-import { SlashCommand } from '../hooks/slashCommandProcessor.js';
+import { SlashCommand } from '../commands/types.js';
interface Help {
commands: SlashCommand[];
@@ -67,13 +67,25 @@ export const Help: React.FC<Help> = ({ commands }) => (
{commands
.filter((command) => command.description)
.map((command: SlashCommand) => (
- <Text key={command.name} color={Colors.Foreground}>
- <Text bold color={Colors.AccentPurple}>
- {' '}
- /{command.name}
+ <Box key={command.name} flexDirection="column">
+ <Text color={Colors.Foreground}>
+ <Text bold color={Colors.AccentPurple}>
+ {' '}
+ /{command.name}
+ </Text>
+ {command.description && ' - ' + command.description}
</Text>
- {command.description && ' - ' + command.description}
- </Text>
+ {command.subCommands &&
+ command.subCommands.map((subCommand) => (
+ <Text key={subCommand.name} color={Colors.Foreground}>
+ <Text> </Text>
+ <Text bold color={Colors.AccentPurple}>
+ {subCommand.name}
+ </Text>
+ {subCommand.description && ' - ' + subCommand.description}
+ </Text>
+ ))}
+ </Box>
))}
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 7d0cfcbb..6f3f996d 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -8,10 +8,12 @@ import { render } from 'ink-testing-library';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@google/gemini-cli-core';
+import { CommandContext, SlashCommand } from '../commands/types.js';
import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
@@ -21,12 +23,38 @@ type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
+const mockSlashCommands: SlashCommand[] = [
+ { name: 'clear', description: 'Clear screen', action: vi.fn() },
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory', action: vi.fn() },
+ { name: 'add', description: 'Add to memory', action: vi.fn() },
+ { name: 'refresh', description: 'Refresh memory', action: vi.fn() },
+ ],
+ },
+ {
+ name: 'chat',
+ description: 'Manage chats',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a chat',
+ action: vi.fn(),
+ completion: async () => ['fix-foo', 'fix-bar'],
+ },
+ ],
+ },
+];
+
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: MockedUseShellHistory;
let mockCompletion: MockedUseCompletion;
let mockInputHistory: MockedUseInputHistory;
let mockBuffer: TextBuffer;
+ let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
@@ -35,6 +63,8 @@ describe('InputPrompt', () => {
beforeEach(() => {
vi.resetAllMocks();
+ mockCommandContext = createMockCommandContext();
+
mockBuffer = {
text: '',
cursor: [0, 0],
@@ -99,12 +129,15 @@ describe('InputPrompt', () => {
getTargetDir: () => '/test/project/src',
} as unknown as Config,
slashCommands: [],
+ commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
};
+
+ props.slashCommands = mockSlashCommands;
});
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -184,4 +217,194 @@ describe('InputPrompt', () => {
expect(props.onSubmit).toHaveBeenCalledWith('some text');
unmount();
});
+
+ it('should complete a partial parent command and add a space', async () => {
+ // SCENARIO: /mem -> Tab
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
+ unmount();
+ });
+
+ it('should append a sub-command when the parent command is already complete with a space', async () => {
+ // SCENARIO: /memory -> Tab (to accept 'add')
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeSuggestionIndex: 1, // 'add' is highlighted
+ });
+ props.buffer.setText('/memory ');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
+ unmount();
+ });
+
+ it('should handle the "backspace" edge case correctly', async () => {
+ // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
+ // This is the critical bug we fixed.
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeSuggestionIndex: 0, // 'show' is highlighted
+ });
+ // The user has backspaced, so the query is now just '/memory'
+ props.buffer.setText('/memory');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ // It should NOT become '/show '. It should correctly become '/memory show '.
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
+ unmount();
+ });
+
+ it('should complete a partial argument for a command', async () => {
+ // SCENARIO: /chat resume fi- -> Tab
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/chat resume fi-');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
+ unmount();
+ });
+
+ it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'memory', value: 'memory' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ // The app should autocomplete the text, NOT submit.
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should complete a command based on its altName', async () => {
+ // Add a command with an altName to our mock for this test
+ props.slashCommands.push({
+ name: 'help',
+ altName: '?',
+ description: '...',
+ });
+
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'help', value: 'help' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/?');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\t'); // Press Tab
+ await wait();
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
+ unmount();
+ });
+
+ // ADD this test for defensive coverage
+
+ it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
+ props.buffer.setText(' '); // Set buffer to whitespace
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r'); // Press Enter
+ await wait();
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should submit directly on Enter when a complete leaf command is typed', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: false,
+ });
+ props.buffer.setText('/clear');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ expect(props.onSubmit).toHaveBeenCalledWith('/clear');
+ expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
+ unmount();
+ });
+
+ it('should autocomplete an @-path on Enter without submitting', async () => {
+ mockedUseCompletion.mockReturnValue({
+ ...mockCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'index.ts', value: 'index.ts' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('@src/components/');
+
+ const { stdin, unmount } = render(<InputPrompt {...props} />);
+ await wait();
+
+ stdin.write('\r');
+ await wait();
+
+ expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 763d4e7e..3771f5b9 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -13,12 +13,11 @@ import { TextBuffer } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
-import process from 'node:process';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
-import { SlashCommand } from '../hooks/slashCommandProcessor.js';
+import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
export interface InputPromptProps {
@@ -26,8 +25,9 @@ export interface InputPromptProps {
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
- config: Config; // Added config for useCompletion
- slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
+ config: Config;
+ slashCommands: SlashCommand[];
+ commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
@@ -43,6 +43,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onClearScreen,
config,
slashCommands,
+ commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
@@ -57,6 +58,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
config.getTargetDir(),
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
slashCommands,
+ commandContext,
config,
);
@@ -116,28 +118,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
- const parts = query.trimStart().substring(1).split(' ');
- const commandName = parts[0];
- const slashIndex = query.indexOf('/');
- const base = query.substring(0, slashIndex + 1);
+ const hasTrailingSpace = query.endsWith(' ');
+ const parts = query
+ .trimStart()
+ .substring(1)
+ .split(/\s+/)
+ .filter(Boolean);
- const command = slashCommands.find((cmd) => cmd.name === commandName);
- // Make sure completion isn't the original command when command.completion hasn't happened yet.
- if (command && command.completion && suggestion !== commandName) {
- const newValue = `${base}${commandName} ${suggestion}`;
- if (newValue === query) {
- handleSubmitAndClear(newValue);
- } else {
- buffer.setText(newValue);
- }
- } else {
- const newValue = base + suggestion;
- if (newValue === query) {
- handleSubmitAndClear(newValue);
- } else {
- buffer.setText(newValue);
+ let isParentPath = false;
+ // If there's no trailing space, we need to check if the current query
+ // is already a complete path to a parent command.
+ if (!hasTrailingSpace) {
+ let currentLevel: SlashCommand[] | undefined = slashCommands;
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const found: SlashCommand | undefined = currentLevel?.find(
+ (cmd) => cmd.name === part || cmd.altName === part,
+ );
+
+ if (found) {
+ if (i === parts.length - 1 && found.subCommands) {
+ isParentPath = true;
+ }
+ currentLevel = found.subCommands;
+ } else {
+ // Path is invalid, so it can't be a parent path.
+ currentLevel = undefined;
+ break;
+ }
}
}
+
+ // Determine the base path of the command.
+ // - If there's a trailing space, the whole command is the base.
+ // - If it's a known parent path, the whole command is the base.
+ // - Otherwise, the base is everything EXCEPT the last partial part.
+ const basePath =
+ hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
+ const newValue = `/${[...basePath, suggestion].join(' ')} `;
+
+ buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
@@ -155,13 +175,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
resetCompletionState();
},
- [
- resetCompletionState,
- handleSubmitAndClear,
- buffer,
- completionSuggestions,
- slashCommands,
- ],
+ [resetCompletionState, buffer, completionSuggestions, slashCommands],
);
const handleInput = useCallback(
@@ -169,12 +183,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (!focus) {
return;
}
- const query = buffer.text;
- if (key.sequence === '!' && query === '' && !completion.showSuggestions) {
+ if (
+ key.sequence === '!' &&
+ buffer.text === '' &&
+ !completion.showSuggestions
+ ) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
- return true;
+ return;
+ }
+
+ if (key.name === 'escape') {
+ if (shellModeActive) {
+ setShellModeActive(false);
+ return;
+ }
+
+ if (completion.showSuggestions) {
+ completion.resetCompletionState();
+ return;
+ }
+ }
+
+ if (key.ctrl && key.name === 'l') {
+ onClearScreen();
+ return;
}
if (completion.showSuggestions) {
@@ -186,11 +220,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.navigateDown();
return;
}
- if (key.name === 'tab') {
+
+ if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
- ? 0
+ ? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex);
@@ -198,67 +233,72 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
return;
}
- if (key.name === 'return') {
- if (completion.activeSuggestionIndex >= 0) {
- handleAutocomplete(completion.activeSuggestionIndex);
- } else if (query.trim()) {
- handleSubmitAndClear(query);
- }
- return;
- }
} else {
- // Keybindings when suggestions are not shown
- if (key.ctrl && key.name === 'l') {
- onClearScreen();
- return;
- }
- if (key.ctrl && key.name === 'p') {
- inputHistory.navigateUp();
- return;
- }
- if (key.ctrl && key.name === 'n') {
- inputHistory.navigateDown();
- return;
- }
- if (key.name === 'escape') {
- if (shellModeActive) {
- setShellModeActive(false);
+ if (!shellModeActive) {
+ if (key.ctrl && key.name === 'p') {
+ inputHistory.navigateUp();
return;
}
- completion.resetCompletionState();
+ if (key.ctrl && key.name === 'n') {
+ inputHistory.navigateDown();
+ return;
+ }
+ // Handle arrow-up/down for history on single-line or at edges
+ if (
+ key.name === 'up' &&
+ (buffer.allVisualLines.length === 1 ||
+ (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
+ ) {
+ inputHistory.navigateUp();
+ return;
+ }
+ if (
+ key.name === 'down' &&
+ (buffer.allVisualLines.length === 1 ||
+ buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
+ ) {
+ inputHistory.navigateDown();
+ return;
+ }
+ } else {
+ // Shell History Navigation
+ if (key.name === 'up') {
+ const prevCommand = shellHistory.getPreviousCommand();
+ if (prevCommand !== null) buffer.setText(prevCommand);
+ return;
+ }
+ if (key.name === 'down') {
+ const nextCommand = shellHistory.getNextCommand();
+ if (nextCommand !== null) buffer.setText(nextCommand);
+ return;
+ }
+ }
+
+ if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
+ if (buffer.text.trim()) {
+ handleSubmitAndClear(buffer.text);
+ }
return;
}
}
- // Ctrl+A (Home)
+ // Newline insertion
+ if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
+ buffer.newline();
+ return;
+ }
+
+ // Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
buffer.move('home');
- buffer.moveToOffset(0);
return;
}
- // Ctrl+E (End)
if (key.ctrl && key.name === 'e') {
buffer.move('end');
- buffer.moveToOffset(cpLen(buffer.text));
- return;
- }
- // Ctrl+L (Clear Screen)
- if (key.ctrl && key.name === 'l') {
- onClearScreen();
- return;
- }
- // Ctrl+P (History Up)
- if (key.ctrl && key.name === 'p' && !completion.showSuggestions) {
- inputHistory.navigateUp();
- return;
- }
- // Ctrl+N (History Down)
- if (key.ctrl && key.name === 'n' && !completion.showSuggestions) {
- inputHistory.navigateDown();
return;
}
- // Core text editing from MultilineTextEditor's useInput
+ // Kill line commands
if (key.ctrl && key.name === 'k') {
buffer.killLineRight();
return;
@@ -267,97 +307,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.killLineLeft();
return;
}
- const isCtrlX =
- (key.ctrl && (key.name === 'x' || key.sequence === '\x18')) ||
- key.sequence === '\x18';
- const isCtrlEFromEditor =
- (key.ctrl && (key.name === 'e' || key.sequence === '\x05')) ||
- key.sequence === '\x05' ||
- (!key.ctrl &&
- key.name === 'e' &&
- key.sequence.length === 1 &&
- key.sequence.charCodeAt(0) === 5);
-
- if (isCtrlX || isCtrlEFromEditor) {
- if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) {
- // Avoid double handling Ctrl+E
- buffer.openInExternalEditor();
- return;
- }
- if (isCtrlX) {
- buffer.openInExternalEditor();
- return;
- }
- }
-
- if (
- process.env['TEXTBUFFER_DEBUG'] === '1' ||
- process.env['TEXTBUFFER_DEBUG'] === 'true'
- ) {
- console.log('[InputPromptCombined] event', { key });
- }
-
- // Ctrl+Enter for newline, Enter for submit
- if (key.name === 'return') {
- const [row, col] = buffer.cursor;
- const line = buffer.lines[row];
- const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
- if (key.ctrl || key.meta || charBefore === '\\' || key.paste) {
- // Ctrl+Enter or escaped newline
- if (charBefore === '\\') {
- buffer.backspace();
- }
- buffer.newline();
- } else {
- // Enter for submit
- if (query.trim()) {
- handleSubmitAndClear(query);
- }
- }
- return;
- }
- // Standard arrow navigation within the buffer
- if (key.name === 'up' && !completion.showSuggestions) {
- if (shellModeActive) {
- const prevCommand = shellHistory.getPreviousCommand();
- if (prevCommand !== null) {
- buffer.setText(prevCommand);
- }
- return;
- }
- if (
- (buffer.allVisualLines.length === 1 || // Always navigate for single line
- (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
- inputHistory.navigateUp
- ) {
- inputHistory.navigateUp();
- } else {
- buffer.move('up');
- }
- return;
- }
- if (key.name === 'down' && !completion.showSuggestions) {
- if (shellModeActive) {
- const nextCommand = shellHistory.getNextCommand();
- if (nextCommand !== null) {
- buffer.setText(nextCommand);
- }
- return;
- }
- if (
- (buffer.allVisualLines.length === 1 || // Always navigate for single line
- buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
- inputHistory.navigateDown
- ) {
- inputHistory.navigateDown();
- } else {
- buffer.move('down');
- }
+ // External editor
+ const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
+ if (isCtrlX) {
+ buffer.openInExternalEditor();
return;
}
- // Fallback to buffer's default input handling
+ // Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
[
diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx
index b89d19e7..320df324 100644
--- a/packages/cli/src/ui/contexts/SessionContext.tsx
+++ b/packages/cli/src/ui/contexts/SessionContext.tsx
@@ -22,7 +22,7 @@ import {
export type { SessionMetrics, ModelMetrics };
-interface SessionStatsState {
+export interface SessionStatsState {
sessionStartTime: Date;
metrics: SessionMetrics;
lastPromptTokenCount: number;
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index d10ae22b..137098df 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -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;
+ let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
- commandResult = await handleSlashCommand('/help');
+ commandResult = await handleSlashCommand('/editor');
});
- expect(mockSetShowHelp).toHaveBeenCalledWith(true);
- expect(commandResult).toBe(true);
+ expect(mockOpenEditorDialog).toHaveBeenCalled();
+ expect(commandResult).toEqual({ type: 'handled' });
});
+ });
- it('/clear should clear items, reset chat, and refresh static', async () => {
- const mockResetChat = vi.fn();
- mockConfig = {
- ...mockConfig,
- getGeminiClient: () => ({
- resetChat: mockResetChat,
- }),
- } as unknown as Config;
+ describe('New command registry', () => {
+ let ActualCommandService: typeof CommandService;
- const { handleSlashCommand } = getProcessor();
- let commandResult: SlashCommandActionReturn | boolean = false;
+ 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 handleSlashCommand('/clear');
+ commandResult = await result.current.handleSlashCommand('/test');
});
- expect(mockClearItems).toHaveBeenCalled();
- expect(mockResetChat).toHaveBeenCalled();
- expect(mockRefreshStatic).toHaveBeenCalled();
- expect(commandResult).toBe(true);
+ expect(mockAction).toHaveBeenCalledTimes(1);
+ expect(commandResult).toEqual({ type: 'handled' });
});
- it('/editor should open editor dialog and return true', async () => {
- const { handleSlashCommand } = getProcessor();
- let commandResult: SlashCommandActionReturn | boolean = false;
+ 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 () => {
- commandResult = await handleSlashCommand('/editor');
+ await result.current.handleSlashCommand('/parent');
});
- expect(mockOpenEditorDialog).toHaveBeenCalled();
- expect(commandResult).toBe(true);
+
+ 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 () => {
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 01378d89..c174b8a4 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -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',
@@ -226,17 +243,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',
action: (_mainCommand, _subCommand, _args) => {
@@ -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',
@@ -494,38 +494,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',
action: async (_mainCommand, _subCommand, _args) => {
@@ -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];
+ // --- Start of New Tree Traversal Logic ---
+
+ let currentCommands = commands;
+ let commandToExecute: SlashCommand | undefined;
+ let pathIndex = 0;
+
+ 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;
+ }
+ } else {
+ break;
}
- if (parts.length > 2) {
- args = parts.slice(2).join(' ');
+ }
+
+ 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' };
}
- return parts[0];
- })();
+ }
+
+ // --- End of New Tree Traversal Logic ---
- const mainCommand = commandToMatch;
+ // --- Legacy Fallback Logic (for commands not yet migrated) ---
- for (const cmd of slashCommands) {
+ 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, args);
- if (
- typeof actionResult === 'object' &&
- actionResult?.shouldScheduleTool
- ) {
- return actionResult; // Return the object for useGeminiStream
+ const actionResult = await cmd.action(
+ mainCommand,
+ subCommand,
+ legacyArgs,
+ );
+
+ if (actionResult?.type === 'tool') {
+ return {
+ type: 'schedule_tool',
+ toolName: actionResult.toolName,
+ toolArgs: actionResult.toolArgs,
+ };
}
- return true; // Command was handled, but no tool to schedule
+ 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,
+ };
};
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
index f5864a58..705b2735 100644
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
@@ -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);
+ });
});
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index fd826c92..1f6e570d 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -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)
+ cmd.description &&
+ (cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)),
+ );
+
+ // If a user's input is an exact match and it is a leaf command,
+ // enter should submit immediately.
+ if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
+ const perfectMatch = potentialSuggestions.find(
+ (s) => s.name === partial,
);
- })
- .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
+ if (perfectMatch && !perfectMatch.subCommands) {
+ potentialSuggestions = [];
+ }
+ }
+
+ const finalSuggestions = potentialSuggestions.map((cmd) => ({
+ label: cmd.name,
+ value: cmd.name,
description: cmd.description,
- }))
- .sort((a, b) => a.label.localeCompare(b.label));
+ }));
- setSuggestions(filteredSuggestions);
- setShowSuggestions(filteredSuggestions.length > 0);
- setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1);
- setVisibleStartIndex(0);
- setIsLoadingSuggestions(false);
+ 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,
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 6a41234b..3a002919 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -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;
+ await act(async () => {
+ await result.current.submitQuery('/memory add "test fact"');
+ });
- mockUseReactToolScheduler.mockImplementation((onComplete) => {
- capturedOnComplete = onComplete;
- return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
+ await waitFor(() => {
+ expect(mockScheduleToolCalls).toHaveBeenCalledWith(
+ [
+ expect.objectContaining({
+ name: 'save_memory',
+ args: { fact: 'test fact' },
+ isClientInitiated: true,
+ }),
+ ],
+ expect.any(AbortSignal),
+ );
+ expect(mockSendMessageStream).not.toHaveBeenCalled();
});
+ });
- const { result } = renderHook(() =>
- useGeminiStream(
- new MockedGeminiClientClass(mockConfig),
- [],
- mockAddItem,
- mockSetShowHelp,
- mockConfig,
- mockOnDebugMessage,
- mockHandleSlashCommand,
- false,
- () => 'vscode' as EditorType,
- () => {},
- () => Promise.resolve(),
- ),
- );
+ 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);
- // --- User runs the slash command ---
- await act(async () => {
- await result.current.submitQuery('/memory add "test fact"');
- });
+ const { result } = renderTestHook();
- // Trigger the onComplete callback with the completed client-initiated tool
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
});
});
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index bba01bc9..b4acdb9a 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -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)) {
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index dd78c0c9..223ccd47 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -216,3 +216,16 @@ export interface ConsoleMessageItem {
content: string;
count: number;
}
+
+/**
+ * Defines the result of the slash command processor for its consumer (useGeminiStream).
+ */
+export type SlashCommandProcessorResult =
+ | {
+ type: 'schedule_tool';
+ toolName: string;
+ toolArgs: Record<string, unknown>;
+ }
+ | {
+ type: 'handled'; // Indicates the command was processed and no further action is needed.
+ };
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 9b576b96..c9debdbe 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { Config, ConfigParameters, SandboxConfig } from './config.js';
import * as path from 'path';
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
@@ -13,6 +13,8 @@ import {
DEFAULT_OTLP_ENDPOINT,
} from '../telemetry/index.js';
+import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
+
// Mock dependencies that might be called during Config construction or createServerConfig
vi.mock('../tools/tool-registry', () => {
const ToolRegistryMock = vi.fn();
@@ -24,6 +26,10 @@ vi.mock('../tools/tool-registry', () => {
return { ToolRegistry: ToolRegistryMock };
});
+vi.mock('../utils/memoryDiscovery.js', () => ({
+ loadServerHierarchicalMemory: vi.fn(),
+}));
+
// Mock individual tools if their constructors are complex or have side effects
vi.mock('../tools/ls');
vi.mock('../tools/read-file');
@@ -270,4 +276,38 @@ describe('Server Config (config.ts)', () => {
expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT);
});
});
+
+ describe('refreshMemory', () => {
+ it('should update memory and file count on successful refresh', async () => {
+ const config = new Config(baseParams);
+ const mockMemoryData = {
+ memoryContent: 'new memory content',
+ fileCount: 5,
+ };
+
+ (loadServerHierarchicalMemory as Mock).mockResolvedValue(mockMemoryData);
+
+ const result = await config.refreshMemory();
+
+ expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
+ config.getWorkingDir(),
+ config.getDebugMode(),
+ config.getFileService(),
+ config.getExtensionContextFilePaths(),
+ );
+
+ expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);
+ expect(config.getGeminiMdFileCount()).toBe(mockMemoryData.fileCount);
+ expect(result).toEqual(mockMemoryData);
+ });
+
+ it('should propagate errors from loadServerHierarchicalMemory', async () => {
+ const config = new Config(baseParams);
+ const testError = new Error('Failed to load memory');
+
+ (loadServerHierarchicalMemory as Mock).mockRejectedValue(testError);
+
+ await expect(config.refreshMemory()).rejects.toThrow(testError);
+ });
+ });
});
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index f2404bb0..fd96af91 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -30,6 +30,7 @@ import { WebSearchTool } from '../tools/web-search.js';
import { GeminiClient } from '../core/client.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
+import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import { getProjectTempDir } from '../utils/paths.js';
import {
initializeTelemetry,
@@ -454,6 +455,20 @@ export class Config {
}
return this.gitService;
}
+
+ async refreshMemory(): Promise<{ memoryContent: string; fileCount: number }> {
+ const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
+ this.getWorkingDir(),
+ this.getDebugMode(),
+ this.getFileService(),
+ this.getExtensionContextFilePaths(),
+ );
+
+ this.setUserMemory(memoryContent);
+ this.setGeminiMdFileCount(fileCount);
+
+ return { memoryContent, fileCount };
+ }
}
export function createToolRegistry(config: Config): Promise<ToolRegistry> {