Migrate /corgi (#4419)

This commit is contained in:
Abhi 2025-07-17 19:40:36 -04:00 committed by GitHub
parent 5df6c9fb66
commit ca07b5b0c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 76 additions and 138 deletions

View File

@ -11,6 +11,7 @@ import { type SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js'; import { chatCommand } from '../ui/commands/chatCommand.js';
import { authCommand } from '../ui/commands/authCommand.js'; import { authCommand } from '../ui/commands/authCommand.js';
@ -38,6 +39,9 @@ vi.mock('../ui/commands/helpCommand.js', () => ({
vi.mock('../ui/commands/clearCommand.js', () => ({ vi.mock('../ui/commands/clearCommand.js', () => ({
clearCommand: { name: 'clear', description: 'Mock Clear' }, clearCommand: { name: 'clear', description: 'Mock Clear' },
})); }));
vi.mock('../ui/commands/corgiCommand.js', () => ({
corgiCommand: { name: 'corgi', description: 'Mock Corgi' },
}));
vi.mock('../ui/commands/docsCommand.js', () => ({ vi.mock('../ui/commands/docsCommand.js', () => ({
docsCommand: { name: 'docs', description: 'Mock Docs' }, docsCommand: { name: 'docs', description: 'Mock Docs' },
})); }));
@ -85,7 +89,7 @@ vi.mock('../ui/commands/restoreCommand.js', () => ({
})); }));
describe('CommandService', () => { describe('CommandService', () => {
const subCommandLen = 17; const subCommandLen = 18;
let mockConfig: Mocked<Config>; let mockConfig: Mocked<Config>;
beforeEach(() => { beforeEach(() => {
@ -128,6 +132,8 @@ describe('CommandService', () => {
expect(commandNames).toContain('memory'); expect(commandNames).toContain('memory');
expect(commandNames).toContain('help'); expect(commandNames).toContain('help');
expect(commandNames).toContain('clear'); expect(commandNames).toContain('clear');
expect(commandNames).toContain('compress');
expect(commandNames).toContain('corgi');
expect(commandNames).toContain('docs'); expect(commandNames).toContain('docs');
expect(commandNames).toContain('chat'); expect(commandNames).toContain('chat');
expect(commandNames).toContain('theme'); expect(commandNames).toContain('theme');
@ -136,7 +142,6 @@ describe('CommandService', () => {
expect(commandNames).toContain('about'); expect(commandNames).toContain('about');
expect(commandNames).toContain('extensions'); expect(commandNames).toContain('extensions');
expect(commandNames).toContain('tools'); expect(commandNames).toContain('tools');
expect(commandNames).toContain('compress');
expect(commandNames).toContain('mcp'); expect(commandNames).toContain('mcp');
expect(commandNames).not.toContain('ide'); expect(commandNames).not.toContain('ide');
}); });
@ -201,6 +206,7 @@ describe('CommandService', () => {
chatCommand, chatCommand,
clearCommand, clearCommand,
compressCommand, compressCommand,
corgiCommand,
docsCommand, docsCommand,
editorCommand, editorCommand,
extensionsCommand, extensionsCommand,

View File

@ -9,6 +9,7 @@ import { SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { authCommand } from '../ui/commands/authCommand.js'; import { authCommand } from '../ui/commands/authCommand.js';
@ -36,6 +37,7 @@ const loadBuiltInCommands = async (
chatCommand, chatCommand,
clearCommand, clearCommand,
compressCommand, compressCommand,
corgiCommand,
docsCommand, docsCommand,
editorCommand, editorCommand,
extensionsCommand, extensionsCommand,

View File

@ -47,6 +47,7 @@ export const createMockCommandContext = (
pendingItem: null, pendingItem: null,
setPendingItem: vi.fn(), setPendingItem: vi.fn(),
loadHistory: vi.fn(), loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
}, },
session: { session: {
stats: { stats: {

View File

@ -379,7 +379,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
} = useSlashCommandProcessor( } = useSlashCommandProcessor(
config, config,
settings, settings,
history,
addItem, addItem,
clearItems, clearItems,
loadHistory, loadHistory,

View File

@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@ -47,6 +47,8 @@ export interface CommandContext {
* @param history The array of history items to load. * @param history The array of history items to load.
*/ */
loadHistory: UseHistoryManagerReturn['loadHistory']; loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
}; };
// Session-specific data // Session-specific data
session: { session: {
@ -103,6 +105,7 @@ export type SlashCommandActionReturn =
| QuitActionReturn | QuitActionReturn
| OpenDialogActionReturn | OpenDialogActionReturn
| LoadHistoryActionReturn; | LoadHistoryActionReturn;
// The standardized contract for any command in the system. // The standardized contract for any command in the system.
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;

View File

@ -14,7 +14,7 @@ vi.mock('node:process', () => ({
cwd: vi.fn(() => '/mock/cwd'), cwd: vi.fn(() => '/mock/cwd'),
get env() { get env() {
return process.env; return process.env;
}, // Use a getter to ensure current process.env is used },
platform: 'test-platform', platform: 'test-platform',
version: 'test-node-version', version: 'test-node-version',
memoryUsage: vi.fn(() => ({ memoryUsage: vi.fn(() => ({
@ -25,12 +25,11 @@ vi.mock('node:process', () => ({
arrayBuffers: 123456, arrayBuffers: 123456,
})), })),
}, },
// Provide top-level exports as well for compatibility
exit: mockProcessExit, exit: mockProcessExit,
cwd: vi.fn(() => '/mock/cwd'), cwd: vi.fn(() => '/mock/cwd'),
get env() { get env() {
return process.env; return process.env;
}, // Use a getter here too },
platform: 'test-platform', platform: 'test-platform',
version: 'test-node-version', version: 'test-node-version',
memoryUsage: vi.fn(() => ({ memoryUsage: vi.fn(() => ({
@ -106,20 +105,8 @@ describe('useSlashCommandProcessor', () => {
const mockUseSessionStats = useSessionStats as Mock; const mockUseSessionStats = useSessionStats as Mock;
beforeEach(() => { beforeEach(() => {
// Reset all mocks to clear any previous state or calls.
vi.clearAllMocks(); vi.clearAllMocks();
// Default mock setup for CommandService for all the OLD tests.
// This makes them pass again by simulating the original behavior where
// the service is constructed but doesn't do much yet.
vi.mocked(CommandService).mockImplementation(
() =>
({
loadCommands: vi.fn().mockResolvedValue(undefined),
getCommands: vi.fn().mockReturnValue([]), // Return an empty array by default
}) as unknown as CommandService,
);
mockAddItem = vi.fn(); mockAddItem = vi.fn();
mockClearItems = vi.fn(); mockClearItems = vi.fn();
mockLoadHistory = vi.fn(); mockLoadHistory = vi.fn();
@ -177,7 +164,6 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor( useSlashCommandProcessor(
mockConfig, mockConfig,
settings, settings,
[],
mockAddItem, mockAddItem,
mockClearItems, mockClearItems,
mockLoadHistory, mockLoadHistory,
@ -194,7 +180,7 @@ describe('useSlashCommandProcessor', () => {
); );
}; };
describe('New command registry', () => { describe('Command Processing', () => {
let ActualCommandService: typeof CommandService; let ActualCommandService: typeof CommandService;
beforeAll(async () => { beforeAll(async () => {
@ -208,7 +194,7 @@ describe('useSlashCommandProcessor', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should execute a command from the new registry', async () => { it('should execute a registered command', async () => {
const mockAction = vi.fn(); const mockAction = vi.fn();
const newCommand: SlashCommand = { name: 'test', action: mockAction }; const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand]; const mockLoader = async () => [newCommand];
@ -243,7 +229,7 @@ describe('useSlashCommandProcessor', () => {
expect(commandResult).toEqual({ type: 'handled' }); expect(commandResult).toEqual({ type: 'handled' });
}); });
it('should return "schedule_tool" when a new command returns a tool action', async () => { it('should return "schedule_tool" for a command returning a tool action', async () => {
const mockAction = vi.fn().mockResolvedValue({ const mockAction = vi.fn().mockResolvedValue({
type: 'tool', type: 'tool',
toolName: 'my_tool', toolName: 'my_tool',
@ -276,7 +262,7 @@ describe('useSlashCommandProcessor', () => {
}); });
}); });
it('should return "handled" when a new command returns a message action', async () => { it('should return "handled" for a command returning a message action', async () => {
const mockAction = vi.fn().mockResolvedValue({ const mockAction = vi.fn().mockResolvedValue({
type: 'message', type: 'message',
messageType: 'info', messageType: 'info',
@ -312,7 +298,7 @@ describe('useSlashCommandProcessor', () => {
expect(commandResult).toEqual({ type: 'handled' }); expect(commandResult).toEqual({ type: 'handled' });
}); });
it('should return "handled" when a new command returns a dialog action', async () => { it('should return "handled" for a command returning a dialog action', async () => {
const mockAction = vi.fn().mockResolvedValue({ const mockAction = vi.fn().mockResolvedValue({
type: 'dialog', type: 'dialog',
dialog: 'help', dialog: 'help',
@ -341,7 +327,7 @@ describe('useSlashCommandProcessor', () => {
expect(commandResult).toEqual({ type: 'handled' }); expect(commandResult).toEqual({ type: 'handled' });
}); });
it('should open the auth dialog when a new command returns an auth dialog action', async () => { it('should open the auth dialog for a command returning an auth dialog action', async () => {
const mockAction = vi.fn().mockResolvedValue({ const mockAction = vi.fn().mockResolvedValue({
type: 'dialog', type: 'dialog',
dialog: 'auth', dialog: 'auth',
@ -371,7 +357,7 @@ describe('useSlashCommandProcessor', () => {
expect(commandResult).toEqual({ type: 'handled' }); expect(commandResult).toEqual({ type: 'handled' });
}); });
it('should open the theme dialog when a new command returns a theme dialog action', async () => { it('should open the theme dialog for a command returning a theme dialog action', async () => {
const mockAction = vi.fn().mockResolvedValue({ const mockAction = vi.fn().mockResolvedValue({
type: 'dialog', type: 'dialog',
dialog: 'theme', dialog: 'theme',

View File

@ -19,37 +19,15 @@ import {
SlashCommandProcessorResult, SlashCommandProcessorResult,
} from '../types.js'; } from '../types.js';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { import { type CommandContext, type SlashCommand } from '../commands/types.js';
type CommandContext,
type SlashCommandActionReturn,
type SlashCommand,
} from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js'; import { CommandService } from '../../services/CommandService.js';
// This interface is for the old, inline command definitions.
// It will be removed once all commands are migrated to the new system.
export interface LegacySlashCommand {
name: string;
altName?: string;
description?: string;
completion?: () => Promise<string[]>;
action: (
mainCommand: string,
subCommand?: string,
args?: string,
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>;
}
/** /**
* Hook to define and process slash commands (e.g., /help, /clear). * Hook to define and process slash commands (e.g., /help, /clear).
*/ */
export const useSlashCommandProcessor = ( export const useSlashCommandProcessor = (
config: Config | null, config: Config | null,
settings: LoadedSettings, settings: LoadedSettings,
history: HistoryItem[],
addItem: UseHistoryManagerReturn['addItem'], addItem: UseHistoryManagerReturn['addItem'],
clearItems: UseHistoryManagerReturn['clearItems'], clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'], loadHistory: UseHistoryManagerReturn['loadHistory'],
@ -157,6 +135,7 @@ export const useSlashCommandProcessor = (
setDebugMessage: onDebugMessage, setDebugMessage: onDebugMessage,
pendingItem: pendingCompressionItemRef.current, pendingItem: pendingCompressionItemRef.current,
setPendingItem: setPendingCompressionItem, setPendingItem: setPendingCompressionItem,
toggleCorgiMode,
}, },
session: { session: {
stats: session.stats, stats: session.stats,
@ -175,6 +154,7 @@ export const useSlashCommandProcessor = (
onDebugMessage, onDebugMessage,
pendingCompressionItemRef, pendingCompressionItemRef,
setPendingCompressionItem, setPendingCompressionItem,
toggleCorgiMode,
], ],
); );
@ -189,23 +169,6 @@ export const useSlashCommandProcessor = (
load(); load();
}, [commandService]); }, [commandService]);
// Define legacy commands
// This list contains all commands that have NOT YET been migrated to the
// new system. As commands are migrated, they are removed from this list.
const legacyCommands: LegacySlashCommand[] = useMemo(() => {
const commands: LegacySlashCommand[] = [
// `/help` and `/clear` have been migrated and REMOVED from this list.
{
name: 'corgi',
action: (_mainCommand, _subCommand, _args) => {
toggleCorgiMode();
},
},
];
return commands;
}, [toggleCorgiMode]);
const handleSlashCommand = useCallback( const handleSlashCommand = useCallback(
async ( async (
rawQuery: PartListUnion, rawQuery: PartListUnion,
@ -230,8 +193,6 @@ export const useSlashCommandProcessor = (
const parts = trimmed.substring(1).trim().split(/\s+/); const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
// --- Start of New Tree Traversal Logic ---
let currentCommands = commands; let currentCommands = commands;
let commandToExecute: SlashCommand | undefined; let commandToExecute: SlashCommand | undefined;
let pathIndex = 0; let pathIndex = 0;
@ -341,45 +302,6 @@ export const useSlashCommandProcessor = (
} }
} }
// --- End of New Tree Traversal Logic ---
// --- Legacy Fallback Logic (for commands not yet migrated) ---
const mainCommand = parts[0];
const subCommand = parts[1];
const legacyArgs = parts.slice(2).join(' ');
for (const cmd of legacyCommands) {
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
const actionResult = await cmd.action(
mainCommand,
subCommand,
legacyArgs,
);
if (actionResult?.type === 'tool') {
return {
type: 'schedule_tool',
toolName: actionResult.toolName,
toolArgs: actionResult.toolArgs,
};
}
if (actionResult?.type === 'message') {
addItem(
{
type:
actionResult.messageType === 'error'
? MessageType.ERROR
: MessageType.INFO,
text: actionResult.content,
},
Date.now(),
);
}
return { type: 'handled' };
}
}
addMessage({ addMessage({
type: MessageType.ERROR, type: MessageType.ERROR,
content: `Unknown command: ${trimmed}`, content: `Unknown command: ${trimmed}`,
@ -393,7 +315,6 @@ export const useSlashCommandProcessor = (
setShowHelp, setShowHelp,
openAuthDialog, openAuthDialog,
commands, commands,
legacyCommands,
commandContext, commandContext,
addMessage, addMessage,
openThemeDialog, openThemeDialog,
@ -403,38 +324,9 @@ export const useSlashCommandProcessor = (
], ],
); );
const allCommands = useMemo(() => {
// Adapt legacy commands to the new SlashCommand interface
const adaptedLegacyCommands: SlashCommand[] = legacyCommands.map(
(legacyCmd) => ({
name: legacyCmd.name,
altName: legacyCmd.altName,
description: legacyCmd.description,
action: async (_context: CommandContext, args: string) => {
const parts = args.split(/\s+/);
const subCommand = parts[0] || undefined;
const restOfArgs = parts.slice(1).join(' ') || undefined;
return legacyCmd.action(legacyCmd.name, subCommand, restOfArgs);
},
completion: legacyCmd.completion
? async (_context: CommandContext, _partialArg: string) =>
legacyCmd.completion!()
: undefined,
}),
);
const newCommandNames = new Set(commands.map((c) => c.name));
const filteredAdaptedLegacy = adaptedLegacyCommands.filter(
(c) => !newCommandNames.has(c.name),
);
return [...commands, ...filteredAdaptedLegacy];
}, [commands, legacyCommands]);
return { return {
handleSlashCommand, handleSlashCommand,
slashCommands: allCommands, slashCommands: commands,
pendingHistoryItems, pendingHistoryItems,
commandContext, commandContext,
}; };