diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/slashCommandProcessor.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 395 |
1 files changed, 247 insertions, 148 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 01378d89..c174b8a4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useEffect, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import open from 'open'; import process from 'node:process'; @@ -25,23 +25,24 @@ import { MessageType, HistoryItemWithoutId, HistoryItem, + SlashCommandProcessorResult, } from '../types.js'; import { promises as fs } from 'fs'; import path from 'path'; -import { createShowMemoryAction } from './useShowMemoryCommand.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatDuration, formatMemoryUsage } from '../utils/formatters.js'; import { getCliVersion } from '../../utils/version.js'; import { LoadedSettings } from '../../config/settings.js'; +import { + type CommandContext, + type SlashCommandActionReturn, + type SlashCommand, +} from '../commands/types.js'; +import { CommandService } from '../../services/CommandService.js'; -export interface SlashCommandActionReturn { - shouldScheduleTool?: boolean; - toolName?: string; - toolArgs?: Record<string, unknown>; - message?: string; // For simple messages or errors -} - -export interface SlashCommand { +// 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; @@ -53,7 +54,7 @@ export interface SlashCommand { ) => | void | SlashCommandActionReturn - | Promise<void | SlashCommandActionReturn>; // Action can now return this object + | Promise<void | SlashCommandActionReturn>; } /** @@ -72,13 +73,13 @@ export const useSlashCommandProcessor = ( openThemeDialog: () => void, openAuthDialog: () => void, openEditorDialog: () => void, - performMemoryRefresh: () => Promise<void>, toggleCorgiMode: () => void, showToolDescriptions: boolean = false, setQuittingMessages: (message: HistoryItem[]) => void, openPrivacyNotice: () => void, ) => { const session = useSessionStats(); + const [commands, setCommands] = useState<SlashCommand[]>([]); const gitService = useMemo(() => { if (!config?.getProjectRoot()) { return; @@ -86,12 +87,23 @@ export const useSlashCommandProcessor = ( return new GitService(config.getProjectRoot()); }, [config]); - const pendingHistoryItems: HistoryItemWithoutId[] = []; + const logger = useMemo(() => { + const l = new Logger(config?.getSessionId() || ''); + // The logger's initialize is async, but we can create the instance + // synchronously. Commands that use it will await its initialization. + return l; + }, [config]); + const [pendingCompressionItemRef, setPendingCompressionItem] = useStateAndRef<HistoryItemWithoutId | null>(null); - if (pendingCompressionItemRef.current != null) { - pendingHistoryItems.push(pendingCompressionItemRef.current); - } + + const pendingHistoryItems = useMemo(() => { + const items: HistoryItemWithoutId[] = []; + if (pendingCompressionItemRef.current != null) { + items.push(pendingCompressionItemRef.current); + } + return items; + }, [pendingCompressionItemRef]); const addMessage = useCallback( (message: Message) => { @@ -141,41 +153,51 @@ export const useSlashCommandProcessor = ( [addItem], ); - const showMemoryAction = useCallback(async () => { - const actionFn = createShowMemoryAction(config, settings, addMessage); - await actionFn(); - }, [config, settings, addMessage]); - - const addMemoryAction = useCallback( - ( - _mainCommand: string, - _subCommand?: string, - args?: string, - ): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - addMessage({ - type: MessageType.ERROR, - content: 'Usage: /memory add <text to remember>', - timestamp: new Date(), - }); - return; - } - // UI feedback for attempting to schedule - addMessage({ - type: MessageType.INFO, - content: `Attempting to save to memory: "${args.trim()}"`, - timestamp: new Date(), - }); - // Return info for scheduling the tool call - return { - shouldScheduleTool: true, - toolName: 'save_memory', - toolArgs: { fact: args.trim() }, - }; - }, - [addMessage], + const commandContext = useMemo( + (): CommandContext => ({ + services: { + config, + settings, + git: gitService, + logger, + }, + ui: { + addItem, + clear: () => { + clearItems(); + console.clear(); + refreshStatic(); + }, + setDebugMessage: onDebugMessage, + }, + session: { + stats: session.stats, + }, + }), + [ + config, + settings, + gitService, + logger, + addItem, + clearItems, + refreshStatic, + session.stats, + onDebugMessage, + ], ); + const commandService = useMemo(() => new CommandService(), []); + + useEffect(() => { + const load = async () => { + await commandService.loadCommands(); + setCommands(commandService.getCommands()); + }; + + load(); + }, [commandService]); + const savedChatTags = useCallback(async () => { const geminiDir = config?.getProjectTempDir(); if (!geminiDir) { @@ -193,17 +215,12 @@ export const useSlashCommandProcessor = ( } }, [config]); - const slashCommands: SlashCommand[] = useMemo(() => { - const commands: SlashCommand[] = [ - { - name: 'help', - altName: '?', - description: 'for help on gemini-cli', - action: (_mainCommand, _subCommand, _args) => { - onDebugMessage('Opening help.'); - setShowHelp(true); - }, - }, + // 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: 'docs', description: 'open full Gemini CLI documentation in your browser', @@ -226,17 +243,6 @@ export const useSlashCommandProcessor = ( }, }, { - name: 'clear', - description: 'clear the screen and conversation history', - action: async (_mainCommand, _subCommand, _args) => { - onDebugMessage('Clearing terminal and resetting chat.'); - clearItems(); - await config?.getGeminiClient()?.resetChat(); - console.clear(); - refreshStatic(); - }, - }, - { name: 'theme', description: 'change the theme', action: (_mainCommand, _subCommand, _args) => { @@ -246,23 +252,17 @@ export const useSlashCommandProcessor = ( { name: 'auth', description: 'change the auth method', - action: (_mainCommand, _subCommand, _args) => { - openAuthDialog(); - }, + action: (_mainCommand, _subCommand, _args) => openAuthDialog(), }, { name: 'editor', description: 'set external editor preference', - action: (_mainCommand, _subCommand, _args) => { - openEditorDialog(); - }, + action: (_mainCommand, _subCommand, _args) => openEditorDialog(), }, { name: 'privacy', description: 'display the privacy notice', - action: (_mainCommand, _subCommand, _args) => { - openPrivacyNotice(); - }, + action: (_mainCommand, _subCommand, _args) => openPrivacyNotice(), }, { name: 'stats', @@ -494,38 +494,6 @@ export const useSlashCommandProcessor = ( }, }, { - name: 'memory', - description: - 'manage memory. Usage: /memory <show|refresh|add> [text for add]', - action: (mainCommand, subCommand, args) => { - switch (subCommand) { - case 'show': - showMemoryAction(); - return; - case 'refresh': - performMemoryRefresh(); - return; - case 'add': - return addMemoryAction(mainCommand, subCommand, args); // Return the object - case undefined: - addMessage({ - type: MessageType.ERROR, - content: - 'Missing command\nUsage: /memory <show|refresh|add> [text for add]', - timestamp: new Date(), - }); - return; - default: - addMessage({ - type: MessageType.ERROR, - content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, - timestamp: new Date(), - }); - return; - } - }, - }, - { name: 'tools', description: 'list available Gemini CLI tools', action: async (_mainCommand, _subCommand, _args) => { @@ -1020,7 +988,7 @@ export const useSlashCommandProcessor = ( } return { - shouldScheduleTool: true, + type: 'tool', toolName: toolCallData.toolCall.name, toolArgs: toolCallData.toolCall.args, }; @@ -1036,17 +1004,11 @@ export const useSlashCommandProcessor = ( } return commands; }, [ - onDebugMessage, - setShowHelp, - refreshStatic, + addMessage, openThemeDialog, openAuthDialog, openEditorDialog, - clearItems, - performMemoryRefresh, - showMemoryAction, - addMemoryAction, - addMessage, + openPrivacyNotice, toggleCorgiMode, savedChatTags, config, @@ -1059,20 +1021,23 @@ export const useSlashCommandProcessor = ( setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, - openPrivacyNotice, + clearItems, + refreshStatic, ]); const handleSlashCommand = useCallback( async ( rawQuery: PartListUnion, - ): Promise<SlashCommandActionReturn | boolean> => { + ): Promise<SlashCommandProcessorResult | false> => { if (typeof rawQuery !== 'string') { return false; } + const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { return false; } + const userMessageTimestamp = Date.now(); if (trimmed !== '/quit' && trimmed !== '/exit') { addItem( @@ -1081,35 +1046,128 @@ export const useSlashCommandProcessor = ( ); } - let subCommand: string | undefined; - let args: string | undefined; + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] - const commandToMatch = (() => { - if (trimmed.startsWith('?')) { - return 'help'; - } - const parts = trimmed.substring(1).trim().split(/\s+/); - if (parts.length > 1) { - subCommand = parts[1]; + // --- Start of New Tree Traversal Logic --- + + let currentCommands = commands; + let commandToExecute: SlashCommand | undefined; + let pathIndex = 0; + + for (const part of commandPath) { + const foundCommand = currentCommands.find( + (cmd) => cmd.name === part || cmd.altName === part, + ); + + if (foundCommand) { + commandToExecute = foundCommand; + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; + } + } else { + break; } - if (parts.length > 2) { - args = parts.slice(2).join(' '); + } + + if (commandToExecute) { + const args = parts.slice(pathIndex).join(' '); + + if (commandToExecute.action) { + const result = await commandToExecute.action(commandContext, args); + + if (result) { + switch (result.type) { + case 'tool': + return { + type: 'schedule_tool', + toolName: result.toolName, + toolArgs: result.toolArgs, + }; + case 'message': + addItem( + { + type: + result.messageType === 'error' + ? MessageType.ERROR + : MessageType.INFO, + text: result.content, + }, + Date.now(), + ); + return { type: 'handled' }; + case 'dialog': + switch (result.dialog) { + case 'help': + setShowHelp(true); + return { type: 'handled' }; + default: { + const unhandled: never = result.dialog; + throw new Error( + `Unhandled slash command result: ${unhandled}`, + ); + } + } + default: { + const unhandled: never = result; + throw new Error(`Unhandled slash command result: ${unhandled}`); + } + } + } + + return { type: 'handled' }; + } else if (commandToExecute.subCommands) { + const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands + .map((sc) => ` - ${sc.name}: ${sc.description || ''}`) + .join('\n')}`; + addMessage({ + type: MessageType.INFO, + content: helpText, + timestamp: new Date(), + }); + return { type: 'handled' }; } - return parts[0]; - })(); + } + + // --- End of New Tree Traversal Logic --- - const mainCommand = commandToMatch; + // --- Legacy Fallback Logic (for commands not yet migrated) --- - for (const cmd of slashCommands) { + 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, args); - if ( - typeof actionResult === 'object' && - actionResult?.shouldScheduleTool - ) { - return actionResult; // Return the object for useGeminiStream + const actionResult = await cmd.action( + mainCommand, + subCommand, + legacyArgs, + ); + + if (actionResult?.type === 'tool') { + return { + type: 'schedule_tool', + toolName: actionResult.toolName, + toolArgs: actionResult.toolArgs, + }; } - return true; // Command was handled, but no tool to schedule + if (actionResult?.type === 'message') { + addItem( + { + type: + actionResult.messageType === 'error' + ? MessageType.ERROR + : MessageType.INFO, + text: actionResult.content, + }, + Date.now(), + ); + } + return { type: 'handled' }; } } @@ -1118,10 +1176,51 @@ export const useSlashCommandProcessor = ( content: `Unknown command: ${trimmed}`, timestamp: new Date(), }); - return true; // Indicate command was processed (even if unknown) + return { type: 'handled' }; }, - [addItem, slashCommands, addMessage], + [ + addItem, + setShowHelp, + commands, + legacyCommands, + commandContext, + addMessage, + ], ); - return { handleSlashCommand, slashCommands, pendingHistoryItems }; + 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 { + handleSlashCommand, + slashCommands: allCommands, + pendingHistoryItems, + commandContext, + }; }; |
