mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
prefactor(commands): Command Service Prefactor for Extensible Commands (#4511)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,413 +11,169 @@ 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,
|
||||
}));
|
||||
|
||||
const mockLoadCommands = vi.fn();
|
||||
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
|
||||
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
|
||||
loadCommands: mockLoadCommands,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetCliVersionFn = vi.fn(() => Promise.resolve('0.1.0'));
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: (...args: []) => mockGetCliVersionFn(...args),
|
||||
}));
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, beforeAll, Mock } from 'vitest';
|
||||
import open from 'open';
|
||||
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';
|
||||
|
||||
vi.mock('../contexts/SessionContext.js', () => ({
|
||||
useSessionStats: vi.fn(),
|
||||
useSessionStats: vi.fn(() => ({ stats: {} })),
|
||||
}));
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
});
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
|
||||
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 mockAddItem = vi.fn();
|
||||
const mockClearItems = vi.fn();
|
||||
const mockLoadHistory = vi.fn();
|
||||
const mockSetShowHelp = vi.fn();
|
||||
const mockOpenAuthDialog = vi.fn();
|
||||
const mockSetQuittingMessages = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: () => '/mock/cwd',
|
||||
getSessionId: () => 'test-session',
|
||||
getGeminiClient: () => ({
|
||||
setHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
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
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('Command Processing', () => {
|
||||
let ActualCommandService: typeof CommandService;
|
||||
|
||||
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]);
|
||||
|
||||
it('should execute a registered command', async () => {
|
||||
const mockAction = vi.fn();
|
||||
const newCommand: SlashCommand = { name: 'test', action: mockAction };
|
||||
const mockLoader = async () => [newCommand];
|
||||
|
||||
// We create the instance outside the mock implementation.
|
||||
const commandServiceInstance = new ActualCommandService(
|
||||
mockConfig,
|
||||
mockLoader,
|
||||
);
|
||||
|
||||
// This mock ensures the hook uses our pre-configured instance.
|
||||
vi.mocked(CommandService).mockImplementation(
|
||||
() => commandServiceInstance,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
|
||||
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);
|
||||
await waitFor(() => {
|
||||
expect(result.current.slashCommands).toHaveLength(1);
|
||||
});
|
||||
|
||||
let commandResult: SlashCommandProcessorResult | false = false;
|
||||
expect(result.current.slashCommands[0]?.name).toBe('test');
|
||||
expect(mockLoadCommands).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should provide an immutable array of commands to consumers', async () => {
|
||||
const testCommand: SlashCommand = {
|
||||
name: 'test',
|
||||
description: 'a test command',
|
||||
kind: 'built-in',
|
||||
};
|
||||
const result = setupProcessorHook([testCommand]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.slashCommands).toHaveLength(1);
|
||||
});
|
||||
|
||||
const commands = result.current.slashCommands;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Execution Logic', () => {
|
||||
it('should display an error for an unknown command', async () => {
|
||||
const result = setupProcessorHook();
|
||||
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
||||
|
||||
await act(async () => {
|
||||
commandResult = await result.current.handleSlashCommand('/test');
|
||||
await result.current.handleSlashCommand('/nonexistent');
|
||||
});
|
||||
|
||||
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||
expect(commandResult).toEqual({ type: 'handled' });
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const commandResult = await result.current.handleSlashCommand('/test');
|
||||
|
||||
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||
expect(commandResult).toEqual({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'my_tool',
|
||||
toolArgs: { arg1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const commandResult = await result.current.handleSlashCommand('/test');
|
||||
|
||||
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
// Expect 2 calls: one for the user's input, one for the error message.
|
||||
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
||||
expect(mockAddItem).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'This is a message',
|
||||
type: MessageType.ERROR,
|
||||
text: 'Unknown command: /nonexistent',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
const newAuthCommand: SlashCommand = { name: 'auth', action: mockAction };
|
||||
|
||||
const mockLoader = async () => [newAuthCommand];
|
||||
const commandServiceInstance = new ActualCommandService(
|
||||
mockConfig,
|
||||
mockLoader,
|
||||
);
|
||||
vi.mocked(CommandService).mockImplementation(
|
||||
() => commandServiceInstance,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'auth'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const commandResult = await result.current.handleSlashCommand('/auth');
|
||||
|
||||
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenAuthDialog).toHaveBeenCalledWith();
|
||||
expect(commandResult).toEqual({ type: 'handled' });
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const commandResult = await result.current.handleSlashCommand('/test');
|
||||
|
||||
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenThemeDialog).toHaveBeenCalledWith();
|
||||
expect(commandResult).toEqual({ type: 'handled' });
|
||||
});
|
||||
|
||||
it('should show help for a parent command with no action', async () => {
|
||||
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: 'child', description: 'A child.', action: vi.fn() },
|
||||
{
|
||||
name: 'child1',
|
||||
description: 'First child.',
|
||||
kind: 'built-in',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockLoader = async () => [parentCommand];
|
||||
const commandServiceInstance = new ActualCommandService(
|
||||
mockConfig,
|
||||
mockLoader,
|
||||
);
|
||||
vi.mocked(CommandService).mockImplementation(
|
||||
() => commandServiceInstance,
|
||||
);
|
||||
|
||||
const { result } = getProcessorHook();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
result.current.slashCommands.some((c) => c.name === 'parent'),
|
||||
).toBe(true);
|
||||
});
|
||||
const result = setupProcessorHook([parentCommand]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/parent');
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
||||
expect(mockAddItem).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(
|
||||
"Command '/parent' requires a subcommand.",
|
||||
),
|
||||
@@ -425,5 +181,218 @@ describe('useSlashCommandProcessor', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/parent child with args');
|
||||
});
|
||||
|
||||
expect(childAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(childAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
services: expect.objectContaining({
|
||||
config: mockConfig,
|
||||
}),
|
||||
ui: expect.objectContaining({
|
||||
addItem: mockAddItem,
|
||||
}),
|
||||
}),
|
||||
'with args',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/helpcmd');
|
||||
});
|
||||
|
||||
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
expect(mockClearItems).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'user', text: 'old prompt' }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.slashCommands).toHaveLength(1),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/exit');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
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 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(' /test with-args ');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user