Files
gemini-cli/packages/cli/src/ui/hooks/slashCommandProcessor.ts
2026-03-07 21:05:38 +00:00

740 lines
24 KiB
TypeScript

/**
* @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<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()),
);
// 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<HistoryItemWithoutId | null>(
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<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 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<string, unknown>;
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,
};
};