| |
| |
| |
| |
| |
|
|
| 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 { SkillCommandLoader } from '../../services/SkillCommandLoader.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; |
| openVoiceModelDialog: () => void; |
| openAgentConfigDialog: ( |
| name: string, |
| displayName: string, |
| definition: AgentDefinition, |
| ) => void; |
| openPermissionsDialog: (props?: { targetDirectory?: string }) => void; |
| quit: (messages: HistoryItem[]) => void; |
| setDebugMessage: (message: string) => void; |
| toggleCorgiMode: () => void; |
| toggleVoiceMode: () => void; |
| toggleDebugProfiler: () => void; |
| dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; |
| addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; |
| toggleBackgroundTasks: () => void; |
| toggleShortcutsHelp: () => void; |
| setText: (text: string) => void; |
| } |
|
|
| |
| |
| |
| export const useSlashCommandProcessor = ( |
| config: Config | null, |
| settings: LoadedSettings, |
| addItem: UseHistoryManagerReturn['addItem'], |
| clearItems: UseHistoryManagerReturn['clearItems'], |
| loadHistory: UseHistoryManagerReturn['loadHistory'], |
| refreshStatic: () => void, |
| toggleVimEnabled: () => Promise<boolean>, |
| setIsProcessing: (isProcessing: boolean) => void, |
| actions: SlashCommandProcessorActions, |
| extensionsUpdateState: Map<string, ExtensionUpdateStatus>, |
| isConfigInitialized: boolean, |
| setBannerVisible: (visible: boolean) => void, |
| setCustomDialog: (dialog: React.ReactNode | null) => void, |
| ) => { |
| const session = useSessionStats(); |
| const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>( |
| undefined, |
| ); |
| const [reloadTrigger, setReloadTrigger] = useState(0); |
|
|
| const reloadCommands = useCallback(() => { |
| setReloadTrigger((v) => v + 1); |
| }, []); |
| const [confirmationRequest, setConfirmationRequest] = useState<null | { |
| prompt: React.ReactNode; |
| onConfirm: (confirmed: boolean) => void; |
| }>(null); |
|
|
| const [sessionShellAllowlist, setSessionShellAllowlist] = useState( |
| new Set<string>(), |
| ); |
| 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()), |
| ); |
| |
| |
| return l; |
| }, [config]); |
|
|
| const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>( |
| null, |
| ); |
|
|
| const pendingHistoryItems = useMemo(() => { |
| const items: HistoryItemWithoutId[] = []; |
| if (pendingItem != null) { |
| items.push(pendingItem); |
| } |
| return items; |
| }, [pendingItem]); |
|
|
| const addMessage = useCallback( |
| (message: Message) => { |
| |
| 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: { |
| agentContext: 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, |
| toggleVoiceMode: actions.toggleVoiceMode, |
| toggleDebugProfiler: actions.toggleDebugProfiler, |
| toggleVimEnabled, |
| reloadCommands, |
| openAgentConfigDialog: actions.openAgentConfigDialog, |
| extensionsUpdateState, |
| dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate, |
| addConfirmUpdateExtensionRequest: |
| actions.addConfirmUpdateExtensionRequest, |
| setConfirmationRequest, |
| removeComponent: () => setCustomDialog(null), |
| toggleBackgroundTasks: actions.toggleBackgroundTasks, |
| 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(); |
| }; |
| let isActive = true; |
| let activeIdeClient: IdeClient | undefined; |
|
|
| |
| (async () => { |
| const ideClient = await IdeClient.getInstance(); |
| if (!isActive) { |
| return; |
| } |
| activeIdeClient = ideClient; |
| ideClient.addStatusChangeListener(listener); |
| })(); |
|
|
| |
| |
| addMCPStatusChangeListener(listener); |
|
|
| |
| |
| |
| |
| const extensionEventListener = ( |
| _event: ExtensionsStartingEvent | ExtensionsStoppingEvent, |
| ) => { |
| |
| |
| reloadCommands(); |
| }; |
| coreEvents.on('extensionsStarting', extensionEventListener); |
| coreEvents.on('extensionsStopping', extensionEventListener); |
|
|
| return () => { |
| isActive = false; |
| activeIdeClient?.removeStatusChangeListener(listener); |
| removeMCPStatusChangeListener(listener); |
| coreEvents.off('extensionsStarting', extensionEventListener); |
| coreEvents.off('extensionsStopping', extensionEventListener); |
| }; |
| }, [config, reloadCommands]); |
|
|
| useEffect(() => { |
| const controller = new AbortController(); |
|
|
| |
| (async () => { |
| const commandService = await CommandService.create( |
| [ |
| new BuiltinCommandLoader(config), |
| new SkillCommandLoader(config), |
| new McpPromptLoader(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<string>, |
| overwriteConfirmed?: boolean, |
| addToHistory: boolean = true, |
| ): Promise<SlashCommandProcessorResult | false> => { |
| 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 (!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 (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, |
| postSubmitPrompt: result.postSubmitPrompt, |
| }; |
| case 'message': |
| addItem( |
| { |
| type: |
| result.messageType === 'error' |
| ? MessageType.ERROR |
| : MessageType.INFO, |
| text: result.content, |
| }, |
| Date.now(), |
| ); |
| return { type: 'handled' }; |
| case 'logout': |
| |
| 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 'voice-model': |
| actions.openVoiceModelDialog(); |
| return { type: 'handled' }; |
| case 'agentConfig': { |
| |
| const props = result.props as Record<string, unknown>; |
| if ( |
| !props || |
| |
| typeof props['name'] !== 'string' || |
| |
| typeof props['displayName'] !== 'string' || |
| !props['definition'] |
| ) { |
| throw new Error( |
| 'Received invalid properties for agentConfig dialog action.', |
| ); |
| } |
|
|
| actions.openAgentConfigDialog( |
| props['name'], |
| props['displayName'], |
| |
| props['definition'] as AgentDefinition, |
| ); |
| return { type: 'handled' }; |
| } |
| case 'permissions': |
| actions.openPermissionsDialog( |
| |
| 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': |
| if (result.deleteSession) { |
| try { |
| const chatRecordingService = config |
| ?.getGeminiClient() |
| ?.getChatRecordingService(); |
| if (chatRecordingService) { |
| await chatRecordingService.deleteCurrentSessionAsync(); |
| } |
| } catch { |
| |
| } |
| } |
| 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) => { |
| |
| 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, |
| |
| new Set(approvedCommands), |
| undefined, |
| false, |
| ); |
| } |
| 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, |
| }; |
| }; |
|
|