/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useMemo, useEffect, useState, createElement, } from 'react'; import { type PartListUnion } from '@google/genai'; import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { Config, ExtensionsStartingEvent, ExtensionsStoppingEvent, ToolCallConfirmationDetails, AgentDefinition, } from '@google/gemini-cli-core'; import { GitService, Logger, logSlashCommand, makeSlashCommandEvent, SlashCommandStatus, ToolConfirmationOutcome, Storage, IdeClient, coreEvents, addMCPStatusChangeListener, removeMCPStatusChangeListener, MCPDiscoveryState, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import type { Message, HistoryItemWithoutId, SlashCommandProcessorResult, HistoryItem, ConfirmationRequest, IndividualToolCallDisplay, } from '../types.js'; import { MessageType } from '../types.js'; import type { 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'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, } from '../state/extensions.js'; import { LogoutConfirmationDialog, LogoutChoice, } from '../components/LogoutConfirmationDialog.js'; import { runExitCleanup } from '../../utils/cleanup.js'; interface SlashCommandProcessorActions { openAuthDialog: () => void; openThemeDialog: () => void; openEditorDialog: () => void; openPrivacyNotice: () => void; openSettingsDialog: () => void; openSessionBrowser: () => void; openModelDialog: () => void; openAgentConfigDialog: ( name: string, displayName: string, definition: AgentDefinition, ) => void; openPermissionsDialog: (props?: { targetDirectory?: string }) => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; toggleDebugProfiler: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; toggleBackgroundShell: () => void; toggleShortcutsHelp: () => void; setText: (text: string) => void; } /** * Hook to define and process slash commands (e.g., /help, /clear). */ export const useSlashCommandProcessor = ( config: Config | null, settings: LoadedSettings, addItem: UseHistoryManagerReturn['addItem'], clearItems: UseHistoryManagerReturn['clearItems'], loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, actions: SlashCommandProcessorActions, extensionsUpdateState: Map, isConfigInitialized: boolean, setBannerVisible: (visible: boolean) => void, setCustomDialog: (dialog: React.ReactNode | null) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState( undefined, ); const [reloadTrigger, setReloadTrigger] = useState(0); const reloadCommands = useCallback(() => { setReloadTrigger((v) => v + 1); }, []); const [confirmationRequest, setConfirmationRequest] = useState void; }>(null); const [sessionShellAllowlist, setSessionShellAllowlist] = useState( new Set(), ); const gitService = useMemo(() => { if (!config?.getProjectRoot()) { return; } return new GitService(config.getProjectRoot(), config.storage); }, [config]); const logger = useMemo(() => { const l = new Logger( config?.getSessionId() || '', config?.storage ?? new Storage(process.cwd()), ); // 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 [pendingItem, setPendingItem] = useState( null, ); const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingItem != null) { items.push(pendingItem); } return items; }, [pendingItem]); const addMessage = useCallback( (message: Message) => { // Convert Message to HistoryItemWithoutId let historyItemContent: HistoryItemWithoutId; if (message.type === MessageType.ABOUT) { historyItemContent = { type: 'about', cliVersion: message.cliVersion, osVersion: message.osVersion, sandboxEnv: message.sandboxEnv, modelVersion: message.modelVersion, selectedAuthType: message.selectedAuthType, gcpProject: message.gcpProject, ideClient: message.ideClient, }; } else if (message.type === MessageType.HELP) { historyItemContent = { type: 'help', timestamp: message.timestamp, }; } else if (message.type === MessageType.STATS) { historyItemContent = { type: 'stats', duration: message.duration, }; } else if (message.type === MessageType.MODEL_STATS) { historyItemContent = { type: 'model_stats', }; } else if (message.type === MessageType.TOOL_STATS) { historyItemContent = { type: 'tool_stats', }; } else if (message.type === MessageType.QUIT) { historyItemContent = { type: 'quit', duration: message.duration, }; } else if (message.type === MessageType.COMPRESSION) { historyItemContent = { type: 'compression', compression: message.compression, }; } else { historyItemContent = { type: message.type, text: message.content, }; } addItem(historyItemContent, message.timestamp.getTime()); }, [addItem], ); const commandContext = useMemo( (): CommandContext => ({ services: { config, settings, git: gitService, logger, }, ui: { addItem, clear: () => { clearItems(); refreshStatic(); setBannerVisible(false); }, loadHistory: (history, postLoadInput) => { loadHistory(history); refreshStatic(); if (postLoadInput !== undefined) { actions.setText(postLoadInput); } }, setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, toggleCorgiMode: actions.toggleCorgiMode, toggleDebugProfiler: actions.toggleDebugProfiler, toggleVimEnabled, reloadCommands, openAgentConfigDialog: actions.openAgentConfigDialog, extensionsUpdateState, dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest: actions.addConfirmUpdateExtensionRequest, setConfirmationRequest, removeComponent: () => setCustomDialog(null), toggleBackgroundShell: actions.toggleBackgroundShell, toggleShortcutsHelp: actions.toggleShortcutsHelp, }, session: { stats: session.stats, sessionShellAllowlist, }, }), [ config, settings, gitService, logger, loadHistory, addItem, clearItems, refreshStatic, session.stats, actions, pendingItem, setPendingItem, setConfirmationRequest, toggleVimEnabled, sessionShellAllowlist, reloadCommands, extensionsUpdateState, setBannerVisible, setCustomDialog, ], ); useEffect(() => { if (!config) { return; } const listener = () => { reloadCommands(); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { const ideClient = await IdeClient.getInstance(); ideClient.addStatusChangeListener(listener); })(); // Listen for MCP server status changes (e.g. connection, discovery completion) // to reload slash commands (since they may include MCP prompts). addMCPStatusChangeListener(listener); // TODO: Ideally this would happen more directly inside the ExtensionLoader, // but the CommandService today is not conducive to that since it isn't a // long lived service but instead gets fully re-created based on reload // events within this hook. const extensionEventListener = ( _event: ExtensionsStartingEvent | ExtensionsStoppingEvent, ) => { // We only care once at least one extension has completed // starting/stopping reloadCommands(); }; coreEvents.on('extensionsStarting', extensionEventListener); coreEvents.on('extensionsStopping', extensionEventListener); return () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { const ideClient = await IdeClient.getInstance(); ideClient.removeStatusChangeListener(listener); })(); removeMCPStatusChangeListener(listener); coreEvents.off('extensionsStarting', extensionEventListener); coreEvents.off('extensionsStopping', extensionEventListener); }; }, [config, reloadCommands]); useEffect(() => { const controller = new AbortController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { const commandService = await CommandService.create( [ new McpPromptLoader(config), new BuiltinCommandLoader(config), new FileCommandLoader(config), ], controller.signal, ); if (controller.signal.aborted) { return; } setCommands(commandService.getCommands()); })(); return () => { controller.abort(); }; }, [config, reloadTrigger, isConfigInitialized]); const handleSlashCommand = useCallback( async ( rawQuery: PartListUnion, oneTimeShellAllowlist?: Set, overwriteConfirmed?: boolean, addToHistory: boolean = true, ): Promise => { if (!commands) { return false; } if (typeof rawQuery !== 'string') { return false; } const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { return false; } const { commandToExecute, args, canonicalPath: resolvedCommandPath, } = parseSlashCommand(trimmed, commands); // If the input doesn't match any known command, check if MCP servers // are still loading (the command might come from an MCP server). // Otherwise, treat it as regular text input (e.g. file paths like // /home/user/file.txt) and let it be sent to the model. if (!commandToExecute) { const isMcpLoading = config?.getMcpClientManager()?.getDiscoveryState() === MCPDiscoveryState.IN_PROGRESS; if (isMcpLoading) { setIsProcessing(true); if (addToHistory) { addItem({ type: MessageType.USER, text: trimmed }, Date.now()); } addMessage({ type: MessageType.ERROR, content: `Unknown command: ${trimmed}. Command might have been from an MCP server but MCP servers are not done loading.`, timestamp: new Date(), }); setIsProcessing(false); return { type: 'handled' }; } return false; } setIsProcessing(true); if (addToHistory) { const userMessageTimestamp = Date.now(); addItem( { type: MessageType.USER, text: trimmed }, userMessageTimestamp, ); } let hasError = false; const subcommand = resolvedCommandPath.length > 1 ? resolvedCommandPath.slice(1).join(' ') : undefined; try { if (commandToExecute) { if (commandToExecute.action) { const fullCommandContext: CommandContext = { ...commandContext, invocation: { raw: trimmed, name: commandToExecute.name, args, }, overwriteConfirmed, }; // If a one-time list is provided for a "Proceed" action, temporarily // augment the session allowlist for this single execution. if (oneTimeShellAllowlist && oneTimeShellAllowlist.size > 0) { fullCommandContext.session = { ...fullCommandContext.session, sessionShellAllowlist: new Set([ ...fullCommandContext.session.sessionShellAllowlist, ...oneTimeShellAllowlist, ]), }; } const result = await commandToExecute.action( fullCommandContext, 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 'logout': // Show logout confirmation dialog with Login/Exit options setCustomDialog( createElement(LogoutConfirmationDialog, { onSelect: async (choice: LogoutChoice) => { setCustomDialog(null); if (choice === LogoutChoice.LOGIN) { actions.openAuthDialog(); } else { await runExitCleanup(); process.exit(0); } }, }), ); return { type: 'handled' }; case 'dialog': switch (result.dialog) { case 'auth': actions.openAuthDialog(); return { type: 'handled' }; case 'theme': actions.openThemeDialog(); return { type: 'handled' }; case 'editor': actions.openEditorDialog(); return { type: 'handled' }; case 'privacy': actions.openPrivacyNotice(); return { type: 'handled' }; case 'sessionBrowser': actions.openSessionBrowser(); return { type: 'handled' }; case 'settings': actions.openSettingsDialog(); return { type: 'handled' }; case 'model': actions.openModelDialog(); return { type: 'handled' }; case 'agentConfig': { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const props = result.props as Record; if ( !props || // eslint-disable-next-line no-restricted-syntax typeof props['name'] !== 'string' || // eslint-disable-next-line no-restricted-syntax typeof props['displayName'] !== 'string' || !props['definition'] ) { throw new Error( 'Received invalid properties for agentConfig dialog action.', ); } actions.openAgentConfigDialog( props['name'], props['displayName'], // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion props['definition'] as AgentDefinition, ); return { type: 'handled' }; } case 'permissions': actions.openPermissionsDialog( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion result.props as { targetDirectory?: string }, ); return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { const unhandled: never = result.dialog; throw new Error( `Unhandled slash command result: ${unhandled}`, ); } } case 'load_history': { config?.getGeminiClient()?.setHistory(result.clientHistory); fullCommandContext.ui.clear(); result.history.forEach((item, index) => { fullCommandContext.ui.addItem(item, index); }); return { type: 'handled' }; } case 'quit': actions.quit(result.messages); return { type: 'handled' }; case 'submit_prompt': return { type: 'submit_prompt', content: result.content, }; case 'confirm_shell_commands': { const callId = `expansion-${Date.now()}`; const { outcome, approvedCommands } = await new Promise<{ outcome: ToolConfirmationOutcome; approvedCommands?: string[]; }>((resolve) => { const confirmationDetails: ToolCallConfirmationDetails = { type: 'exec', title: `Confirm Shell Expansion`, command: result.commandsToConfirm[0] || '', rootCommand: result.commandsToConfirm[0] || '', rootCommands: result.commandsToConfirm, commands: result.commandsToConfirm, onConfirm: async (resolvedOutcome) => { // Close the pending tool display by resolving resolve({ outcome: resolvedOutcome, approvedCommands: resolvedOutcome === ToolConfirmationOutcome.Cancel ? [] : result.commandsToConfirm, }); }, }; const toolDisplay: IndividualToolCallDisplay = { callId, name: 'Expansion', description: 'Command expansion needs shell access', status: CoreToolCallStatus.AwaitingApproval, isClientInitiated: true, resultDisplay: undefined, confirmationDetails, }; setPendingItem({ type: 'tool_group', tools: [toolDisplay], }); }); setPendingItem(null); if ( outcome === ToolConfirmationOutcome.Cancel || !approvedCommands || approvedCommands.length === 0 ) { addItem( { type: MessageType.INFO, text: 'Slash command shell execution declined.', }, Date.now(), ); return { type: 'handled' }; } if (outcome === ToolConfirmationOutcome.ProceedAlways) { setSessionShellAllowlist( (prev) => new Set([...prev, ...approvedCommands]), ); } return await handleSlashCommand( result.originalInvocation.raw, // Pass the approved commands as a one-time grant for this execution. new Set(approvedCommands), undefined, false, // Do not add to history again ); } case 'confirm_action': { const { confirmed } = await new Promise<{ confirmed: boolean; }>((resolve) => { setConfirmationRequest({ prompt: result.prompt, onConfirm: (resolvedConfirmed) => { setConfirmationRequest(null); resolve({ confirmed: resolvedConfirmed }); }, }); }); if (!confirmed) { addItem( { type: MessageType.INFO, text: 'Operation cancelled.', }, Date.now(), ); return { type: 'handled' }; } return await handleSlashCommand( result.originalInvocation.raw, undefined, true, ); } case 'custom_dialog': { setCustomDialog(result.component); return { type: 'handled' }; } 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 { type: 'handled' }; } catch (e: unknown) { hasError = true; if (config) { const event = makeSlashCommandEvent({ command: resolvedCommandPath[0], subcommand, status: SlashCommandStatus.ERROR, extension_id: commandToExecute?.extensionId, }); logSlashCommand(config, event); } addItem( { type: MessageType.ERROR, text: e instanceof Error ? e.message : String(e), }, Date.now(), ); return { type: 'handled' }; } finally { if (config && resolvedCommandPath[0] && !hasError) { const event = makeSlashCommandEvent({ command: resolvedCommandPath[0], subcommand, status: SlashCommandStatus.SUCCESS, extension_id: commandToExecute?.extensionId, }); logSlashCommand(config, event); } setIsProcessing(false); } }, [ config, addItem, actions, commands, commandContext, addMessage, setSessionShellAllowlist, setIsProcessing, setConfirmationRequest, setCustomDialog, ], ); return { handleSlashCommand, slashCommands: commands, pendingHistoryItems, commandContext, confirmationRequest, }; };