summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui')
-rw-r--r--packages/cli/src/ui/commands/aboutCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/authCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/bugCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/chatCommand.ts13
-rw-r--r--packages/cli/src/ui/commands/clearCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/compressCommand.ts5
-rw-r--r--packages/cli/src/ui/commands/copyCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/corgiCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/docsCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/editorCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/extensionsCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/helpCommand.test.ts6
-rw-r--r--packages/cli/src/ui/commands/helpCommand.ts5
-rw-r--r--packages/cli/src/ui/commands/ideCommand.ts4
-rw-r--r--packages/cli/src/ui/commands/mcpCommand.ts2
-rw-r--r--packages/cli/src/ui/commands/memoryCommand.ts10
-rw-r--r--packages/cli/src/ui/commands/privacyCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/quitCommand.ts5
-rw-r--r--packages/cli/src/ui/commands/restoreCommand.ts2
-rw-r--r--packages/cli/src/ui/commands/statsCommand.ts11
-rw-r--r--packages/cli/src/ui/commands/themeCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/toolsCommand.ts7
-rw-r--r--packages/cli/src/ui/commands/types.ts11
-rw-r--r--packages/cli/src/ui/components/Help.tsx2
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx8
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx10
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts625
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts21
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts8
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.test.ts8
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts20
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 = [];