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 => [ + clearCommand, + helpCommand, + memoryCommand, +]; + +export class CommandService { + private commands: SlashCommand[] = []; + + constructor( + private commandLoader: () => Promise = loadBuiltInCommands, + ) { + // The constructor can be used for dependency injection in the future. + } + + async loadCommands(): Promise { + // 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 extends object + ? { + [P in keyof T]?: DeepPartial; + } + : 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 => { + 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; + + 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(); + 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 ', + }); + + 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 ', + }; + } + + 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; +} + +/** + * 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; + + // Provides argument completion (e.g., completing a tag for `/chat resume `). + completion?: ( + context: CommandContext, + partialArg: string, + ) => Promise; + + 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 = ({ commands }) => ( {commands .filter((command) => command.description) .map((command: SlashCommand) => ( - - - {' '} - /{command.name} + + + + {' '} + /{command.name} + + {command.description && ' - ' + command.description} - {command.description && ' - ' + command.description} - + {command.subCommands && + command.subCommands.map((subCommand) => ( + + + + {subCommand.name} + + {subCommand.description && ' - ' + subCommand.description} + + ))} + ))} 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; type MockedUseCompletion = ReturnType; type MockedUseInputHistory = ReturnType; +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 = ({ onClearScreen, config, slashCommands, + commandContext, placeholder = ' Type your message or @path/to/file', focus = true, inputWidth, @@ -57,6 +58,7 @@ export const InputPrompt: React.FC = ({ config.getTargetDir(), isAtCommand(buffer.text) || isSlashCommand(buffer.text), slashCommands, + commandContext, config, ); @@ -116,28 +118,46 @@ export const InputPrompt: React.FC = ({ 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 = ({ } resetCompletionState(); }, - [ - resetCompletionState, - handleSubmitAndClear, - buffer, - completionSuggestions, - slashCommands, - ], + [resetCompletionState, buffer, completionSuggestions, slashCommands], ); const handleInput = useCallback( @@ -169,12 +183,32 @@ export const InputPrompt: React.FC = ({ 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 = ({ 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 = ({ } 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) - 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(); + // Newline insertion + if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) { + buffer.newline(); return; } - // Core text editing from MultilineTextEditor's useInput + // Ctrl+A (Home) / Ctrl+E (End) + if (key.ctrl && key.name === 'a') { + buffer.move('home'); + return; + } + if (key.ctrl && key.name === 'e') { + buffer.move('end'); + return; + } + + // Kill line commands if (key.ctrl && key.name === 'k') { buffer.killLineRight(); return; @@ -267,97 +307,15 @@ export const InputPrompt: React.FC = ({ 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); - } - } + // External editor + const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18'); + if (isCtrlX) { + buffer.openInExternalEditor(); 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'); - } - 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(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + getMCPDiscoveryState: vi.fn(), + }; +}); + describe('useSlashCommandProcessor', () => { let mockAddItem: ReturnType; let mockClearItems: ReturnType; @@ -97,7 +108,6 @@ describe('useSlashCommandProcessor', () => { let mockOpenThemeDialog: ReturnType; let mockOpenAuthDialog: ReturnType; let mockOpenEditorDialog: ReturnType; - let mockPerformMemoryRefresh: ReturnType; let mockSetQuittingMessages: ReturnType; let mockTryCompressChat: ReturnType; 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 ', - }), - expect.any(Number), - ); - expect(commandResult).toBe(true); // Command was handled (by showing an error) - }); - }); - - describe('/memory show', () => { - it('should call the showMemoryAction and return true', async () => { - const mockReturnedShowAction = vi.fn(); - vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue( - mockReturnedShowAction, - ); - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; - await act(async () => { - commandResult = await handleSlashCommand('/memory show'); - }); - expect( - ShowMemoryCommandModule.createShowMemoryAction, - ).toHaveBeenCalledWith( - mockConfig, - expect.any(Object), - expect.any(Function), - ); - expect(mockReturnedShowAction).toHaveBeenCalled(); - expect(commandResult).toBe(true); - }); - }); - - describe('/memory refresh', () => { - it('should call performMemoryRefresh and return true', async () => { - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; - await act(async () => { - commandResult = await handleSlashCommand('/memory refresh'); - }); - expect(mockPerformMemoryRefresh).toHaveBeenCalled(); - expect(commandResult).toBe(true); - }); - }); - - describe('Unknown /memory subcommand', () => { - it('should show an error for unknown /memory subcommand and return true', async () => { - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; - await act(async () => { - commandResult = await handleSlashCommand('/memory foobar'); - }); - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.ERROR, - text: 'Unknown /memory command: foobar. Available: show, refresh, add', - }), - expect.any(Number), - ); - expect(commandResult).toBe(true); - }); - }); - describe('/stats command', () => { it('should show detailed session statistics', async () => { // Arrange @@ -376,7 +290,7 @@ describe('useSlashCommandProcessor', () => { selectedAuthType: 'test-auth-type', contextFileName: 'GEMINI.md', }, - } as LoadedSettings; + } as unknown as LoadedSettings; const { result } = renderHook(() => useSlashCommandProcessor( @@ -392,10 +306,10 @@ describe('useSlashCommandProcessor', () => { mockOpenThemeDialog, mockOpenAuthDialog, mockOpenEditorDialog, - mockPerformMemoryRefresh, mockCorgiMode, false, mockSetQuittingMessages, + vi.fn(), // mockOpenPrivacyNotice ), ); @@ -447,45 +361,187 @@ describe('useSlashCommandProcessor', () => { }); describe('Other commands', () => { - it('/help should open help and return true', async () => { + it('/editor should open editor dialog and return handled', async () => { const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; - await act(async () => { - commandResult = await handleSlashCommand('/help'); - }); - expect(mockSetShowHelp).toHaveBeenCalledWith(true); - expect(commandResult).toBe(true); - }); - - it('/clear should clear items, reset chat, and refresh static', async () => { - const mockResetChat = vi.fn(); - mockConfig = { - ...mockConfig, - getGeminiClient: () => ({ - resetChat: mockResetChat, - }), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; - await act(async () => { - commandResult = await handleSlashCommand('/clear'); - }); - - expect(mockClearItems).toHaveBeenCalled(); - expect(mockResetChat).toHaveBeenCalled(); - expect(mockRefreshStatic).toHaveBeenCalled(); - expect(commandResult).toBe(true); - }); - - it('/editor should open editor dialog and return true', async () => { - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/editor'); }); expect(mockOpenEditorDialog).toHaveBeenCalled(); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); + }); + }); + + describe('New command registry', () => { + let ActualCommandService: typeof CommandService; + + beforeAll(async () => { + const actual = (await vi.importActual( + '../../services/CommandService.js', + )) as { CommandService: typeof CommandService }; + ActualCommandService = actual.CommandService; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should execute a command from the new registry', async () => { + const mockAction = vi.fn(); + const newCommand: SlashCommand = { name: 'test', action: mockAction }; + const mockLoader = async () => [newCommand]; + + // We create the instance outside the mock implementation. + const commandServiceInstance = new ActualCommandService(mockLoader); + + // This mock ensures the hook uses our pre-configured instance. + vi.mocked(CommandService).mockImplementation( + () => commandServiceInstance, + ); + + const { result } = getProcessorHook(); + + await vi.waitFor(() => { + // We check that the `slashCommands` array, which is the public API + // of our hook, eventually contains the command we injected. + expect( + result.current.slashCommands.some((c) => c.name === 'test'), + ).toBe(true); + }); + + let commandResult: SlashCommandProcessorResult | false = false; + await act(async () => { + commandResult = await result.current.handleSlashCommand('/test'); + }); + + expect(mockAction).toHaveBeenCalledTimes(1); + expect(commandResult).toEqual({ type: 'handled' }); + }); + + it('should return "schedule_tool" when a new command returns a tool action', async () => { + const mockAction = vi.fn().mockResolvedValue({ + type: 'tool', + toolName: 'my_tool', + toolArgs: { arg1: 'value1' }, + }); + const newCommand: SlashCommand = { name: 'test', action: mockAction }; + const mockLoader = async () => [newCommand]; + const commandServiceInstance = new ActualCommandService(mockLoader); + vi.mocked(CommandService).mockImplementation( + () => commandServiceInstance, + ); + + const { result } = getProcessorHook(); + await vi.waitFor(() => { + expect( + result.current.slashCommands.some((c) => c.name === 'test'), + ).toBe(true); + }); + + const commandResult = await result.current.handleSlashCommand('/test'); + + expect(mockAction).toHaveBeenCalledTimes(1); + expect(commandResult).toEqual({ + type: 'schedule_tool', + toolName: 'my_tool', + toolArgs: { arg1: 'value1' }, + }); + }); + + it('should return "handled" when a new command returns a message action', async () => { + const mockAction = vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'This is a message', + }); + const newCommand: SlashCommand = { name: 'test', action: mockAction }; + const mockLoader = async () => [newCommand]; + const commandServiceInstance = new ActualCommandService(mockLoader); + vi.mocked(CommandService).mockImplementation( + () => commandServiceInstance, + ); + + const { result } = getProcessorHook(); + await vi.waitFor(() => { + expect( + result.current.slashCommands.some((c) => c.name === 'test'), + ).toBe(true); + }); + + const commandResult = await result.current.handleSlashCommand('/test'); + + expect(mockAction).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'This is a message', + }), + expect.any(Number), + ); + expect(commandResult).toEqual({ type: 'handled' }); + }); + + it('should return "handled" when a new command returns a dialog action', async () => { + const mockAction = vi.fn().mockResolvedValue({ + type: 'dialog', + dialog: 'help', + }); + const newCommand: SlashCommand = { name: 'test', action: mockAction }; + const mockLoader = async () => [newCommand]; + const commandServiceInstance = new ActualCommandService(mockLoader); + vi.mocked(CommandService).mockImplementation( + () => commandServiceInstance, + ); + + const { result } = getProcessorHook(); + await vi.waitFor(() => { + expect( + result.current.slashCommands.some((c) => c.name === 'test'), + ).toBe(true); + }); + + const commandResult = await result.current.handleSlashCommand('/test'); + + expect(mockAction).toHaveBeenCalledTimes(1); + expect(mockSetShowHelp).toHaveBeenCalledWith(true); + expect(commandResult).toEqual({ type: 'handled' }); + }); + + it('should show help for a parent command with no action', async () => { + const parentCommand: SlashCommand = { + name: 'parent', + subCommands: [ + { name: 'child', description: 'A child.', action: vi.fn() }, + ], + }; + + const mockLoader = async () => [parentCommand]; + const commandServiceInstance = new ActualCommandService(mockLoader); + vi.mocked(CommandService).mockImplementation( + () => commandServiceInstance, + ); + + const { result } = getProcessorHook(); + + await vi.waitFor(() => { + expect( + result.current.slashCommands.some((c) => c.name === 'parent'), + ).toBe(true); + }); + + await act(async () => { + await result.current.handleSlashCommand('/parent'); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining( + "Command '/parent' requires a subcommand.", + ), + }), + expect.any(Number), + ); }); }); @@ -498,6 +554,7 @@ describe('useSlashCommandProcessor', () => { }); afterEach(() => { + vi.useRealTimers(); process.env = originalEnv; }); @@ -547,14 +604,14 @@ describe('useSlashCommandProcessor', () => { process.env.SEATBELT_PROFILE, 'test-version', ); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand(`/bug ${bugDescription}`); }); expect(mockAddItem).toHaveBeenCalledTimes(2); expect(open).toHaveBeenCalledWith(expectedUrl); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should use the custom bug command URL from config if available', async () => { @@ -585,14 +642,14 @@ describe('useSlashCommandProcessor', () => { .replace('{title}', encodeURIComponent(bugDescription)) .replace('{info}', encodeURIComponent(info)); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand(`/bug ${bugDescription}`); }); expect(mockAddItem).toHaveBeenCalledTimes(2); expect(open).toHaveBeenCalledWith(expectedUrl); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); }); @@ -640,9 +697,9 @@ describe('useSlashCommandProcessor', () => { }); describe('Unknown command', () => { - it('should show an error and return true for a general unknown command', async () => { + it('should show an error and return handled for a general unknown command', async () => { const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/unknowncommand'); }); @@ -654,7 +711,7 @@ describe('useSlashCommandProcessor', () => { }), expect.any(Number), ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); }); @@ -665,7 +722,7 @@ describe('useSlashCommandProcessor', () => { getToolRegistry: vi.fn().mockResolvedValue(undefined), } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/tools'); }); @@ -678,7 +735,7 @@ describe('useSlashCommandProcessor', () => { }), expect.any(Number), ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should show an error if getAllTools returns undefined', async () => { @@ -689,7 +746,7 @@ describe('useSlashCommandProcessor', () => { }), } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/tools'); }); @@ -702,7 +759,7 @@ describe('useSlashCommandProcessor', () => { }), expect.any(Number), ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display only Gemini CLI tools (filtering out MCP tools)', async () => { @@ -722,7 +779,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/tools'); }); @@ -731,7 +788,7 @@ describe('useSlashCommandProcessor', () => { const message = mockAddItem.mock.calls[1][0].text; expect(message).toContain('Tool1'); expect(message).toContain('Tool2'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display a message when no Gemini CLI tools are available', async () => { @@ -749,14 +806,14 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/tools'); }); const message = mockAddItem.mock.calls[1][0].text; expect(message).toContain('No tools available'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display tool descriptions when /tools desc is used', async () => { @@ -781,7 +838,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/tools desc'); }); @@ -791,40 +848,18 @@ describe('useSlashCommandProcessor', () => { expect(message).toContain('Description for Tool1'); expect(message).toContain('Tool2'); expect(message).toContain('Description for Tool2'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); }); describe('/mcp command', () => { - beforeEach(() => { - // Mock the core module with getMCPServerStatus and getMCPDiscoveryState - vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - MCPServerStatus: { - CONNECTED: 'connected', - CONNECTING: 'connecting', - DISCONNECTED: 'disconnected', - }, - MCPDiscoveryState: { - NOT_STARTED: 'not_started', - IN_PROGRESS: 'in_progress', - COMPLETED: 'completed', - }, - getMCPServerStatus: vi.fn(), - getMCPDiscoveryState: vi.fn(), - }; - }); - }); - it('should show an error if tool registry is not available', async () => { mockConfig = { ...mockConfig, getToolRegistry: vi.fn().mockResolvedValue(undefined), } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -837,7 +872,7 @@ describe('useSlashCommandProcessor', () => { }), expect.any(Number), ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => { @@ -851,7 +886,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -864,7 +899,7 @@ describe('useSlashCommandProcessor', () => { }), expect.any(Number), ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); delete process.env.SANDBOX; }); @@ -878,7 +913,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -892,7 +927,7 @@ describe('useSlashCommandProcessor', () => { expect.any(Number), ); expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display configured MCP servers with status indicators and their tools', async () => { @@ -941,7 +976,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -976,7 +1011,7 @@ describe('useSlashCommandProcessor', () => { ); expect(message).toContain('\u001b[36mserver3_tool1\u001b[0m'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should display tool descriptions when showToolDescriptions is true', async () => { @@ -1014,7 +1049,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(true); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -1046,7 +1081,7 @@ describe('useSlashCommandProcessor', () => { '\u001b[32mThis is tool 2 description\u001b[0m', ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should indicate when a server has no tools', async () => { @@ -1071,7 +1106,7 @@ describe('useSlashCommandProcessor', () => { // Mock tools from each server - server2 has no tools const mockServer1Tools = [{ name: 'server1_tool1' }]; - const mockServer2Tools = []; + const mockServer2Tools: Array<{ name: string }> = []; const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => { if (serverName === 'server1') return mockServer1Tools; @@ -1088,7 +1123,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -1113,7 +1148,7 @@ describe('useSlashCommandProcessor', () => { ); expect(message).toContain('No tools available'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); it('should show startup indicator when servers are connecting', async () => { @@ -1154,7 +1189,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp'); }); @@ -1177,7 +1212,7 @@ describe('useSlashCommandProcessor', () => { '🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)', ); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); }); @@ -1229,7 +1264,7 @@ describe('useSlashCommandProcessor', () => { } as unknown as Config; const { handleSlashCommand } = getProcessor(true); - let commandResult: SlashCommandActionReturn | boolean = false; + let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { commandResult = await handleSlashCommand('/mcp schema'); }); @@ -1257,30 +1292,16 @@ describe('useSlashCommandProcessor', () => { expect(message).toContain('param2'); expect(message).toContain('number'); - expect(commandResult).toBe(true); + expect(commandResult).toEqual({ type: 'handled' }); }); }); describe('/compress command', () => { it('should call tryCompressChat(true)', async () => { const hook = getProcessorHook(); - mockTryCompressChat.mockImplementationOnce(async (force?: boolean) => { - expect(force).toBe(true); - await act(async () => { - hook.rerender(); - }); - expect(hook.result.current.pendingHistoryItems).toContainEqual({ - type: MessageType.COMPRESSION, - compression: { - isPending: true, - originalTokenCount: null, - newTokenCount: null, - }, - }); - return { - originalTokenCount: 100, - newTokenCount: 50, - }; + mockTryCompressChat.mockResolvedValue({ + originalTokenCount: 100, + newTokenCount: 50, }); await act(async () => { 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; - 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; // Action can now return this object + | Promise; } /** @@ -72,13 +73,13 @@ export const useSlashCommandProcessor = ( openThemeDialog: () => void, openAuthDialog: () => void, openEditorDialog: () => void, - performMemoryRefresh: () => Promise, toggleCorgiMode: () => void, showToolDescriptions: boolean = false, setQuittingMessages: (message: HistoryItem[]) => void, openPrivacyNotice: () => void, ) => { const session = useSessionStats(); + const [commands, setCommands] = useState([]); 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(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 ', - timestamp: new Date(), - }); - return; - } - // UI feedback for attempting to schedule - addMessage({ - type: MessageType.INFO, - content: `Attempting to save to memory: "${args.trim()}"`, - timestamp: new Date(), - }); - // Return info for scheduling the tool call - return { - shouldScheduleTool: true, - toolName: 'save_memory', - toolArgs: { fact: args.trim() }, - }; - }, - [addMessage], + const commandContext = useMemo( + (): CommandContext => ({ + services: { + config, + settings, + git: gitService, + logger, + }, + ui: { + addItem, + clear: () => { + clearItems(); + console.clear(); + refreshStatic(); + }, + setDebugMessage: onDebugMessage, + }, + session: { + stats: session.stats, + }, + }), + [ + config, + settings, + gitService, + logger, + addItem, + clearItems, + refreshStatic, + session.stats, + onDebugMessage, + ], ); + const commandService = useMemo(() => new CommandService(), []); + + useEffect(() => { + const load = async () => { + await commandService.loadCommands(); + setCommands(commandService.getCommands()); + }; + + load(); + }, [commandService]); + const savedChatTags = useCallback(async () => { const geminiDir = config?.getProjectTempDir(); if (!geminiDir) { @@ -193,17 +215,12 @@ export const useSlashCommandProcessor = ( } }, [config]); - const slashCommands: SlashCommand[] = useMemo(() => { - const commands: SlashCommand[] = [ - { - name: 'help', - altName: '?', - description: 'for help on gemini-cli', - action: (_mainCommand, _subCommand, _args) => { - onDebugMessage('Opening help.'); - setShowHelp(true); - }, - }, + // Define legacy commands + // This list contains all commands that have NOT YET been migrated to the + // new system. As commands are migrated, they are removed from this list. + const legacyCommands: LegacySlashCommand[] = useMemo(() => { + const commands: LegacySlashCommand[] = [ + // `/help` and `/clear` have been migrated and REMOVED from this list. { name: 'docs', description: 'open full Gemini CLI documentation in your browser', @@ -225,17 +242,6 @@ export const useSlashCommandProcessor = ( } }, }, - { - name: 'clear', - description: 'clear the screen and conversation history', - action: async (_mainCommand, _subCommand, _args) => { - onDebugMessage('Clearing terminal and resetting chat.'); - clearItems(); - await config?.getGeminiClient()?.resetChat(); - console.clear(); - refreshStatic(); - }, - }, { name: 'theme', description: 'change the theme', @@ -246,23 +252,17 @@ export const useSlashCommandProcessor = ( { name: 'auth', description: 'change the auth method', - action: (_mainCommand, _subCommand, _args) => { - openAuthDialog(); - }, + action: (_mainCommand, _subCommand, _args) => openAuthDialog(), }, { name: 'editor', description: 'set external editor preference', - action: (_mainCommand, _subCommand, _args) => { - openEditorDialog(); - }, + action: (_mainCommand, _subCommand, _args) => openEditorDialog(), }, { name: 'privacy', description: 'display the privacy notice', - action: (_mainCommand, _subCommand, _args) => { - openPrivacyNotice(); - }, + action: (_mainCommand, _subCommand, _args) => openPrivacyNotice(), }, { name: 'stats', @@ -493,38 +493,6 @@ export const useSlashCommandProcessor = ( }); }, }, - { - name: 'memory', - description: - 'manage memory. Usage: /memory [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 [text for add]', - timestamp: new Date(), - }); - return; - default: - addMessage({ - type: MessageType.ERROR, - content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, - timestamp: new Date(), - }); - return; - } - }, - }, { name: 'tools', description: 'list available Gemini CLI tools', @@ -1020,7 +988,7 @@ export const useSlashCommandProcessor = ( } return { - shouldScheduleTool: true, + type: 'tool', toolName: toolCallData.toolCall.name, toolArgs: toolCallData.toolCall.args, }; @@ -1036,17 +1004,11 @@ export const useSlashCommandProcessor = ( } return commands; }, [ - onDebugMessage, - setShowHelp, - refreshStatic, + addMessage, openThemeDialog, openAuthDialog, openEditorDialog, - clearItems, - performMemoryRefresh, - showMemoryAction, - addMemoryAction, - addMessage, + openPrivacyNotice, toggleCorgiMode, savedChatTags, config, @@ -1059,20 +1021,23 @@ export const useSlashCommandProcessor = ( setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, - openPrivacyNotice, + clearItems, + refreshStatic, ]); const handleSlashCommand = useCallback( async ( rawQuery: PartListUnion, - ): Promise => { + ): Promise => { if (typeof rawQuery !== 'string') { return false; } + const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { return false; } + const userMessageTimestamp = Date.now(); if (trimmed !== '/quit' && trimmed !== '/exit') { addItem( @@ -1081,35 +1046,128 @@ export const useSlashCommandProcessor = ( ); } - let subCommand: string | undefined; - let args: string | undefined; + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] - const commandToMatch = (() => { - if (trimmed.startsWith('?')) { - return 'help'; - } - const parts = trimmed.substring(1).trim().split(/\s+/); - if (parts.length > 1) { - subCommand = parts[1]; - } - if (parts.length > 2) { - args = parts.slice(2).join(' '); - } - return parts[0]; - })(); + // --- Start of New Tree Traversal Logic --- - const mainCommand = commandToMatch; + let currentCommands = commands; + let commandToExecute: SlashCommand | undefined; + let pathIndex = 0; - for (const cmd of slashCommands) { - if (mainCommand === cmd.name || mainCommand === cmd.altName) { - const actionResult = await cmd.action(mainCommand, subCommand, args); - if ( - typeof actionResult === 'object' && - actionResult?.shouldScheduleTool - ) { - return actionResult; // Return the object for useGeminiStream + for (const part of commandPath) { + const foundCommand = currentCommands.find( + (cmd) => cmd.name === part || cmd.altName === part, + ); + + if (foundCommand) { + commandToExecute = foundCommand; + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; } - return true; // Command was handled, but no tool to schedule + } else { + break; + } + } + + if (commandToExecute) { + const args = parts.slice(pathIndex).join(' '); + + if (commandToExecute.action) { + const result = await commandToExecute.action(commandContext, args); + + if (result) { + switch (result.type) { + case 'tool': + return { + type: 'schedule_tool', + toolName: result.toolName, + toolArgs: result.toolArgs, + }; + case 'message': + addItem( + { + type: + result.messageType === 'error' + ? MessageType.ERROR + : MessageType.INFO, + text: result.content, + }, + Date.now(), + ); + return { type: 'handled' }; + case 'dialog': + switch (result.dialog) { + case 'help': + setShowHelp(true); + return { type: 'handled' }; + default: { + const unhandled: never = result.dialog; + throw new Error( + `Unhandled slash command result: ${unhandled}`, + ); + } + } + default: { + const unhandled: never = result; + throw new Error(`Unhandled slash command result: ${unhandled}`); + } + } + } + + return { type: 'handled' }; + } else if (commandToExecute.subCommands) { + const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands + .map((sc) => ` - ${sc.name}: ${sc.description || ''}`) + .join('\n')}`; + addMessage({ + type: MessageType.INFO, + content: helpText, + timestamp: new Date(), + }); + return { type: 'handled' }; + } + } + + // --- End of New Tree Traversal Logic --- + + // --- Legacy Fallback Logic (for commands not yet migrated) --- + + const mainCommand = parts[0]; + const subCommand = parts[1]; + const legacyArgs = parts.slice(2).join(' '); + + for (const cmd of legacyCommands) { + if (mainCommand === cmd.name || mainCommand === cmd.altName) { + const actionResult = await cmd.action( + mainCommand, + subCommand, + legacyArgs, + ); + + if (actionResult?.type === 'tool') { + return { + type: 'schedule_tool', + toolName: actionResult.toolName, + toolArgs: actionResult.toolArgs, + }; + } + if (actionResult?.type === 'message') { + addItem( + { + type: + actionResult.messageType === 'error' + ? MessageType.ERROR + : MessageType.INFO, + text: actionResult.content, + }, + Date.now(), + ); + } + return { type: 'handled' }; } } @@ -1118,10 +1176,51 @@ export const useSlashCommandProcessor = ( content: `Unknown command: ${trimmed}`, timestamp: new Date(), }); - return true; // Indicate command was processed (even if unknown) + return { type: 'handled' }; }, - [addItem, slashCommands, addMessage], + [ + addItem, + setShowHelp, + commands, + legacyCommands, + commandContext, + addMessage, + ], ); - return { handleSlashCommand, slashCommands, pendingHistoryItems }; + const allCommands = useMemo(() => { + // Adapt legacy commands to the new SlashCommand interface + const adaptedLegacyCommands: SlashCommand[] = legacyCommands.map( + (legacyCmd) => ({ + name: legacyCmd.name, + altName: legacyCmd.altName, + description: legacyCmd.description, + action: async (_context: CommandContext, args: string) => { + const parts = args.split(/\s+/); + const subCommand = parts[0] || undefined; + const restOfArgs = parts.slice(1).join(' ') || undefined; + + return legacyCmd.action(legacyCmd.name, subCommand, restOfArgs); + }, + completion: legacyCmd.completion + ? async (_context: CommandContext, _partialArg: string) => + legacyCmd.completion!() + : undefined, + }), + ); + + const newCommandNames = new Set(commands.map((c) => c.name)); + const filteredAdaptedLegacy = adaptedLegacyCommands.filter( + (c) => !newCommandNames.has(c.name), + ); + + return [...commands, ...filteredAdaptedLegacy]; + }, [commands, legacyCommands]); + + return { + handleSlashCommand, + slashCommands: allCommands, + pendingHistoryItems, + commandContext, + }; }; 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; - 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; 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>); // 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>); 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>); 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>); 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>); 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([]); @@ -123,75 +124,129 @@ export function useCompletion( return; } - const trimmedQuery = query.trimStart(); // Trim leading whitespace + const trimmedQuery = query.trimStart(); - // --- Handle Slash Command Completion --- if (trimmedQuery.startsWith('/')) { - const parts = trimmedQuery.substring(1).split(' '); - const commandName = parts[0]; - const subCommand = parts.slice(1).join(' '); + const fullPath = trimmedQuery.substring(1); + const hasTrailingSpace = trimmedQuery.endsWith(' '); - const command = slashCommands.find( - (cmd) => cmd.name === commandName || cmd.altName === commandName, - ); + // Get all non-empty parts of the command. + const rawParts = fullPath.split(/\s+/).filter((p) => p); - // Continue to show command help until user types past command name. - if (command && command.completion && parts.length > 1) { + let commandPathParts = rawParts; + let partial = ''; + + // If there's no trailing space, the last part is potentially a partial segment. + // We tentatively separate it. + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + // Traverse the Command Tree using the tentative completed path + let currentLevel: SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found: SlashCommand | undefined = currentLevel.find( + (cmd) => cmd.name === part || cmd.altName === part, + ); + if (found) { + leafCommand = found; + currentLevel = found.subCommands; + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + // Handle the Ambiguous Case + if (!hasTrailingSpace && currentLevel) { + const exactMatchAsParent = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altName === partial) && + cmd.subCommands, + ); + + if (exactMatchAsParent) { + // It's a perfect match for a parent command. Override our initial guess. + // Treat it as a completed command path. + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; // We now want to suggest ALL of its sub-commands. + } + } + + const depth = commandPathParts.length; + + // Provide Suggestions based on the now-corrected context + + // Argument Completion + if ( + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')) + ) { const fetchAndSetSuggestions = async () => { setIsLoadingSuggestions(true); - if (command.completion) { - const results = await command.completion(); - const filtered = results.filter((r) => r.startsWith(subCommand)); - const newSuggestions = filtered.map((s) => ({ - label: s, - value: s, - })); - setSuggestions(newSuggestions); - setShowSuggestions(newSuggestions.length > 0); - setActiveSuggestionIndex(newSuggestions.length > 0 ? 0 : -1); - } + const argString = rawParts.slice(depth).join(' '); + const results = + (await leafCommand!.completion!(commandContext, argString)) || []; + const finalSuggestions = results.map((s) => ({ label: s, value: s })); + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); setIsLoadingSuggestions(false); }; fetchAndSetSuggestions(); return; } - const partialCommand = trimmedQuery.substring(1); - const filteredSuggestions = slashCommands - .filter( + // Command/Sub-command Completion + const commandsToSearch = currentLevel || []; + if (commandsToSearch.length > 0) { + let potentialSuggestions = commandsToSearch.filter( (cmd) => - cmd.name.startsWith(partialCommand) || - cmd.altName?.startsWith(partialCommand), - ) - // Filter out ? and any other single character commands unless it's the only char - .filter((cmd) => { - const nameMatch = cmd.name.startsWith(partialCommand); - const altNameMatch = cmd.altName?.startsWith(partialCommand); - if (partialCommand.length === 1) { - return nameMatch || altNameMatch; // Allow single char match if query is single char - } - return ( - (nameMatch && cmd.name.length > 1) || - (altNameMatch && cmd.altName && cmd.altName.length > 1) - ); - }) - .filter((cmd) => cmd.description) - .map((cmd) => ({ - label: cmd.name, // Always show the main name as label - value: cmd.name, // Value should be the main command name for execution - description: cmd.description, - })) - .sort((a, b) => a.label.localeCompare(b.label)); + cmd.description && + (cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)), + ); - setSuggestions(filteredSuggestions); - setShowSuggestions(filteredSuggestions.length > 0); - setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1); - setVisibleStartIndex(0); - setIsLoadingSuggestions(false); + // If a user's input is an exact match and it is a leaf command, + // enter should submit immediately. + if (potentialSuggestions.length > 0 && !hasTrailingSpace) { + const perfectMatch = potentialSuggestions.find( + (s) => s.name === partial, + ); + if (perfectMatch && !perfectMatch.subCommands) { + potentialSuggestions = []; + } + } + + const finalSuggestions = potentialSuggestions.map((cmd) => ({ + label: cmd.name, + value: cmd.name, + description: cmd.description, + })); + + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); + setIsLoadingSuggestions(false); + return; + } + + // If we fall through, no suggestions are available. + resetCompletionState(); return; } - // --- Handle At Command Completion --- + // Handle At Command Completion const atIndex = query.lastIndexOf('@'); if (atIndex === -1) { resetCompletionState(); @@ -451,7 +506,15 @@ export function useCompletion( isMounted = false; clearTimeout(debounceTimeout); }; - }, [query, cwd, isActive, resetCompletionState, slashCommands, config]); + }, [ + query, + cwd, + isActive, + resetCompletionState, + slashCommands, + commandContext, + config, + ]); return { suggestions, 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; 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, 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) - | null = null; - - mockUseReactToolScheduler.mockImplementation((onComplete) => { - capturedOnComplete = onComplete; - return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; - }); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockSetShowHelp, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - ), - ); - - // --- User runs the slash command --- await act(async () => { await result.current.submitQuery('/memory add "test fact"'); }); - // Trigger the onComplete callback with the completed client-initiated tool + await waitFor(() => { + expect(mockScheduleToolCalls).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: 'save_memory', + args: { fact: 'test fact' }, + isClientInitiated: true, + }), + ], + expect.any(AbortSignal), + ); + expect(mockSendMessageStream).not.toHaveBeenCalled(); + }); + }); + + it('should stop processing and not call Gemini when a command is handled without a tool call', async () => { + const uiOnlyCommandResult: SlashCommandProcessorResult = { + type: 'handled', + }; + mockHandleSlashCommand.mockResolvedValue(uiOnlyCommandResult); + + const { result } = renderTestHook(); + await act(async () => { - if (capturedOnComplete) { - await capturedOnComplete([completedToolCall]); - } + await result.current.submitQuery('/help'); }); - // --- Assert the outcome --- await waitFor(() => { - // The tool should be marked as submitted locally - expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([ - 'client-call-1', - ]); - // Crucially, no message should be sent to the Gemini API - expect(mockSendMessageStream).not.toHaveBeenCalled(); + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help'); + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made }); }); }); 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, 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; + } + | { + 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 {