diff options
Diffstat (limited to 'packages/cli/src/ui/commands')
| -rw-r--r-- | packages/cli/src/ui/commands/clearCommand.test.ts | 78 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/clearCommand.ts | 17 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/helpCommand.test.ts | 40 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/helpCommand.ts | 20 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/memoryCommand.test.ts | 249 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/memoryCommand.ts | 106 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/types.ts | 99 |
7 files changed, 609 insertions, 0 deletions
diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts new file mode 100644 index 00000000..8019dd68 --- /dev/null +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { clearCommand } from './clearCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { GeminiClient } from '@google/gemini-cli-core'; + +describe('clearCommand', () => { + let mockContext: CommandContext; + let mockResetChat: ReturnType<typeof vi.fn>; + + beforeEach(() => { + mockResetChat = vi.fn().mockResolvedValue(undefined); + + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => + ({ + resetChat: mockResetChat, + }) as unknown as GeminiClient, + }, + }, + }); + }); + + it('should set debug message, reset chat, and clear UI when config is available', async () => { + if (!clearCommand.action) { + throw new Error('clearCommand must have an action.'); + } + + await clearCommand.action(mockContext, ''); + + expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith( + 'Clearing terminal and resetting chat.', + ); + expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1); + + expect(mockResetChat).toHaveBeenCalledTimes(1); + + expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); + + // Check the order of operations. + const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock + .invocationCallOrder[0]; + const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; + const clearOrder = (mockContext.ui.clear as Mock).mock + .invocationCallOrder[0]; + + expect(setDebugMessageOrder).toBeLessThan(resetChatOrder); + expect(resetChatOrder).toBeLessThan(clearOrder); + }); + + it('should not attempt to reset chat if config service is not available', async () => { + if (!clearCommand.action) { + throw new Error('clearCommand must have an action.'); + } + + const nullConfigContext = createMockCommandContext({ + services: { + config: null, + }, + }); + + await clearCommand.action(nullConfigContext, ''); + + expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith( + 'Clearing terminal and resetting chat.', + ); + expect(mockResetChat).not.toHaveBeenCalled(); + expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts new file mode 100644 index 00000000..e5473b5b --- /dev/null +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SlashCommand } from './types.js'; + +export const clearCommand: SlashCommand = { + name: 'clear', + description: 'clear the screen and conversation history', + action: async (context, _args) => { + context.ui.setDebugMessage('Clearing terminal and resetting chat.'); + await context.services.config?.getGeminiClient()?.resetChat(); + context.ui.clear(); + }, +}; diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts new file mode 100644 index 00000000..a6b19c05 --- /dev/null +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { helpCommand } from './helpCommand.js'; +import { type CommandContext } from './types.js'; + +describe('helpCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = {} as unknown as CommandContext; + }); + + it("should return a dialog action and log a debug message for '/help'", () => { + const consoleDebugSpy = vi + .spyOn(console, 'debug') + .mockImplementation(() => {}); + if (!helpCommand.action) { + throw new Error('Help command has no action'); + } + const result = helpCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'help', + }); + expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...'); + }); + + it("should also be triggered by its alternative name '?'", () => { + // This test is more conceptual. The routing of altName to the command + // is handled by the slash command processor, but we can assert the + // altName is correctly defined on the command object itself. + expect(helpCommand.altName).toBe('?'); + }); +}); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts new file mode 100644 index 00000000..82d0d536 --- /dev/null +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenDialogActionReturn, SlashCommand } from './types.js'; + +export const helpCommand: SlashCommand = { + name: 'help', + altName: '?', + description: 'for help on gemini-cli', + action: (_context, _args): OpenDialogActionReturn => { + console.debug('Opening help UI ...'); + return { + type: 'dialog', + dialog: 'help', + }; + }, +}; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts new file mode 100644 index 00000000..47d098b1 --- /dev/null +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { memoryCommand } from './memoryCommand.js'; +import { type CommandContext, SlashCommand } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal<typeof import('@google/gemini-cli-core')>(); + return { + ...original, + getErrorMessage: vi.fn((error: unknown) => { + if (error instanceof Error) return error.message; + return String(error); + }), + }; +}); + +describe('memoryCommand', () => { + let mockContext: CommandContext; + + const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => { + const subCommand = memoryCommand.subCommands?.find( + (cmd) => cmd.name === name, + ); + if (!subCommand) { + throw new Error(`/memory ${name} command not found.`); + } + return subCommand; + }; + + describe('/memory show', () => { + let showCommand: SlashCommand; + let mockGetUserMemory: Mock; + let mockGetGeminiMdFileCount: Mock; + + beforeEach(() => { + showCommand = getSubCommand('show'); + + mockGetUserMemory = vi.fn(); + mockGetGeminiMdFileCount = vi.fn(); + + mockContext = createMockCommandContext({ + services: { + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + }, + }, + }); + }); + + it('should display a message if memory is empty', async () => { + if (!showCommand.action) throw new Error('Command has no action'); + + mockGetUserMemory.mockReturnValue(''); + mockGetGeminiMdFileCount.mockReturnValue(0); + + await showCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory is currently empty.', + }, + expect.any(Number), + ); + }); + + it('should display the memory content and file count if it exists', async () => { + if (!showCommand.action) throw new Error('Command has no action'); + + const memoryContent = 'This is a test memory.'; + + mockGetUserMemory.mockReturnValue(memoryContent); + mockGetGeminiMdFileCount.mockReturnValue(1); + + await showCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`, + }, + expect.any(Number), + ); + }); + }); + + describe('/memory add', () => { + let addCommand: SlashCommand; + + beforeEach(() => { + addCommand = getSubCommand('add'); + mockContext = createMockCommandContext(); + }); + + it('should return an error message if no arguments are provided', () => { + if (!addCommand.action) throw new Error('Command has no action'); + + const result = addCommand.action(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /memory add <text to remember>', + }); + + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should return a tool action and add an info message when arguments are provided', () => { + if (!addCommand.action) throw new Error('Command has no action'); + + const fact = 'remember this'; + const result = addCommand.action(mockContext, ` ${fact} `); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Attempting to save to memory: "${fact}"`, + }, + expect.any(Number), + ); + + expect(result).toEqual({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact }, + }); + }); + }); + + describe('/memory refresh', () => { + let refreshCommand: SlashCommand; + let mockRefreshMemory: Mock; + + beforeEach(() => { + refreshCommand = getSubCommand('refresh'); + mockRefreshMemory = vi.fn(); + mockContext = createMockCommandContext({ + services: { + config: { + refreshMemory: mockRefreshMemory, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }, + }); + }); + + it('should display success message when memory is refreshed with content', async () => { + if (!refreshCommand.action) throw new Error('Command has no action'); + + const refreshResult = { + memoryContent: 'new memory content', + fileCount: 2, + }; + mockRefreshMemory.mockResolvedValue(refreshResult); + + await refreshCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Refreshing memory from source files...', + }, + expect.any(Number), + ); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + }, + expect.any(Number), + ); + }); + + it('should display success message when memory is refreshed with no content', async () => { + if (!refreshCommand.action) throw new Error('Command has no action'); + + const refreshResult = { memoryContent: '', fileCount: 0 }; + mockRefreshMemory.mockResolvedValue(refreshResult); + + await refreshCommand.action(mockContext, ''); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory refreshed successfully. No memory content found.', + }, + expect.any(Number), + ); + }); + + it('should display an error message if refreshing fails', async () => { + if (!refreshCommand.action) throw new Error('Command has no action'); + + const error = new Error('Failed to read memory files.'); + mockRefreshMemory.mockRejectedValue(error); + + await refreshCommand.action(mockContext, ''); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Error refreshing memory: ${error.message}`, + }, + expect.any(Number), + ); + + expect(getErrorMessage).toHaveBeenCalledWith(error); + }); + + it('should not throw if config service is unavailable', async () => { + if (!refreshCommand.action) throw new Error('Command has no action'); + + const nullConfigContext = createMockCommandContext({ + services: { config: null }, + }); + + await expect( + refreshCommand.action(nullConfigContext, ''), + ).resolves.toBeUndefined(); + + expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Refreshing memory from source files...', + }, + expect.any(Number), + ); + + expect(mockRefreshMemory).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts new file mode 100644 index 00000000..18ca96bb --- /dev/null +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getErrorMessage } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; +import { SlashCommand, SlashCommandActionReturn } from './types.js'; + +export const memoryCommand: SlashCommand = { + name: 'memory', + description: 'Commands for interacting with memory.', + subCommands: [ + { + name: 'show', + description: 'Show the current memory contents.', + action: async (context) => { + const memoryContent = context.services.config?.getUserMemory() || ''; + const fileCount = context.services.config?.getGeminiMdFileCount() || 0; + + const messageContent = + memoryContent.length > 0 + ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` + : 'Memory is currently empty.'; + + context.ui.addItem( + { + type: MessageType.INFO, + text: messageContent, + }, + Date.now(), + ); + }, + }, + { + name: 'add', + description: 'Add content to the memory.', + action: (context, args): SlashCommandActionReturn | void => { + if (!args || args.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /memory add <text to remember>', + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Attempting to save to memory: "${args.trim()}"`, + }, + Date.now(), + ); + + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: args.trim() }, + }; + }, + }, + { + name: 'refresh', + description: 'Refresh the memory from the source.', + action: async (context) => { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Refreshing memory from source files...', + }, + Date.now(), + ); + + try { + const result = await context.services.config?.refreshMemory(); + + if (result) { + const { memoryContent, fileCount } = result; + const successMessage = + memoryContent.length > 0 + ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` + : 'Memory refreshed successfully. No memory content found.'; + + context.ui.addItem( + { + type: MessageType.INFO, + text: successMessage, + }, + Date.now(), + ); + } + } catch (error) { + const errorMessage = getErrorMessage(error); + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Error refreshing memory: ${errorMessage}`, + }, + Date.now(), + ); + } + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts new file mode 100644 index 00000000..09682d7a --- /dev/null +++ b/packages/cli/src/ui/commands/types.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Config, GitService, Logger } from '@google/gemini-cli-core'; +import { LoadedSettings } from '../../config/settings.js'; +import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { SessionStatsState } from '../contexts/SessionContext.js'; + +// Grouped dependencies for clarity and easier mocking +export interface CommandContext { + // Core services and configuration + services: { + // TODO(abhipatel12): Ensure that config is never null. + config: Config | null; + settings: LoadedSettings; + git: GitService | undefined; + logger: Logger; + }; + // UI state and history management + ui: { + // TODO - As more commands are add some additions may be needed or reworked using this new context. + // Ex. + // history: HistoryItem[]; + // pendingHistoryItems: HistoryItemWithoutId[]; + + /** Adds a new item to the history display. */ + addItem: UseHistoryManagerReturn['addItem']; + /** Clears all history items and the console screen. */ + clear: () => void; + /** + * Sets the transient debug message displayed in the application footer in debug mode. + */ + setDebugMessage: (message: string) => void; + }; + // Session-specific data + session: { + stats: SessionStatsState; + }; +} + +/** + * The return type for a command action that results in scheduling a tool call. + */ +export interface ToolActionReturn { + type: 'tool'; + toolName: string; + toolArgs: Record<string, unknown>; +} + +/** + * The return type for a command action that results in a simple message + * being displayed to the user. + */ +export interface MessageActionReturn { + type: 'message'; + messageType: 'info' | 'error'; + content: string; +} + +/** + * The return type for a command action that needs to open a dialog. + */ +export interface OpenDialogActionReturn { + type: 'dialog'; + // TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens. + dialog: 'help'; +} + +export type SlashCommandActionReturn = + | ToolActionReturn + | MessageActionReturn + | OpenDialogActionReturn; + +// The standardized contract for any command in the system. +export interface SlashCommand { + name: string; + altName?: string; + description?: string; + + // The action to run. Optional for parent commands that only group sub-commands. + action?: ( + context: CommandContext, + args: string, + ) => + | void + | SlashCommandActionReturn + | Promise<void | SlashCommandActionReturn>; + + // Provides argument completion (e.g., completing a tag for `/chat resume <tag>`). + completion?: ( + context: CommandContext, + partialArg: string, + ) => Promise<string[]>; + + subCommands?: SlashCommand[]; +} |
