summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/commands
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/commands')
-rw-r--r--packages/cli/src/ui/commands/clearCommand.test.ts78
-rw-r--r--packages/cli/src/ui/commands/clearCommand.ts17
-rw-r--r--packages/cli/src/ui/commands/helpCommand.test.ts40
-rw-r--r--packages/cli/src/ui/commands/helpCommand.ts20
-rw-r--r--packages/cli/src/ui/commands/memoryCommand.test.ts249
-rw-r--r--packages/cli/src/ui/commands/memoryCommand.ts106
-rw-r--r--packages/cli/src/ui/commands/types.ts99
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[];
+}