From 9daead63ddc4a0bddad05ec9f4bb7c0726da44f4 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Tue, 22 Jul 2025 00:34:55 -0400 Subject: [PATCH] (feat): Initial Version of Custom Commands (#4572) --- docs/cli/commands.md | 89 +++++- package-lock.json | 29 ++ package.json | 2 + packages/cli/package.json | 1 + .../src/services/BuiltinCommandLoader.test.ts | 8 +- .../src/services/FileCommandLoader.test.ts | 235 ++++++++++++++++ .../cli/src/services/FileCommandLoader.ts | 171 ++++++++++++ packages/cli/src/ui/commands/types.ts | 12 +- .../ui/hooks/slashCommandProcessor.test.ts | 257 +++++++++++++----- .../cli/src/ui/hooks/slashCommandProcessor.ts | 38 ++- .../hooks/useCompletion.integration.test.ts | 29 +- .../cli/src/ui/hooks/useCompletion.test.ts | 100 ++++++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 59 ++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 42 ++- packages/cli/src/ui/types.ts | 12 +- packages/cli/tsconfig.json | 2 +- packages/core/src/utils/paths.ts | 18 ++ 17 files changed, 1008 insertions(+), 96 deletions(-) create mode 100644 packages/cli/src/services/FileCommandLoader.test.ts create mode 100644 packages/cli/src/services/FileCommandLoader.ts diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 97527a68..7f11094a 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -6,6 +6,8 @@ Gemini CLI supports several built-in commands to help you manage your session, c Slash commands provide meta-level control over the CLI itself. +### Built-in Commands + - **`/bug`** - **Description:** File an issue about Gemini CLI. By default, the issue is filed within the GitHub repository for Gemini CLI. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `bugCommand` setting in your `.gemini/settings.json` files. @@ -38,7 +40,7 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Lists all active extensions in the current Gemini CLI session. See [Gemini CLI Extensions](../extension.md). - **`/help`** (or **`/?`**) - - **Description:** Display help information about the Gemini CLI, including available commands and their usage. + - **Description:** Display help information about Gemini CLI, including available commands and their usage. - **`/mcp`** - **Description:** List configured Model Context Protocol (MCP) servers, their connection status, server details, and available tools. @@ -93,6 +95,91 @@ Slash commands provide meta-level control over the CLI itself. - **`/quit`** (or **`/exit`**) - **Description:** Exit Gemini CLI. +### Custom Commands + +For a quick start, see the [example](#example-a-pure-function-refactoring-command) below. + +Custom commands allow you to save and reuse your favorite or most frequently used prompts as personal shortcuts within Gemini CLI. You can create commands that are specific to a single project or commands that are available globally across all your projects, streamlining your workflow and ensuring consistency. + +#### File Locations & Precedence + +Gemini CLI discovers commands from two locations, loaded in a specific order: + +1. **User Commands (Global):** Located in `~/.gemini/commands/`. These commands are available in any project you are working on. +2. **Project Commands (Local):** Located in `/.gemini/commands/`. These commands are specific to the current project and can be checked into version control to be shared with your team. + +If a command in the project directory has the same name as a command in the user directory, the **project command will always be used.** This allows projects to override global commands with project-specific versions. + +#### Naming and Namespacing + +The name of a command is determined by its file path relative to its `commands` directory. Subdirectories are used to create namespaced commands, with the path separator (`/` or `\`) being converted to a colon (`:`). + +- A file at `~/.gemini/commands/test.toml` becomes the command `/test`. +- A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. + +#### TOML File Format (v1) + +Your command definition files must be written in the TOML format and use the `.toml` file extension. + +##### Required Fields + +- `prompt` (String): The prompt that will be sent to the Gemini model when the command is executed. This can be a single-line or multi-line string. + +##### Optional Fields + +- `description` (String): A brief, one-line description of what the command does. This text will be displayed next to your command in the `/help` menu. **If you omit this field, a generic description will be generated from the filename.** + +--- + +#### Example: A "Pure Function" Refactoring Command + +Let's create a global command that asks the model to refactor a piece of code. + +**1. Create the file and directories:** + +First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file. + +```bash +mkdir -p ~/.gemini/commands/refactor +touch ~/.gemini/commands/refactor/pure.toml +``` + +**2. Add the content to the file:** + +Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the following content. We are including the optional `description` for best practice. + +```toml +# In: ~/.gemini/commands/refactor/pure.toml +# This command will be invoked via: /refactor:pure + +description = "Asks the model to refactor the current context into a pure function." + +prompt = """ +Please analyze the code I've provided in the current context. +Refactor it into a pure function. + +Your response should include: +1. The refactored, pure function code block. +2. A brief explanation of the key changes you made and why they contribute to purity. +""" +``` + +**3. Run the Command:** + +That's it! You can now run your command in the CLI. First, you might add a file to the context, and then invoke your command: + +``` +> @my-messy-function.js +> /refactor:pure +``` + +Gemini CLI will then execute the multi-line prompt defined in your TOML file. + +This initial version of custom commands is focused on static prompts. Future updates are planned to introduce more dynamic capabilities, including: + +- **Argument Support:** Passing arguments from the command line directly into your `prompt` template. +- **Shell Execution:** Creating commands that can run local shell scripts to gather context before running the prompt. + ## At commands (`@`) At commands are used to include the content of files or directories as part of your prompt to Gemini. These commands include git-aware filtering. diff --git a/package-lock.json b/package-lock.json index e35d9bc6..1b34f994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/micromatch": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", + "@types/mock-fs": "^4.13.4", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "concurrently": "^9.2.0", @@ -33,6 +34,7 @@ "json": "^11.0.0", "lodash": "^4.17.21", "memfs": "^4.17.2", + "mock-fs": "^5.5.0", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", "typescript-eslint": "^8.30.1", @@ -1034,6 +1036,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2368,6 +2376,16 @@ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "license": "MIT" }, + "node_modules/@types/mock-fs": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", + "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", @@ -7820,6 +7838,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mock-fs": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -11622,6 +11650,7 @@ "version": "0.1.13", "dependencies": { "@google/gemini-cli-core": "file:../core", + "@iarna/toml": "^2.2.5", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", "diff": "^7.0.0", diff --git a/package.json b/package.json index a64b7578..e1213dce 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@types/micromatch": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", + "@types/mock-fs": "^4.13.4", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "concurrently": "^9.2.0", @@ -76,6 +77,7 @@ "json": "^11.0.0", "lodash": "^4.17.21", "memfs": "^4.17.2", + "mock-fs": "^5.5.0", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", "typescript-eslint": "^8.30.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 31b5f199..faebfea3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@google/gemini-cli-core": "file:../core", + "@iarna/toml": "^2.2.5", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", "diff": "^7.0.0", diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 642309dc..0e64b1ac 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -72,7 +72,7 @@ describe('BuiltinCommandLoader', () => { it('should correctly pass the config object to command factory functions', async () => { const loader = new BuiltinCommandLoader(mockConfig); - await loader.loadCommands(); + await loader.loadCommands(new AbortController().signal); expect(ideCommandMock).toHaveBeenCalledTimes(1); expect(ideCommandMock).toHaveBeenCalledWith(mockConfig); @@ -84,7 +84,7 @@ describe('BuiltinCommandLoader', () => { // Override the mock's behavior for this specific test. ideCommandMock.mockReturnValue(null); const loader = new BuiltinCommandLoader(mockConfig); - const commands = await loader.loadCommands(); + const commands = await loader.loadCommands(new AbortController().signal); // The 'ide' command should be filtered out. const ideCmd = commands.find((c) => c.name === 'ide'); @@ -97,7 +97,7 @@ describe('BuiltinCommandLoader', () => { it('should handle a null config gracefully when calling factories', async () => { const loader = new BuiltinCommandLoader(null); - await loader.loadCommands(); + await loader.loadCommands(new AbortController().signal); expect(ideCommandMock).toHaveBeenCalledTimes(1); expect(ideCommandMock).toHaveBeenCalledWith(null); expect(restoreCommandMock).toHaveBeenCalledTimes(1); @@ -106,7 +106,7 @@ describe('BuiltinCommandLoader', () => { it('should return a list of all loaded commands', async () => { const loader = new BuiltinCommandLoader(mockConfig); - const commands = await loader.loadCommands(); + const commands = await loader.loadCommands(new AbortController().signal); const aboutCmd = commands.find((c) => c.name === 'about'); expect(aboutCmd).toBeDefined(); diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts new file mode 100644 index 00000000..518c9230 --- /dev/null +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FileCommandLoader } from './FileCommandLoader.js'; +import { + Config, + getProjectCommandsDir, + getUserCommandsDir, +} from '@google/gemini-cli-core'; +import mock from 'mock-fs'; +import { assert } from 'vitest'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; + +const mockContext = createMockCommandContext(); + +describe('FileCommandLoader', () => { + const signal: AbortSignal = new AbortController().signal; + + afterEach(() => { + mock.restore(); + }); + + it('loads a single command from a file', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "This is a test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test'); + + const result = await command.action?.(mockContext, ''); + if (result?.type === 'submit_prompt') { + expect(result.content).toBe('This is a test prompt'); + } else { + assert.fail('Incorrect action type'); + } + }); + + it('loads multiple commands', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + 'test2.toml': 'prompt = "Prompt 2"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + }); + + it('creates deeply nested namespaces correctly', async () => { + const userCommandsDir = getUserCommandsDir(); + + mock({ + [userCommandsDir]: { + gcp: { + pipelines: { + 'run.toml': 'prompt = "run pipeline"', + }, + }, + }, + }); + const loader = new FileCommandLoader({ + getProjectRoot: () => '/path/to/project', + } as Config); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe('gcp:pipelines:run'); + }); + + it('creates namespaces from nested directories', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + git: { + 'commit.toml': 'prompt = "git commit prompt"', + }, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('git:commit'); + }); + + it('overrides user commands with project commands', async () => { + const userCommandsDir = getUserCommandsDir(); + const projectCommandsDir = getProjectCommandsDir(process.cwd()); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + }, + [projectCommandsDir]: { + 'test.toml': 'prompt = "Project prompt"', + }, + }); + + const loader = new FileCommandLoader({ + getProjectRoot: () => process.cwd(), + } as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + + const result = await command.action?.(mockContext, ''); + if (result?.type === 'submit_prompt') { + expect(result.content).toBe('Project prompt'); + } else { + assert.fail('Incorrect action type'); + } + }); + + it('ignores files with TOML syntax errors', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'invalid.toml': 'this is not valid toml', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('ignores files that are semantically invalid (missing prompt)', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'no_prompt.toml': 'description = "This file is missing a prompt"', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('handles filename edge cases correctly', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.v1.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test.v1'); + }); + + it('handles file system errors gracefully', async () => { + mock({}); // Mock an empty file system + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(0); + }); + + it('uses a default description if not provided', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('Custom command from test.toml'); + }); + + it('uses the provided description', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('My test command'); + }); + + it('should sanitize colons in filenames to prevent namespace conflicts', async () => { + const userCommandsDir = getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'legacy:command.toml': 'prompt = "This is a legacy command"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + + // Verify that the ':' in the filename was replaced with an '_' + expect(command.name).toBe('legacy_command'); + }); +}); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts new file mode 100644 index 00000000..1b96cb35 --- /dev/null +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import toml from '@iarna/toml'; +import { glob } from 'glob'; +import { z } from 'zod'; +import { + Config, + getProjectCommandsDir, + getUserCommandsDir, +} from '@google/gemini-cli-core'; +import { ICommandLoader } from './types.js'; +import { CommandKind, SlashCommand } from '../ui/commands/types.js'; + +/** + * Defines the Zod schema for a command definition file. This serves as the + * single source of truth for both validation and type inference. + */ +const TomlCommandDefSchema = z.object({ + prompt: z.string({ + required_error: "The 'prompt' field is required.", + invalid_type_error: "The 'prompt' field must be a string.", + }), + description: z.string().optional(), +}); + +/** + * Discovers and loads custom slash commands from .toml files in both the + * user's global config directory and the current project's directory. + * + * This loader is responsible for: + * - Recursively scanning command directories. + * - Parsing and validating TOML files. + * - Adapting valid definitions into executable SlashCommand objects. + * - Handling file system errors and malformed files gracefully. + */ +export class FileCommandLoader implements ICommandLoader { + private readonly projectRoot: string; + + constructor(private readonly config: Config | null) { + this.projectRoot = config?.getProjectRoot() || process.cwd(); + } + + /** + * Loads all commands, applying the precedence rule where project-level + * commands override user-level commands with the same name. + * @param signal An AbortSignal to cancel the loading process. + * @returns A promise that resolves to an array of loaded SlashCommands. + */ + async loadCommands(signal: AbortSignal): Promise { + const commandMap = new Map(); + const globOptions = { + nodir: true, + dot: true, + signal, + }; + + try { + // User Commands + const userDir = getUserCommandsDir(); + const userFiles = await glob('**/*.toml', { + ...globOptions, + cwd: userDir, + }); + const userCommandPromises = userFiles.map((file) => + this.parseAndAdaptFile(path.join(userDir, file), userDir), + ); + const userCommands = (await Promise.all(userCommandPromises)).filter( + (cmd): cmd is SlashCommand => cmd !== null, + ); + for (const cmd of userCommands) { + commandMap.set(cmd.name, cmd); + } + + // Project Commands (these intentionally override user commands) + const projectDir = getProjectCommandsDir(this.projectRoot); + const projectFiles = await glob('**/*.toml', { + ...globOptions, + cwd: projectDir, + }); + const projectCommandPromises = projectFiles.map((file) => + this.parseAndAdaptFile(path.join(projectDir, file), projectDir), + ); + const projectCommands = ( + await Promise.all(projectCommandPromises) + ).filter((cmd): cmd is SlashCommand => cmd !== null); + for (const cmd of projectCommands) { + commandMap.set(cmd.name, cmd); + } + } catch (error) { + console.error(`[FileCommandLoader] Error during file search:`, error); + } + + return Array.from(commandMap.values()); + } + + /** + * Parses a single .toml file and transforms it into a SlashCommand object. + * @param filePath The absolute path to the .toml file. + * @param baseDir The root command directory for name calculation. + * @returns A promise resolving to a SlashCommand, or null if the file is invalid. + */ + private async parseAndAdaptFile( + filePath: string, + baseDir: string, + ): Promise { + let fileContent: string; + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error: unknown) { + console.error( + `[FileCommandLoader] Failed to read file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + let parsed: unknown; + try { + parsed = toml.parse(fileContent); + } catch (error: unknown) { + console.error( + `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + const validationResult = TomlCommandDefSchema.safeParse(parsed); + + if (!validationResult.success) { + console.error( + `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, + validationResult.error.flatten(), + ); + return null; + } + + const validDef = validationResult.data; + + const relativePathWithExt = path.relative(baseDir, filePath); + const relativePath = relativePathWithExt.substring( + 0, + relativePathWithExt.length - 5, // length of '.toml' + ); + const commandName = relativePath + .split(path.sep) + // Sanitize each path segment to prevent ambiguity. Since ':' is our + // namespace separator, we replace any literal colons in filenames + // with underscores to avoid naming conflicts. + .map((segment) => segment.replaceAll(':', '_')) + .join(':'); + + return { + name: commandName, + description: + validDef.description || + `Custom command from ${path.basename(filePath)}`, + kind: CommandKind.FILE, + action: async () => ({ + type: 'submit_prompt', + content: validDef.prompt, + }), + }; + } +} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 3ffadf83..df7b2f21 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -99,12 +99,22 @@ export interface LoadHistoryActionReturn { clientHistory: Content[]; // The history for the generative client } +/** + * The return type for a command action that should immediately submit + * content as a prompt to the Gemini model. + */ +export interface SubmitPromptActionReturn { + type: 'submit_prompt'; + content: string; +} + export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn | QuitActionReturn | OpenDialogActionReturn - | LoadHistoryActionReturn; + | LoadHistoryActionReturn + | SubmitPromptActionReturn; export enum CommandKind { BUILT_IN = 'built-in', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 32a6810e..84eeb033 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -14,10 +14,17 @@ vi.mock('node:process', () => ({ }, })); -const mockLoadCommands = vi.fn(); +const mockBuiltinLoadCommands = vi.fn(); vi.mock('../../services/BuiltinCommandLoader.js', () => ({ BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ - loadCommands: mockLoadCommands, + loadCommands: mockBuiltinLoadCommands, + })), +})); + +const mockFileLoadCommands = vi.fn(); +vi.mock('../../services/FileCommandLoader.js', () => ({ + FileCommandLoader: vi.fn().mockImplementation(() => ({ + loadCommands: mockFileLoadCommands, })), })); @@ -28,11 +35,22 @@ vi.mock('../contexts/SessionContext.js', () => ({ import { act, renderHook, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; -import { SlashCommand } from '../commands/types.js'; +import { CommandKind, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; import { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; + +const createTestCommand = ( + overrides: Partial, + kind: CommandKind = CommandKind.BUILT_IN, +): SlashCommand => ({ + name: 'test', + description: 'a test command', + kind, + ...overrides, +}); describe('useSlashCommandProcessor', () => { const mockAddItem = vi.fn(); @@ -55,11 +73,17 @@ describe('useSlashCommandProcessor', () => { beforeEach(() => { vi.clearAllMocks(); (vi.mocked(BuiltinCommandLoader) as Mock).mockClear(); - mockLoadCommands.mockResolvedValue([]); + mockBuiltinLoadCommands.mockResolvedValue([]); + mockFileLoadCommands.mockResolvedValue([]); }); - const setupProcessorHook = (commands: SlashCommand[] = []) => { - mockLoadCommands.mockResolvedValue(Object.freeze(commands)); + const setupProcessorHook = ( + builtinCommands: SlashCommand[] = [], + fileCommands: SlashCommand[] = [], + ) => { + mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands)); + mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); + const { result } = renderHook(() => useSlashCommandProcessor( mockConfig, @@ -83,18 +107,14 @@ describe('useSlashCommandProcessor', () => { }; describe('Initialization and Command Loading', () => { - it('should initialize CommandService with BuiltinCommandLoader', () => { + it('should initialize CommandService with all required loaders', () => { setupProcessorHook(); - expect(BuiltinCommandLoader).toHaveBeenCalledTimes(1); expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); + expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); }); it('should call loadCommands and populate state after mounting', async () => { - const testCommand: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); await waitFor(() => { @@ -102,15 +122,12 @@ describe('useSlashCommandProcessor', () => { }); expect(result.current.slashCommands[0]?.name).toBe('test'); - expect(mockLoadCommands).toHaveBeenCalledTimes(1); + expect(mockBuiltinLoadCommands).toHaveBeenCalledTimes(1); + expect(mockFileLoadCommands).toHaveBeenCalledTimes(1); }); it('should provide an immutable array of commands to consumers', async () => { - const testCommand: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); await waitFor(() => { @@ -121,13 +138,39 @@ describe('useSlashCommandProcessor', () => { expect(() => { // @ts-expect-error - We are intentionally testing a violation of the readonly type. - commands.push({ - name: 'rogue', - description: 'a rogue command', - kind: 'built-in', - }); + commands.push(createTestCommand({ name: 'rogue' })); }).toThrow(TypeError); }); + + it('should override built-in commands with file-based commands of the same name', async () => { + const builtinAction = vi.fn(); + const fileAction = vi.fn(); + + const builtinCommand = createTestCommand({ + name: 'override', + description: 'builtin', + action: builtinAction, + }); + const fileCommand = createTestCommand( + { name: 'override', description: 'file', action: fileAction }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([builtinCommand], [fileCommand]); + + await waitFor(() => { + // The service should only return one command with the name 'override' + expect(result.current.slashCommands).toHaveLength(1); + }); + + await act(async () => { + await result.current.handleSlashCommand('/override'); + }); + + // Only the file-based command's action should be called. + expect(fileAction).toHaveBeenCalledTimes(1); + expect(builtinAction).not.toHaveBeenCalled(); + }); }); describe('Command Execution Logic', () => { @@ -142,10 +185,10 @@ describe('useSlashCommandProcessor', () => { // Expect 2 calls: one for the user's input, one for the error message. expect(mockAddItem).toHaveBeenCalledTimes(2); expect(mockAddItem).toHaveBeenLastCalledWith( - expect.objectContaining({ + { type: MessageType.ERROR, text: 'Unknown command: /nonexistent', - }), + }, expect.any(Number), ); }); @@ -154,12 +197,12 @@ describe('useSlashCommandProcessor', () => { const parentCommand: SlashCommand = { name: 'parent', description: 'a parent command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'child1', description: 'First child.', - kind: 'built-in', + kind: CommandKind.BUILT_IN, }, ], }; @@ -172,12 +215,12 @@ describe('useSlashCommandProcessor', () => { expect(mockAddItem).toHaveBeenCalledTimes(2); expect(mockAddItem).toHaveBeenLastCalledWith( - expect.objectContaining({ + { type: MessageType.INFO, text: expect.stringContaining( "Command '/parent' requires a subcommand.", ), - }), + }, expect.any(Number), ); }); @@ -187,12 +230,12 @@ describe('useSlashCommandProcessor', () => { const parentCommand: SlashCommand = { name: 'parent', description: 'a parent command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'child', description: 'a child command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, action: childAction, }, ], @@ -222,12 +265,10 @@ describe('useSlashCommandProcessor', () => { describe('Action Result Handling', () => { it('should handle "dialog: help" action', async () => { - const command: SlashCommand = { + const command = createTestCommand({ name: 'helpcmd', - description: 'a help command', - kind: 'built-in', action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }), - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -239,16 +280,14 @@ describe('useSlashCommandProcessor', () => { }); it('should handle "load_history" action', async () => { - const command: SlashCommand = { + const command = createTestCommand({ name: 'load', - description: 'a load command', - kind: 'built-in', action: vi.fn().mockResolvedValue({ type: 'load_history', history: [{ type: MessageType.USER, text: 'old prompt' }], clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }], }), - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -258,7 +297,7 @@ describe('useSlashCommandProcessor', () => { expect(mockClearItems).toHaveBeenCalledTimes(1); expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'user', text: 'old prompt' }), + { type: 'user', text: 'old prompt' }, expect.any(Number), ); }); @@ -270,12 +309,10 @@ describe('useSlashCommandProcessor', () => { const quitAction = vi .fn() .mockResolvedValue({ type: 'quit', messages: [] }); - const command: SlashCommand = { + const command = createTestCommand({ name: 'exit', - description: 'an exit command', - kind: 'built-in', action: quitAction, - }; + }); const result = setupProcessorHook([command]); await waitFor(() => @@ -300,15 +337,43 @@ describe('useSlashCommandProcessor', () => { } }); }); + + it('should handle "submit_prompt" action returned from a file-based command', async () => { + const fileCommand = createTestCommand( + { + name: 'filecmd', + description: 'A command from a file', + action: async () => ({ + type: 'submit_prompt', + content: 'The actual prompt from the TOML file.', + }), + }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([], [fileCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + let actionResult; + await act(async () => { + actionResult = await result.current.handleSlashCommand('/filecmd'); + }); + + expect(actionResult).toEqual({ + type: 'submit_prompt', + content: 'The actual prompt from the TOML file.', + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: '/filecmd' }, + expect.any(Number), + ); + }); }); describe('Command Parsing and Matching', () => { it('should be case-sensitive', async () => { - const command: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const command = createTestCommand({ name: 'test' }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -319,23 +384,22 @@ describe('useSlashCommandProcessor', () => { // It should fail and call addItem with an error expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ + { type: MessageType.ERROR, text: 'Unknown command: /Test', - }), + }, expect.any(Number), ); }); it('should correctly match an altName', async () => { const action = vi.fn(); - const command: SlashCommand = { + const command = createTestCommand({ name: 'main', altNames: ['alias'], description: 'a command with an alias', - kind: 'built-in', action, - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -351,12 +415,7 @@ describe('useSlashCommandProcessor', () => { it('should handle extra whitespace around the command', async () => { const action = vi.fn(); - const command: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - action, - }; + const command = createTestCommand({ name: 'test', action }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -366,6 +425,82 @@ describe('useSlashCommandProcessor', () => { expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args'); }); + + it('should handle `?` as a command prefix', async () => { + const action = vi.fn(); + const command = createTestCommand({ name: 'help', action }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('?help'); + }); + + expect(action).toHaveBeenCalledTimes(1); + }); + }); + + describe('Command Precedence', () => { + it('should prioritize a command with a primary name over a command with a matching alias', async () => { + const quitAction = vi.fn(); + const exitAction = vi.fn(); + + const quitCommand = createTestCommand({ + name: 'quit', + altNames: ['exit'], + action: quitAction, + }); + + const exitCommand = createTestCommand( + { + name: 'exit', + action: exitAction, + }, + CommandKind.FILE, + ); + + // The order of commands in the final loaded array is not guaranteed, + // so the test must work regardless of which comes first. + const result = setupProcessorHook([quitCommand], [exitCommand]); + + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(2); + }); + + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); + + // The action for the command whose primary name is 'exit' should be called. + expect(exitAction).toHaveBeenCalledTimes(1); + // The action for the command that has 'exit' as an alias should NOT be called. + expect(quitAction).not.toHaveBeenCalled(); + }); + + it('should add an overridden command to the history', async () => { + const quitCommand = createTestCommand({ + name: 'quit', + altNames: ['exit'], + action: vi.fn(), + }); + const exitCommand = createTestCommand( + { name: 'exit', action: vi.fn() }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([quitCommand], [exitCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(2)); + + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); + + // It should be added to the history. + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: '/exit' }, + expect.any(Number), + ); + }); }); describe('Lifecycle', () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index cdf071b1..48be0470 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -22,6 +22,7 @@ import { LoadedSettings } from '../../config/settings.js'; import { type CommandContext, type SlashCommand } from '../commands/types.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; /** * Hook to define and process slash commands (e.g., /help, /clear). @@ -162,8 +163,10 @@ export const useSlashCommandProcessor = ( useEffect(() => { const controller = new AbortController(); const load = async () => { - // TODO - Add other loaders for custom commands. - const loaders = [new BuiltinCommandLoader(config)]; + const loaders = [ + new BuiltinCommandLoader(config), + new FileCommandLoader(config), + ]; const commandService = await CommandService.create( loaders, controller.signal, @@ -192,12 +195,7 @@ export const useSlashCommandProcessor = ( } const userMessageTimestamp = Date.now(); - if (trimmed !== '/quit' && trimmed !== '/exit') { - addItem( - { type: MessageType.USER, text: trimmed }, - userMessageTimestamp, - ); - } + addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); const parts = trimmed.substring(1).trim().split(/\s+/); const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] @@ -207,9 +205,21 @@ export const useSlashCommandProcessor = ( let pathIndex = 0; for (const part of commandPath) { - const foundCommand = currentCommands.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); + // TODO: For better performance and architectural clarity, this two-pass + // search could be replaced. A more optimal approach would be to + // pre-compute a single lookup map in `CommandService.ts` that resolves + // all name and alias conflicts during the initial loading phase. The + // processor would then perform a single, fast lookup on that map. + + // First pass: check for an exact match on the primary command name. + let foundCommand = currentCommands.find((cmd) => cmd.name === part); + + // Second pass: if no primary name matches, check for an alias. + if (!foundCommand) { + foundCommand = currentCommands.find((cmd) => + cmd.altNames?.includes(part), + ); + } if (foundCommand) { commandToExecute = foundCommand; @@ -290,6 +300,12 @@ export const useSlashCommandProcessor = ( process.exit(0); }, 100); return { type: 'handled' }; + + case 'submit_prompt': + return { + type: 'submit_prompt', + content: result.content, + }; default: { const unhandled: never = result; throw new Error(`Unhandled slash command result: ${unhandled}`); diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts index 02162159..840d2814 100644 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts @@ -10,7 +10,11 @@ import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; import { glob } from 'glob'; -import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + CommandContext, + CommandKind, + SlashCommand, +} from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; interface MockConfig { @@ -43,8 +47,18 @@ describe('useCompletion git-aware filtering integration', () => { const testCwd = '/test/project'; const slashCommands = [ - { name: 'help', description: 'Show help', action: vi.fn() }, - { name: 'clear', description: 'Clear screen', action: vi.fn() }, + { + name: 'help', + description: 'Show help', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, + { + name: 'clear', + description: 'Clear screen', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, ]; // A minimal mock is sufficient for these tests. @@ -56,31 +70,37 @@ describe('useCompletion git-aware filtering integration', () => { altNames: ['?'], description: 'Show help', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'clear', description: 'Clear the screen', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'memory', description: 'Manage memory', + kind: CommandKind.BUILT_IN, // This command is a parent, no action. subCommands: [ { name: 'show', description: 'Show memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'add', description: 'Add to memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, ], @@ -88,15 +108,18 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'chat', description: 'Manage chat history', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'save', description: 'Save chat', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'resume', description: 'Resume a saved chat', + kind: CommandKind.BUILT_IN, action: vi.fn(), // This command provides its own argument completions completion: vi diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index d1b22a88..19671de4 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -12,7 +12,11 @@ import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; import { glob } from 'glob'; -import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + CommandContext, + CommandKind, + SlashCommand, +} from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; // Mock dependencies @@ -69,30 +73,36 @@ describe('useCompletion', () => { altNames: ['?'], description: 'Show help', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'clear', description: 'Clear the screen', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'memory', description: 'Manage memory', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'show', description: 'Show memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'add', description: 'Add to memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, ], @@ -100,15 +110,20 @@ describe('useCompletion', () => { { name: 'chat', description: 'Manage chat history', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'save', description: 'Save chat', + kind: CommandKind.BUILT_IN, + action: vi.fn(), }, { name: 'resume', description: 'Resume a saved chat', + kind: CommandKind.BUILT_IN, + action: vi.fn(), completion: vi.fn().mockResolvedValue(['chat1', 'chat2']), }, @@ -344,6 +359,7 @@ describe('useCompletion', () => { const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({ name: `command${i}`, description: `Command ${i}`, + kind: CommandKind.BUILT_IN, action: vi.fn(), })); @@ -629,6 +645,88 @@ describe('useCompletion', () => { }); }); + describe('Slash command completion with namespaced names', () => { + let commandsWithNamespaces: SlashCommand[]; + + beforeEach(() => { + commandsWithNamespaces = [ + ...mockSlashCommands, + { + name: 'git:commit', + description: 'A namespaced git command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + { + name: 'git:push', + description: 'Another namespaced git command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + { + name: 'docker:build', + description: 'A docker command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + ]; + }); + + it('should suggest a namespaced command based on a partial match', () => { + const { result } = renderHook(() => + useCompletion( + '/git:co', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0].label).toBe('git:commit'); + }); + + it('should suggest all commands within a namespace when the namespace prefix is typed', () => { + const { result } = renderHook(() => + useCompletion( + '/git:', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['git:commit', 'git:push']), + ); + + expect(result.current.suggestions.map((s) => s.label)).not.toContain( + 'docker:build', + ); + }); + + it('should not provide suggestions if the namespaced command is a perfect leaf match', () => { + const { result } = renderHook(() => + useCompletion( + '/git:commit', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.showSuggestions).toBe(false); + expect(result.current.suggestions).toHaveLength(0); + }); + }); + describe('File path completion (@-syntax)', () => { beforeEach(() => { vi.mocked(fs.readdir).mockResolvedValue([ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d7fd35c8..02fae607 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1058,6 +1058,65 @@ describe('useGeminiStream', () => { expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made }); }); + + it('should call Gemini with prompt content when slash command returns a `submit_prompt` action', async () => { + const customCommandResult: SlashCommandProcessorResult = { + type: 'submit_prompt', + content: 'This is the actual prompt from the command file.', + }; + mockHandleSlashCommand.mockResolvedValue(customCommandResult); + + const { result, mockSendMessageStream: localMockSendMessageStream } = + renderTestHook(); + + await act(async () => { + await result.current.submitQuery('/my-custom-command'); + }); + + await waitFor(() => { + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/my-custom-command', + ); + + expect(localMockSendMessageStream).not.toHaveBeenCalledWith( + '/my-custom-command', + expect.anything(), + expect.anything(), + ); + + expect(localMockSendMessageStream).toHaveBeenCalledWith( + 'This is the actual prompt from the command file.', + expect.any(AbortSignal), + expect.any(String), + ); + + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + }); + }); + + it('should correctly handle a submit_prompt action with empty content', async () => { + const emptyPromptResult: SlashCommandProcessorResult = { + type: 'submit_prompt', + content: '', + }; + mockHandleSlashCommand.mockResolvedValue(emptyPromptResult); + + const { result, mockSendMessageStream: localMockSendMessageStream } = + renderTestHook(); + + await act(async () => { + await result.current.submitQuery('/emptycmd'); + }); + + await waitFor(() => { + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/emptycmd'); + expect(localMockSendMessageStream).toHaveBeenCalledWith( + '', + expect.any(AbortSignal), + expect.any(String), + ); + }); + }); }); describe('Memory Refresh on save_memory', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 295b5650..456c0fb7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -240,19 +240,37 @@ export const useGeminiStream = ( const slashCommandResult = await handleSlashCommand(trimmedQuery); 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, - args: toolArgs, - isClientInitiated: true, - prompt_id, - }; - scheduleToolCalls([toolCallRequest], abortSignal); - } + switch (slashCommandResult.type) { + case 'schedule_tool': { + const { toolName, toolArgs } = slashCommandResult; + const toolCallRequest: ToolCallRequestInfo = { + callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name: toolName, + args: toolArgs, + isClientInitiated: true, + prompt_id, + }; + scheduleToolCalls([toolCallRequest], abortSignal); + return { queryToSend: null, shouldProceed: false }; + } + case 'submit_prompt': { + localQueryToSendToGemini = slashCommandResult.content; - return { queryToSend: null, shouldProceed: false }; + return { + queryToSend: localQueryToSendToGemini, + shouldProceed: true, + }; + } + case 'handled': { + return { queryToSend: null, shouldProceed: false }; + } + default: { + const unreachable: never = slashCommandResult; + throw new Error( + `Unhandled slash command result type: ${unreachable}`, + ); + } + } } if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 223ccd47..da95d6ec 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -217,6 +217,15 @@ export interface ConsoleMessageItem { count: number; } +/** + * Result type for a slash command that should immediately result in a prompt + * being submitted to the Gemini model. + */ +export interface SubmitPromptResult { + type: 'submit_prompt'; + content: string; +} + /** * Defines the result of the slash command processor for its consumer (useGeminiStream). */ @@ -228,4 +237,5 @@ export type SlashCommandProcessorResult = } | { type: 'handled'; // Indicates the command was processed and no further action is needed. - }; + } + | SubmitPromptResult; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c0faa166..55be9a03 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "dist", "jsx": "react-jsx", - "lib": ["DOM", "DOM.Iterable", "ES2020"], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vitest/globals"] }, "include": [ diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 3382c588..5370a7cb 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -11,6 +11,7 @@ import * as crypto from 'crypto'; export const GEMINI_DIR = '.gemini'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; const TMP_DIR_NAME = 'tmp'; +const COMMANDS_DIR_NAME = 'commands'; /** * Replaces the home directory with a tilde. @@ -158,3 +159,20 @@ export function getProjectTempDir(projectRoot: string): string { const hash = getProjectHash(projectRoot); return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash); } + +/** + * Returns the absolute path to the user-level commands directory. + * @returns The path to the user's commands directory. + */ +export function getUserCommandsDir(): string { + return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME); +} + +/** + * Returns the absolute path to the project-level commands directory. + * @param projectRoot The absolute path to the project's root directory. + * @returns The path to the project's commands directory. + */ +export function getProjectCommandsDir(projectRoot: string): string { + return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME); +}