diff options
Diffstat (limited to 'packages/cli/src/ui')
31 files changed, 449 insertions, 387 deletions
diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 3cb8c2f6..18a82682 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -5,13 +5,14 @@ */ import { getCliVersion } from '../../utils/version.js'; -import { SlashCommand } from './types.js'; +import { CommandKind, SlashCommand } from './types.js'; import process from 'node:process'; import { MessageType, type HistoryItemAbout } from '../types.js'; export const aboutCommand: SlashCommand = { name: 'about', description: 'show version info', + kind: CommandKind.BUILT_IN, action: async (context) => { const osVersion = process.platform; let sandboxEnv = 'no sandbox'; diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 29bd2c9d..8e78cf86 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js'; export const authCommand: SlashCommand = { name: 'auth', description: 'change the auth method', + kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'auth', diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index c1b99db9..667276ab 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -6,7 +6,11 @@ import open from 'open'; import process from 'node:process'; -import { type CommandContext, type SlashCommand } from './types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; import { MessageType } from '../types.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatMemoryUsage } from '../utils/formatters.js'; @@ -15,6 +19,7 @@ import { getCliVersion } from '../../utils/version.js'; export const bugCommand: SlashCommand = { name: 'bug', description: 'submit a bug report', + kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise<void> => { const bugDescription = (args || '').trim(); const { config } = context.services; diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index fd56afbd..2f669481 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -5,7 +5,12 @@ */ import * as fsPromises from 'fs/promises'; -import { CommandContext, SlashCommand, MessageActionReturn } from './types.js'; +import { + CommandContext, + SlashCommand, + MessageActionReturn, + CommandKind, +} from './types.js'; import path from 'path'; import { HistoryItemWithoutId, MessageType } from '../types.js'; @@ -54,6 +59,7 @@ const getSavedChatTags = async ( const listCommand: SlashCommand = { name: 'list', description: 'List saved conversation checkpoints', + kind: CommandKind.BUILT_IN, action: async (context): Promise<MessageActionReturn> => { const chatDetails = await getSavedChatTags(context, false); if (chatDetails.length === 0) { @@ -81,6 +87,7 @@ const saveCommand: SlashCommand = { name: 'save', description: 'Save the current conversation as a checkpoint. Usage: /chat save <tag>', + kind: CommandKind.BUILT_IN, action: async (context, args): Promise<MessageActionReturn> => { const tag = args.trim(); if (!tag) { @@ -122,9 +129,10 @@ const saveCommand: SlashCommand = { const resumeCommand: SlashCommand = { name: 'resume', - altName: 'load', + altNames: ['load'], description: 'Resume a conversation from a checkpoint. Usage: /chat resume <tag>', + kind: CommandKind.BUILT_IN, action: async (context, args) => { const tag = args.trim(); if (!tag) { @@ -193,5 +201,6 @@ const resumeCommand: SlashCommand = { export const chatCommand: SlashCommand = { name: 'chat', description: 'Manage conversation history.', + kind: CommandKind.BUILT_IN, subCommands: [listCommand, saveCommand, resumeCommand], }; diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 1c409359..a2a1c13a 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -5,11 +5,12 @@ */ import { uiTelemetryService } from '@google/gemini-cli-core'; -import { SlashCommand } from './types.js'; +import { CommandKind, SlashCommand } from './types.js'; export const clearCommand: SlashCommand = { name: 'clear', description: 'clear the screen and conversation history', + kind: CommandKind.BUILT_IN, action: async (context, _args) => { const geminiClient = context.services.config?.getGeminiClient(); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index c3dfdf37..792e8b5b 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -5,12 +5,13 @@ */ import { HistoryItemCompression, MessageType } from '../types.js'; -import { SlashCommand } from './types.js'; +import { CommandKind, SlashCommand } from './types.js'; export const compressCommand: SlashCommand = { name: 'compress', - altName: 'summarize', + altNames: ['summarize'], description: 'Compresses the context by replacing it with a summary.', + kind: CommandKind.BUILT_IN, action: async (context) => { const { ui } = context; if (ui.pendingItem) { diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 5714b5ab..bd330faa 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -5,11 +5,16 @@ */ import { copyToClipboard } from '../utils/commandUtils.js'; -import { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { + CommandKind, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; export const copyCommand: SlashCommand = { name: 'copy', description: 'Copy the last result or code snippet to clipboard', + kind: CommandKind.BUILT_IN, action: async (context, _args): Promise<SlashCommandActionReturn | void> => { const chat = await context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistory(); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts index 290c071e..cb3ecd1c 100644 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ b/packages/cli/src/ui/commands/corgiCommand.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type SlashCommand } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; export const corgiCommand: SlashCommand = { name: 'corgi', description: 'Toggles corgi mode.', + kind: CommandKind.BUILT_IN, action: (context, _args) => { context.ui.toggleCorgiMode(); }, diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index e53a4a80..922b236a 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -6,12 +6,17 @@ import open from 'open'; import process from 'node:process'; -import { type CommandContext, type SlashCommand } from './types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; import { MessageType } from '../types.js'; export const docsCommand: SlashCommand = { name: 'docs', description: 'open full Gemini CLI documentation in your browser', + kind: CommandKind.BUILT_IN, action: async (context: CommandContext): Promise<void> => { const docsUrl = 'https://goo.gle/gemini-cli-docs'; diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index dbfafa51..5b5c4c5d 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -4,11 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type OpenDialogActionReturn, type SlashCommand } from './types.js'; +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; export const editorCommand: SlashCommand = { name: 'editor', description: 'set external editor preference', + kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'editor', diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 09241e5f..ea9f9a4f 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,12 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type CommandContext, type SlashCommand } from './types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; import { MessageType } from '../types.js'; export const extensionsCommand: SlashCommand = { name: 'extensions', description: 'list active extensions', + kind: CommandKind.BUILT_IN, action: async (context: CommandContext): Promise<void> => { const activeExtensions = context.services.config ?.getExtensions() diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index a6b19c05..b0441106 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -32,9 +32,9 @@ describe('helpCommand', () => { }); it("should also be triggered by its alternative name '?'", () => { - // This test is more conceptual. The routing of altName to the command + // This test is more conceptual. The routing of altNames 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('?'); + // altNames is correctly defined on the command object itself. + expect(helpCommand.altNames).toContain('?'); }); }); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 82d0d536..03c64615 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js'; export const helpCommand: SlashCommand = { name: 'help', - altName: '?', + altNames: ['?'], description: 'for help on gemini-cli', + kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => { console.debug('Opening help UI ...'); return { diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 0251e619..6fc4f50b 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -17,6 +17,7 @@ import { CommandContext, SlashCommand, SlashCommandActionReturn, + CommandKind, } from './types.js'; import * as child_process from 'child_process'; import * as process from 'process'; @@ -48,10 +49,12 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { return { name: 'ide', description: 'manage IDE integration', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'status', description: 'check status of IDE integration', + kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { const status = getMCPServerStatus(IDE_SERVER_NAME); const discoveryState = getMCPDiscoveryState(); @@ -89,6 +92,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { { name: 'install', description: 'install required VS Code companion extension', + kind: CommandKind.BUILT_IN, action: async (context) => { if (!isVSCodeInstalled()) { context.ui.addItem( diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 891227b0..373f1ca5 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -8,6 +8,7 @@ import { SlashCommand, SlashCommandActionReturn, CommandContext, + CommandKind, } from './types.js'; import { DiscoveredMCPTool, @@ -229,6 +230,7 @@ const getMcpStatus = async ( export const mcpCommand: SlashCommand = { name: 'mcp', description: 'list configured MCP servers and tools', + kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args: string) => { const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 18ca96bb..afa43031 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -6,15 +6,21 @@ import { getErrorMessage } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; -import { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { + CommandKind, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; export const memoryCommand: SlashCommand = { name: 'memory', description: 'Commands for interacting with memory.', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'show', description: 'Show the current memory contents.', + kind: CommandKind.BUILT_IN, action: async (context) => { const memoryContent = context.services.config?.getUserMemory() || ''; const fileCount = context.services.config?.getGeminiMdFileCount() || 0; @@ -36,6 +42,7 @@ export const memoryCommand: SlashCommand = { { name: 'add', description: 'Add content to the memory.', + kind: CommandKind.BUILT_IN, action: (context, args): SlashCommandActionReturn | void => { if (!args || args.trim() === '') { return { @@ -63,6 +70,7 @@ export const memoryCommand: SlashCommand = { { name: 'refresh', description: 'Refresh the memory from the source.', + kind: CommandKind.BUILT_IN, action: async (context) => { context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/privacyCommand.ts b/packages/cli/src/ui/commands/privacyCommand.ts index f239158c..ef9d08a0 100644 --- a/packages/cli/src/ui/commands/privacyCommand.ts +++ b/packages/cli/src/ui/commands/privacyCommand.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js'; export const privacyCommand: SlashCommand = { name: 'privacy', description: 'display the privacy notice', + kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'privacy', diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 48daf8c2..36f15c71 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -5,12 +5,13 @@ */ import { formatDuration } from '../utils/formatters.js'; -import { type SlashCommand } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; export const quitCommand: SlashCommand = { name: 'quit', - altName: 'exit', + altNames: ['exit'], description: 'exit the cli', + kind: CommandKind.BUILT_IN, action: (context) => { const now = Date.now(); const { sessionStartTime } = context.session.stats; diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 3d744189..84259288 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -10,6 +10,7 @@ import { type CommandContext, type SlashCommand, type SlashCommandActionReturn, + CommandKind, } from './types.js'; import { Config } from '@google/gemini-cli-core'; @@ -149,6 +150,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => { name: 'restore', description: 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', + kind: CommandKind.BUILT_IN, action: restoreAction, completion, }; diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 87e902d4..e9e69756 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -6,12 +6,17 @@ import { MessageType, HistoryItemStats } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; -import { type CommandContext, type SlashCommand } from './types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; export const statsCommand: SlashCommand = { name: 'stats', - altName: 'usage', + altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', + kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { const now = new Date(); const { sessionStartTime } = context.session.stats; @@ -38,6 +43,7 @@ export const statsCommand: SlashCommand = { { name: 'model', description: 'Show model-specific usage statistics.', + kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { context.ui.addItem( { @@ -50,6 +56,7 @@ export const statsCommand: SlashCommand = { { name: 'tools', description: 'Show tool-specific usage statistics.', + kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 29e9a491..755d59d9 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js'; export const themeCommand: SlashCommand = { name: 'theme', description: 'change the theme', + kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'theme', diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index f65edd07..e993bab3 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -4,12 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type CommandContext, type SlashCommand } from './types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; import { MessageType } from '../types.js'; export const toolsCommand: SlashCommand = { name: 'tools', description: 'list available Gemini CLI tools', + kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise<void> => { const subCommand = args?.trim(); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 3e269cbf..3ffadf83 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -106,11 +106,18 @@ export type SlashCommandActionReturn = | OpenDialogActionReturn | LoadHistoryActionReturn; +export enum CommandKind { + BUILT_IN = 'built-in', + FILE = 'file', +} + // The standardized contract for any command in the system. export interface SlashCommand { name: string; - altName?: string; - description?: string; + altNames?: string[]; + description: string; + + kind: CommandKind; // The action to run. Optional for parent commands that only group sub-commands. action?: ( diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index c51867af..ecad9b5e 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -10,7 +10,7 @@ import { Colors } from '../colors.js'; import { SlashCommand } from '../commands/types.js'; interface Help { - commands: SlashCommand[]; + commands: readonly SlashCommand[]; } export const Help: React.FC<Help> = ({ commands }) => ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 6b201901..886a6235 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -451,13 +451,13 @@ describe('InputPrompt', () => { unmount(); }); - it('should complete a command based on its altName', async () => { - // Add a command with an altName to our mock for this test + it('should complete a command based on its altNames', async () => { + // Add a command with an altNames to our mock for this test props.slashCommands.push({ name: 'help', - altName: '?', + altNames: ['?'], description: '...', - }); + } as SlashCommand); mockedUseCompletion.mockReturnValue({ ...mockCompletion, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 46326431..b7c53196 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -32,7 +32,7 @@ export interface InputPromptProps { userMessages: readonly string[]; onClearScreen: () => void; config: Config; - slashCommands: SlashCommand[]; + slashCommands: readonly SlashCommand[]; commandContext: CommandContext; placeholder?: string; focus?: boolean; @@ -180,18 +180,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ // 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; + let currentLevel: readonly 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, + (cmd) => cmd.name === part || cmd.altNames?.includes(part), ); if (found) { if (i === parts.length - 1 && found.subCommands) { isParentPath = true; } - currentLevel = found.subCommands; + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; } else { // Path is invalid, so it can't be a parent path. currentLevel = undefined; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 4920b088..32a6810e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -11,419 +11,388 @@ const { mockProcessExit } = vi.hoisted(() => ({ vi.mock('node:process', () => ({ default: { exit: mockProcessExit, - cwd: vi.fn(() => '/mock/cwd'), - get env() { - return process.env; - }, - platform: 'test-platform', - version: 'test-node-version', - memoryUsage: vi.fn(() => ({ - rss: 12345678, - heapTotal: 23456789, - heapUsed: 10234567, - external: 1234567, - arrayBuffers: 123456, - })), }, - exit: mockProcessExit, - cwd: vi.fn(() => '/mock/cwd'), - get env() { - return process.env; - }, - platform: 'test-platform', - version: 'test-node-version', - memoryUsage: vi.fn(() => ({ - rss: 12345678, - heapTotal: 23456789, - heapUsed: 10234567, - external: 1234567, - arrayBuffers: 123456, - })), })); -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), +const mockLoadCommands = vi.fn(); +vi.mock('../../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ + loadCommands: mockLoadCommands, + })), })); -const mockGetCliVersionFn = vi.fn(() => Promise.resolve('0.1.0')); -vi.mock('../../utils/version.js', () => ({ - getCliVersion: (...args: []) => mockGetCliVersionFn(...args), +vi.mock('../contexts/SessionContext.js', () => ({ + useSessionStats: vi.fn(() => ({ stats: {} })), })); -import { act, renderHook } from '@testing-library/react'; -import { vi, describe, it, expect, beforeEach, beforeAll, Mock } from 'vitest'; -import open from 'open'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; -import { SlashCommandProcessorResult } from '../types.js'; -import { Config, GeminiClient } from '@google/gemini-cli-core'; -import { useSessionStats } from '../contexts/SessionContext.js'; -import { LoadedSettings } from '../../config/settings.js'; -import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; -import { CommandService } from '../../services/CommandService.js'; import { 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'; -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()), -})); - -vi.mock('open', () => ({ - default: vi.fn(), -})); +describe('useSlashCommandProcessor', () => { + const mockAddItem = vi.fn(); + const mockClearItems = vi.fn(); + const mockLoadHistory = vi.fn(); + const mockSetShowHelp = vi.fn(); + const mockOpenAuthDialog = vi.fn(); + const mockSetQuittingMessages = vi.fn(); -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal<typeof import('@google/gemini-cli-core')>(); - return { - ...actual, - }; -}); + const mockConfig = { + getProjectRoot: () => '/mock/cwd', + getSessionId: () => 'test-session', + getGeminiClient: () => ({ + setHistory: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as Config; -describe('useSlashCommandProcessor', () => { - let mockAddItem: ReturnType<typeof vi.fn>; - let mockClearItems: ReturnType<typeof vi.fn>; - let mockLoadHistory: ReturnType<typeof vi.fn>; - let mockRefreshStatic: ReturnType<typeof vi.fn>; - let mockSetShowHelp: ReturnType<typeof vi.fn>; - let mockOnDebugMessage: ReturnType<typeof vi.fn>; - let mockOpenThemeDialog: ReturnType<typeof vi.fn>; - let mockOpenAuthDialog: ReturnType<typeof vi.fn>; - let mockOpenEditorDialog: ReturnType<typeof vi.fn>; - let mockSetQuittingMessages: ReturnType<typeof vi.fn>; - let mockTryCompressChat: ReturnType<typeof vi.fn>; - let mockGeminiClient: GeminiClient; - let mockConfig: Config; - let mockCorgiMode: ReturnType<typeof vi.fn>; - const mockUseSessionStats = useSessionStats as Mock; + const mockSettings = {} as LoadedSettings; beforeEach(() => { vi.clearAllMocks(); - - mockAddItem = vi.fn(); - mockClearItems = vi.fn(); - mockLoadHistory = vi.fn(); - mockRefreshStatic = vi.fn(); - mockSetShowHelp = vi.fn(); - mockOnDebugMessage = vi.fn(); - mockOpenThemeDialog = vi.fn(); - mockOpenAuthDialog = vi.fn(); - mockOpenEditorDialog = vi.fn(); - mockSetQuittingMessages = vi.fn(); - mockTryCompressChat = vi.fn(); - mockGeminiClient = { - tryCompressChat: mockTryCompressChat, - } as unknown as GeminiClient; - mockConfig = { - getDebugMode: vi.fn(() => false), - getGeminiClient: () => mockGeminiClient, - getSandbox: vi.fn(() => 'test-sandbox'), - getModel: vi.fn(() => 'test-model'), - getProjectRoot: vi.fn(() => '/test/dir'), - getCheckpointingEnabled: vi.fn(() => true), - getBugCommand: vi.fn(() => undefined), - getSessionId: vi.fn(() => 'test-session-id'), - getIdeMode: vi.fn(() => false), - } as unknown as Config; - mockCorgiMode = vi.fn(); - mockUseSessionStats.mockReturnValue({ - stats: { - sessionStartTime: new Date('2025-01-01T00:00:00.000Z'), - cumulative: { - promptCount: 0, - promptTokenCount: 0, - candidatesTokenCount: 0, - totalTokenCount: 0, - cachedContentTokenCount: 0, - toolUsePromptTokenCount: 0, - thoughtsTokenCount: 0, - }, - }, - }); - - (open as Mock).mockClear(); - mockProcessExit.mockClear(); - (ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear(); - process.env = { ...globalThis.process.env }; + (vi.mocked(BuiltinCommandLoader) as Mock).mockClear(); + mockLoadCommands.mockResolvedValue([]); }); - const getProcessorHook = () => { - const settings = { - merged: { - contextFileName: 'GEMINI.md', - }, - } as unknown as LoadedSettings; - return renderHook(() => + const setupProcessorHook = (commands: SlashCommand[] = []) => { + mockLoadCommands.mockResolvedValue(Object.freeze(commands)); + const { result } = renderHook(() => useSlashCommandProcessor( mockConfig, - settings, + mockSettings, mockAddItem, mockClearItems, mockLoadHistory, - mockRefreshStatic, + vi.fn(), // refreshStatic mockSetShowHelp, - mockOnDebugMessage, - mockOpenThemeDialog, + vi.fn(), // onDebugMessage + vi.fn(), // openThemeDialog mockOpenAuthDialog, - mockOpenEditorDialog, - mockCorgiMode, + vi.fn(), // openEditorDialog + vi.fn(), // toggleCorgiMode mockSetQuittingMessages, - vi.fn(), // mockOpenPrivacyNotice + vi.fn(), // openPrivacyNotice ), ); - }; - describe('Command Processing', () => { - let ActualCommandService: typeof CommandService; + return result; + }; - beforeAll(async () => { - const actual = (await vi.importActual( - '../../services/CommandService.js', - )) as { CommandService: typeof CommandService }; - ActualCommandService = actual.CommandService; + describe('Initialization and Command Loading', () => { + it('should initialize CommandService with BuiltinCommandLoader', () => { + setupProcessorHook(); + expect(BuiltinCommandLoader).toHaveBeenCalledTimes(1); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); }); - beforeEach(() => { - vi.clearAllMocks(); + it('should call loadCommands and populate state after mounting', async () => { + const testCommand: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', + }; + const result = setupProcessorHook([testCommand]); + + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(1); + }); + + expect(result.current.slashCommands[0]?.name).toBe('test'); + expect(mockLoadCommands).toHaveBeenCalledTimes(1); }); - it('should execute a registered command', async () => { - const mockAction = vi.fn(); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; + it('should provide an immutable array of commands to consumers', async () => { + const testCommand: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', + }; + const result = setupProcessorHook([testCommand]); - // We create the instance outside the mock implementation. - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(1); + }); - // This mock ensures the hook uses our pre-configured instance. - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + const commands = result.current.slashCommands; - const { result } = getProcessorHook(); + 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', + }); + }).toThrow(TypeError); + }); + }); - 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); - }); + describe('Command Execution Logic', () => { + it('should display an error for an unknown command', async () => { + const result = setupProcessorHook(); + await waitFor(() => expect(result.current.slashCommands).toBeDefined()); - let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { - commandResult = await result.current.handleSlashCommand('/test'); + await result.current.handleSlashCommand('/nonexistent'); }); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(commandResult).toEqual({ type: 'handled' }); + // 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), + ); }); - it('should return "schedule_tool" for a command returning 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( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + it('should display help for a parent command invoked without a subcommand', async () => { + const parentCommand: SlashCommand = { + name: 'parent', + description: 'a parent command', + kind: 'built-in', + subCommands: [ + { + name: 'child1', + description: 'First child.', + kind: 'built-in', + }, + ], + }; + const result = setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/parent'); }); - const commandResult = await result.current.handleSlashCommand('/test'); - - expect(mockAction).toHaveBeenCalledTimes(1); - expect(commandResult).toEqual({ - type: 'schedule_tool', - toolName: 'my_tool', - toolArgs: { arg1: 'value1' }, - }); + expect(mockAddItem).toHaveBeenCalledTimes(2); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining( + "Command '/parent' requires a subcommand.", + ), + }), + expect.any(Number), + ); }); - it('should return "handled" for a command returning 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( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + it('should correctly find and execute a nested subcommand', async () => { + const childAction = vi.fn(); + const parentCommand: SlashCommand = { + name: 'parent', + description: 'a parent command', + kind: 'built-in', + subCommands: [ + { + name: 'child', + description: 'a child command', + kind: 'built-in', + action: childAction, + }, + ], + }; + const result = setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/parent child with args'); }); - const commandResult = await result.current.handleSlashCommand('/test'); + expect(childAction).toHaveBeenCalledTimes(1); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockAddItem).toHaveBeenCalledWith( + expect(childAction).toHaveBeenCalledWith( expect.objectContaining({ - type: 'info', - text: 'This is a message', + services: expect.objectContaining({ + config: mockConfig, + }), + ui: expect.objectContaining({ + addItem: mockAddItem, + }), }), - expect.any(Number), + 'with args', ); - expect(commandResult).toEqual({ type: 'handled' }); }); + }); - it('should return "handled" for a command returning 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( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + describe('Action Result Handling', () => { + it('should handle "dialog: help" action', async () => { + const command: SlashCommand = { + 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)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/helpcmd'); }); - const commandResult = await result.current.handleSlashCommand('/test'); - - expect(mockAction).toHaveBeenCalledTimes(1); expect(mockSetShowHelp).toHaveBeenCalledWith(true); - expect(commandResult).toEqual({ type: 'handled' }); }); - it('should open the auth dialog for a command returning an auth dialog action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'dialog', - dialog: 'auth', + it('should handle "load_history" action', async () => { + const command: SlashCommand = { + 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)); + + await act(async () => { + await result.current.handleSlashCommand('/load'); }); - const newAuthCommand: SlashCommand = { name: 'auth', action: mockAction }; - const mockLoader = async () => [newAuthCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, + expect(mockClearItems).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'user', text: 'old prompt' }), + expect.any(Number), ); + }); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'auth'), - ).toBe(true); - }); - - const commandResult = await result.current.handleSlashCommand('/auth'); + describe('with fake timers', () => { + // This test needs to let the async `waitFor` complete with REAL timers + // before switching to FAKE timers to test setTimeout. + it('should handle a "quit" action', async () => { + const quitAction = vi + .fn() + .mockResolvedValue({ type: 'quit', messages: [] }); + const command: SlashCommand = { + name: 'exit', + description: 'an exit command', + kind: 'built-in', + action: quitAction, + }; + const result = setupProcessorHook([command]); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockOpenAuthDialog).toHaveBeenCalledWith(); - expect(commandResult).toEqual({ type: 'handled' }); - }); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); - it('should open the theme dialog for a command returning a theme dialog action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'dialog', - dialog: 'theme', - }); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + vi.useFakeTimers(); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); - }); + try { + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); - const commandResult = await result.current.handleSlashCommand('/test'); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockOpenThemeDialog).toHaveBeenCalledWith(); - expect(commandResult).toEqual({ type: 'handled' }); + expect(mockSetQuittingMessages).toHaveBeenCalledWith([]); + expect(mockProcessExit).toHaveBeenCalledWith(0); + } finally { + vi.useRealTimers(); + } + }); }); + }); - 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() }, - ], + describe('Command Parsing and Matching', () => { + it('should be case-sensitive', async () => { + const command: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const mockLoader = async () => [parentCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, + await act(async () => { + // Use uppercase when command is lowercase + await result.current.handleSlashCommand('/Test'); + }); + + // It should fail and call addItem with an error + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Unknown command: /Test', + }), + expect.any(Number), ); + }); - const { result } = getProcessorHook(); + it('should correctly match an altName', async () => { + const action = vi.fn(); + const command: SlashCommand = { + 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)); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'parent'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/alias'); }); + expect(action).toHaveBeenCalledTimes(1); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: MessageType.ERROR }), + ); + }); + + 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 result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await act(async () => { - await result.current.handleSlashCommand('/parent'); + await result.current.handleSlashCommand(' /test with-args '); }); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining( - "Command '/parent' requires a subcommand.", - ), - }), - expect.any(Number), + expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args'); + }); + }); + + describe('Lifecycle', () => { + it('should abort command loading when the hook unmounts', async () => { + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const { unmount } = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), // refreshStatic + mockSetShowHelp, + vi.fn(), // onDebugMessage + vi.fn(), // openThemeDialog + mockOpenAuthDialog, + vi.fn(), // openEditorDialog + vi.fn(), // toggleCorgiMode + mockSetQuittingMessages, + vi.fn(), // openPrivacyNotice + ), ); + + unmount(); + + expect(abortSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index b56adeaf..cdf071b1 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -21,6 +21,7 @@ import { 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'; /** * Hook to define and process slash commands (e.g., /help, /clear). @@ -42,7 +43,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, ) => { const session = useSessionStats(); - const [commands, setCommands] = useState<SlashCommand[]>([]); + const [commands, setCommands] = useState<readonly SlashCommand[]>([]); const gitService = useMemo(() => { if (!config?.getProjectRoot()) { return; @@ -158,16 +159,24 @@ export const useSlashCommandProcessor = ( ], ); - const commandService = useMemo(() => new CommandService(config), [config]); - useEffect(() => { + const controller = new AbortController(); const load = async () => { - await commandService.loadCommands(); + // TODO - Add other loaders for custom commands. + const loaders = [new BuiltinCommandLoader(config)]; + const commandService = await CommandService.create( + loaders, + controller.signal, + ); setCommands(commandService.getCommands()); }; load(); - }, [commandService]); + + return () => { + controller.abort(); + }; + }, [config]); const handleSlashCommand = useCallback( async ( @@ -199,7 +208,7 @@ export const useSlashCommandProcessor = ( for (const part of commandPath) { const foundCommand = currentCommands.find( - (cmd) => cmd.name === part || cmd.altName === part, + (cmd) => cmd.name === part || cmd.altNames?.includes(part), ); if (foundCommand) { diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts index f6f0944b..02162159 100644 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts @@ -53,13 +53,13 @@ describe('useCompletion git-aware filtering integration', () => { const mockSlashCommands: SlashCommand[] = [ { name: 'help', - altName: '?', + altNames: ['?'], description: 'Show help', action: vi.fn(), }, { name: 'stats', - altName: 'usage', + altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), }, @@ -553,7 +553,7 @@ describe('useCompletion git-aware filtering integration', () => { }); it.each([['/?'], ['/usage']])( - 'should not suggest commands when altName is fully typed', + 'should not suggest commands when altNames is fully typed', async (altName) => { const { result } = renderHook(() => useCompletion( @@ -569,7 +569,7 @@ describe('useCompletion git-aware filtering integration', () => { }, ); - it('should suggest commands based on partial altName matches', async () => { + it('should suggest commands based on partial altNames matches', async () => { const { result } = renderHook(() => useCompletion( '/usag', // part of the word "usage" diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index f4227c1a..d1b22a88 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -66,13 +66,13 @@ describe('useCompletion', () => { mockSlashCommands = [ { name: 'help', - altName: '?', + altNames: ['?'], description: 'Show help', action: vi.fn(), }, { name: 'stats', - altName: 'usage', + altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), }, @@ -410,7 +410,7 @@ describe('useCompletion', () => { }); it.each([['/?'], ['/usage']])( - 'should not suggest commands when altName is fully typed', + 'should not suggest commands when altNames is fully typed', (altName) => { const { result } = renderHook(() => useCompletion( @@ -427,7 +427,7 @@ describe('useCompletion', () => { }, ); - it('should suggest commands based on partial altName matches', () => { + it('should suggest commands based on partial altNames matches', () => { const { result } = renderHook(() => useCompletion( '/usag', // part of the word "usage" diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 69d8bfb9..aacc111d 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -41,7 +41,7 @@ export function useCompletion( query: string, cwd: string, isActive: boolean, - slashCommands: SlashCommand[], + slashCommands: readonly SlashCommand[], commandContext: CommandContext, config?: Config, ): UseCompletionReturn { @@ -151,7 +151,7 @@ export function useCompletion( } // Traverse the Command Tree using the tentative completed path - let currentLevel: SlashCommand[] | undefined = slashCommands; + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; let leafCommand: SlashCommand | null = null; for (const part of commandPathParts) { @@ -161,11 +161,13 @@ export function useCompletion( break; } const found: SlashCommand | undefined = currentLevel.find( - (cmd) => cmd.name === part || cmd.altName === part, + (cmd) => cmd.name === part || cmd.altNames?.includes(part), ); if (found) { leafCommand = found; - currentLevel = found.subCommands; + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; } else { leafCommand = null; currentLevel = []; @@ -177,7 +179,7 @@ export function useCompletion( if (!hasTrailingSpace && currentLevel) { const exactMatchAsParent = currentLevel.find( (cmd) => - (cmd.name === partial || cmd.altName === partial) && + (cmd.name === partial || cmd.altNames?.includes(partial)) && cmd.subCommands, ); @@ -199,7 +201,8 @@ export function useCompletion( // Case: /command subcommand<enter> const perfectMatch = currentLevel.find( (cmd) => - (cmd.name === partial || cmd.altName === partial) && cmd.action, + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.action, ); if (perfectMatch) { setIsPerfectMatch(true); @@ -238,14 +241,15 @@ export function useCompletion( let potentialSuggestions = commandsToSearch.filter( (cmd) => cmd.description && - (cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)), + (cmd.name.startsWith(partial) || + cmd.altNames?.some((alt) => alt.startsWith(partial))), ); // If a user's input is an exact match and it is a leaf command, // enter should submit immediately. if (potentialSuggestions.length > 0 && !hasTrailingSpace) { const perfectMatch = potentialSuggestions.find( - (s) => s.name === partial || s.altName === partial, + (s) => s.name === partial || s.altNames?.includes(partial), ); if (perfectMatch && perfectMatch.action) { potentialSuggestions = []; |
