/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useMemo } from 'react'; import { type PartListUnion } from '@google/genai'; import open from 'open'; import process from 'node:process'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useStateAndRef } from './useStateAndRef.js'; import { Config, GitService, Logger, MCPDiscoveryState, MCPServerStatus, getMCPDiscoveryState, getMCPServerStatus, } from '@gemini-cli/core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { Message, MessageType, HistoryItemWithoutId, HistoryItem, } 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'; export interface SlashCommandActionReturn { shouldScheduleTool?: boolean; toolName?: string; toolArgs?: Record; message?: string; // For simple messages or errors } export interface SlashCommand { name: string; altName?: string; description?: string; completion?: () => Promise; action: ( mainCommand: string, subCommand?: string, args?: string, ) => | void | SlashCommandActionReturn | Promise; // Action can now return this object } /** * Hook to define and process slash commands (e.g., /help, /clear). */ export const useSlashCommandProcessor = ( config: Config | null, settings: LoadedSettings, history: HistoryItem[], addItem: UseHistoryManagerReturn['addItem'], clearItems: UseHistoryManagerReturn['clearItems'], loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, setShowHelp: React.Dispatch>, onDebugMessage: (message: string) => void, openThemeDialog: () => void, openAuthDialog: () => void, openEditorDialog: () => void, performMemoryRefresh: () => Promise, toggleCorgiMode: () => void, showToolDescriptions: boolean = false, setQuittingMessages: (message: HistoryItem[]) => void, ) => { const session = useSessionStats(); const gitService = useMemo(() => { if (!config?.getProjectRoot()) { return; } return new GitService(config.getProjectRoot()); }, [config]); const pendingHistoryItems: HistoryItemWithoutId[] = []; const [pendingCompressionItemRef, setPendingCompressionItem] = useStateAndRef(null); if (pendingCompressionItemRef.current != null) { pendingHistoryItems.push(pendingCompressionItemRef.current); } 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, }; } else if (message.type === MessageType.STATS) { historyItemContent = { type: 'stats', stats: message.stats, lastTurnStats: message.lastTurnStats, duration: message.duration, }; } else if (message.type === MessageType.QUIT) { historyItemContent = { type: 'quit', stats: message.stats, duration: message.duration, }; } else if (message.type === MessageType.COMPRESSION) { historyItemContent = { type: 'compression', compression: message.compression, }; } else { historyItemContent = { type: message.type as | MessageType.INFO | MessageType.ERROR | MessageType.USER, text: message.content, }; } addItem(historyItemContent, message.timestamp.getTime()); }, [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 ', 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 slashCommands: SlashCommand[] = useMemo(() => { const commands: SlashCommand[] = [ { name: 'help', altName: '?', description: 'for help on gemini-cli', action: (_mainCommand, _subCommand, _args) => { onDebugMessage('Opening help.'); setShowHelp(true); }, }, { 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) => { openThemeDialog(); }, }, { name: 'auth', description: 'change the auth method', action: (_mainCommand, _subCommand, _args) => { openAuthDialog(); }, }, { name: 'editor', description: 'set external editor preference', action: (_mainCommand, _subCommand, _args) => { openEditorDialog(); }, }, { name: 'stats', altName: 'usage', description: 'check session stats', action: (_mainCommand, _subCommand, _args) => { const now = new Date(); const { sessionStartTime, cumulative, currentTurn } = session.stats; const wallDuration = now.getTime() - sessionStartTime.getTime(); addMessage({ type: MessageType.STATS, stats: cumulative, lastTurnStats: currentTurn, duration: formatDuration(wallDuration), timestamp: new Date(), }); }, }, { name: 'mcp', description: 'list configured MCP servers and tools', action: async (_mainCommand, _subCommand, _args) => { // Check if the _subCommand includes a specific flag to control description visibility let useShowDescriptions = showToolDescriptions; if (_subCommand === 'desc' || _subCommand === 'descriptions') { useShowDescriptions = true; } else if ( _subCommand === 'nodesc' || _subCommand === 'nodescriptions' ) { useShowDescriptions = false; } else if (_args === 'desc' || _args === 'descriptions') { useShowDescriptions = true; } else if (_args === 'nodesc' || _args === 'nodescriptions') { useShowDescriptions = false; } // Check if the _subCommand includes a specific flag to show detailed tool schema let useShowSchema = false; if (_subCommand === 'schema' || _args === 'schema') { useShowSchema = true; } const toolRegistry = await config?.getToolRegistry(); if (!toolRegistry) { addMessage({ type: MessageType.ERROR, content: 'Could not retrieve tool registry.', timestamp: new Date(), }); return; } const mcpServers = config?.getMcpServers() || {}; const serverNames = Object.keys(mcpServers); if (serverNames.length === 0) { addMessage({ type: MessageType.INFO, content: 'No MCP servers configured.', timestamp: new Date(), }); return; } // Check if any servers are still connecting const connectingServers = serverNames.filter( (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, ); const discoveryState = getMCPDiscoveryState(); let message = ''; // Add overall discovery status message if needed if ( discoveryState === MCPDiscoveryState.IN_PROGRESS || connectingServers.length > 0 ) { message += `\u001b[33m⏳ MCP servers are starting up (${connectingServers.length} initializing)...\u001b[0m\n`; message += `\u001b[90mNote: First startup may take longer. Tool availability will update automatically.\u001b[0m\n\n`; } message += 'Configured MCP servers:\n\n'; for (const serverName of serverNames) { const serverTools = toolRegistry.getToolsByServer(serverName); const status = getMCPServerStatus(serverName); // Add status indicator with descriptive text let statusIndicator = ''; let statusText = ''; switch (status) { case MCPServerStatus.CONNECTED: statusIndicator = '🟢'; statusText = 'Ready'; break; case MCPServerStatus.CONNECTING: statusIndicator = '🔄'; statusText = 'Starting... (first startup may take longer)'; break; case MCPServerStatus.DISCONNECTED: default: statusIndicator = '🔴'; statusText = 'Disconnected'; break; } // Get server description if available const server = mcpServers[serverName]; // Format server header with bold formatting and status message += `${statusIndicator} \u001b[1m${serverName}\u001b[0m - ${statusText}`; // Add tool count with conditional messaging if (status === MCPServerStatus.CONNECTED) { message += ` (${serverTools.length} tools)`; } else if (status === MCPServerStatus.CONNECTING) { message += ` (tools will appear when ready)`; } else { message += ` (${serverTools.length} tools cached)`; } // Add server description with proper handling of multi-line descriptions if ((useShowDescriptions || useShowSchema) && server?.description) { const greenColor = '\u001b[32m'; const resetColor = '\u001b[0m'; const descLines = server.description.trim().split('\n'); if (descLines) { message += ':\n'; for (let i = 0; i < descLines.length; i++) { message += ` ${greenColor}${descLines[i]}${resetColor}\n`; } } else { message += '\n'; } } else { message += '\n'; } // Reset formatting after server entry message += '\u001b[0m'; if (serverTools.length > 0) { serverTools.forEach((tool) => { if ( (useShowDescriptions || useShowSchema) && tool.description ) { // Format tool name in cyan using simple ANSI cyan color message += ` - \u001b[36m${tool.name}\u001b[0m`; // Apply green color to the description text const greenColor = '\u001b[32m'; const resetColor = '\u001b[0m'; // Handle multi-line descriptions by properly indenting and preserving formatting const descLines = tool.description.trim().split('\n'); if (descLines) { message += ':\n'; for (let i = 0; i < descLines.length; i++) { message += ` ${greenColor}${descLines[i]}${resetColor}\n`; } } else { message += '\n'; } // Reset is handled inline with each line now } else { // Use cyan color for the tool name even when not showing descriptions message += ` - \u001b[36m${tool.name}\u001b[0m\n`; } if (useShowSchema) { // Prefix the parameters in cyan message += ` \u001b[36mParameters:\u001b[0m\n`; // Apply green color to the parameter text const greenColor = '\u001b[32m'; const resetColor = '\u001b[0m'; const paramsLines = JSON.stringify( tool.schema.parameters, null, 2, ) .trim() .split('\n'); if (paramsLines) { for (let i = 0; i < paramsLines.length; i++) { message += ` ${greenColor}${paramsLines[i]}${resetColor}\n`; } } } }); } else { message += ' No tools available\n'; } message += '\n'; } // Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal message += '\u001b[0m'; addMessage({ type: MessageType.INFO, content: message, timestamp: new Date(), }); }, }, { name: 'memory', description: 'manage memory. Usage: /memory [text for add]', action: (mainCommand, subCommand, args) => { switch (subCommand) { case 'show': showMemoryAction(); return; // Explicitly return void case 'refresh': performMemoryRefresh(); return; // Explicitly return void case 'add': return addMemoryAction(mainCommand, subCommand, args); // Return the object default: addMessage({ type: MessageType.ERROR, content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, timestamp: new Date(), }); return; // Explicitly return void } }, }, { name: 'tools', description: 'list available Gemini CLI tools', action: async (_mainCommand, _subCommand, _args) => { // Check if the _subCommand includes a specific flag to control description visibility let useShowDescriptions = showToolDescriptions; if (_subCommand === 'desc' || _subCommand === 'descriptions') { useShowDescriptions = true; } else if ( _subCommand === 'nodesc' || _subCommand === 'nodescriptions' ) { useShowDescriptions = false; } else if (_args === 'desc' || _args === 'descriptions') { useShowDescriptions = true; } else if (_args === 'nodesc' || _args === 'nodescriptions') { useShowDescriptions = false; } const toolRegistry = await config?.getToolRegistry(); const tools = toolRegistry?.getAllTools(); if (!tools) { addMessage({ type: MessageType.ERROR, content: 'Could not retrieve tools.', timestamp: new Date(), }); return; } // Filter out MCP tools by checking if they have a serverName property const geminiTools = tools.filter((tool) => !('serverName' in tool)); let message = 'Available Gemini CLI tools:\n\n'; if (geminiTools.length > 0) { geminiTools.forEach((tool) => { if (useShowDescriptions && tool.description) { // Format tool name in cyan using simple ANSI cyan color message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`; // Apply green color to the description text const greenColor = '\u001b[32m'; const resetColor = '\u001b[0m'; // Handle multi-line descriptions by properly indenting and preserving formatting const descLines = tool.description.trim().split('\n'); // If there are multiple lines, add proper indentation for each line if (descLines) { for (let i = 0; i < descLines.length; i++) { message += ` ${greenColor}${descLines[i]}${resetColor}\n`; } } } else { // Use cyan color for the tool name even when not showing descriptions message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`; } }); } else { message += ' No tools available\n'; } message += '\n'; // Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal message += '\u001b[0m'; addMessage({ type: MessageType.INFO, content: message, timestamp: new Date(), }); }, }, { name: 'corgi', action: (_mainCommand, _subCommand, _args) => { toggleCorgiMode(); }, }, { name: 'about', description: 'show version info', action: async (_mainCommand, _subCommand, _args) => { const osVersion = process.platform; let sandboxEnv = 'no sandbox'; if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { sandboxEnv = process.env.SANDBOX; } else if (process.env.SANDBOX === 'sandbox-exec') { sandboxEnv = `sandbox-exec (${ process.env.SEATBELT_PROFILE || 'unknown' })`; } const modelVersion = config?.getModel() || 'Unknown'; const cliVersion = await getCliVersion(); addMessage({ type: MessageType.ABOUT, timestamp: new Date(), cliVersion, osVersion, sandboxEnv, modelVersion, }); }, }, { name: 'bug', description: 'submit a bug report', action: async (_mainCommand, _subCommand, args) => { let bugDescription = _subCommand || ''; if (args) { bugDescription += ` ${args}`; } bugDescription = bugDescription.trim(); const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { sandboxEnv = process.env.SANDBOX.replace(/^gemini-(?:code-)?/, ''); } else if (process.env.SANDBOX === 'sandbox-exec') { sandboxEnv = `sandbox-exec (${ process.env.SEATBELT_PROFILE || 'unknown' })`; } const modelVersion = config?.getModel() || 'Unknown'; const cliVersion = await getCliVersion(); const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); const diagnosticInfo = ` ## Describe the bug A clear and concise description of what the bug is. ## Additional context Add any other context about the problem here. ## Diagnostic Information * **CLI Version:** ${cliVersion} * **Git Commit:** ${GIT_COMMIT_INFO} * **Operating System:** ${osVersion} * **Sandbox Environment:** ${sandboxEnv} * **Model Version:** ${modelVersion} * **Memory Usage:** ${memoryUsage} `; let bugReportUrl = 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.md&title={title}&body={body}'; const bugCommand = config?.getBugCommand(); if (bugCommand?.urlTemplate) { bugReportUrl = bugCommand.urlTemplate; } bugReportUrl = bugReportUrl .replace('{title}', encodeURIComponent(bugDescription)) .replace('{body}', encodeURIComponent(diagnosticInfo)); addMessage({ type: MessageType.INFO, content: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, timestamp: new Date(), }); (async () => { try { await open(bugReportUrl); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); addMessage({ type: MessageType.ERROR, content: `Could not open URL in browser: ${errorMessage}`, timestamp: new Date(), }); } })(); }, }, { name: 'save', description: 'save conversation checkpoint. Usage: /save [tag]', action: async (_mainCommand, subCommand, _args) => { const tag = (subCommand || '').trim(); const logger = new Logger(config?.getSessionId() || ''); await logger.initialize(); const chat = await config?.getGeminiClient()?.getChat(); const history = chat?.getHistory() || []; if (history.length > 0) { await logger.saveCheckpoint(chat?.getHistory() || [], tag); addMessage({ type: MessageType.INFO, content: `Conversation checkpoint saved${tag ? ' with tag: ' + tag : ''}.`, timestamp: new Date(), }); } else { addMessage({ type: MessageType.INFO, content: 'No conversation found to save.', timestamp: new Date(), }); } }, }, { name: 'resume', description: 'resume from conversation checkpoint. Usage: /resume [tag]', completion: async () => { const geminiDir = config?.getProjectTempDir(); if (!geminiDir) { return []; } try { const files = await fs.readdir(geminiDir); return files .filter( (file) => file.startsWith('checkpoint-') && file.endsWith('.json'), ) .map((file) => file.replace('checkpoint-', '').replace('.json', ''), ); } catch (_err) { return []; } }, action: async (_mainCommand, subCommand, _args) => { const tag = (subCommand || '').trim(); const logger = new Logger(config?.getSessionId() || ''); await logger.initialize(); const conversation = await logger.loadCheckpoint(tag); if (conversation.length === 0) { addMessage({ type: MessageType.INFO, content: `No saved checkpoint found${tag ? ' with tag: ' + tag : ''}.`, timestamp: new Date(), }); return; } const chat = await config?.getGeminiClient()?.getChat(); if (!chat) { addMessage({ type: MessageType.ERROR, content: 'No chat client available to resume conversation.', timestamp: new Date(), }); return; } clearItems(); chat.clearHistory(); const rolemap: { [key: string]: MessageType } = { user: MessageType.USER, model: MessageType.GEMINI, }; let i = 0; for (const item of conversation) { i += 1; const text = item.parts ?.filter((m) => !!m.text) .map((m) => m.text) .join('') || ''; if (i <= 2) { // Skip system prompt back and forth. continue; } if (!text) { // Parsing Part[] back to various non-text output not yet implemented. continue; } addItem( { type: (item.role && rolemap[item.role]) || MessageType.GEMINI, text, } as HistoryItemWithoutId, i, ); chat.addHistory(item); } console.clear(); refreshStatic(); }, }, { name: 'quit', altName: 'exit', description: 'exit the cli', action: async (mainCommand, _subCommand, _args) => { const now = new Date(); const { sessionStartTime, cumulative } = session.stats; const wallDuration = now.getTime() - sessionStartTime.getTime(); setQuittingMessages([ { type: 'user', text: `/${mainCommand}`, id: now.getTime() - 1, }, { type: 'quit', stats: cumulative, duration: formatDuration(wallDuration), id: now.getTime(), }, ]); setTimeout(() => { process.exit(0); }, 100); }, }, { name: 'compress', altName: 'summarize', description: 'Compresses the context by replacing it with a summary.', action: async (_mainCommand, _subCommand, _args) => { if (pendingCompressionItemRef.current !== null) { addMessage({ type: MessageType.ERROR, content: 'Already compressing, wait for previous request to complete', timestamp: new Date(), }); return; } setPendingCompressionItem({ type: MessageType.COMPRESSION, compression: { isPending: true, originalTokenCount: null, newTokenCount: null, }, }); try { const compressed = await config! .getGeminiClient()! .tryCompressChat(true); if (compressed) { addMessage({ type: MessageType.COMPRESSION, compression: { isPending: false, originalTokenCount: compressed.originalTokenCount, newTokenCount: compressed.newTokenCount, }, timestamp: new Date(), }); } else { addMessage({ type: MessageType.ERROR, content: 'Failed to compress chat history.', timestamp: new Date(), }); } } catch (e) { addMessage({ type: MessageType.ERROR, content: `Failed to compress chat history: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date(), }); } setPendingCompressionItem(null); }, }, ]; if (config?.getCheckpointingEnabled()) { commands.push({ name: 'restore', description: 'restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', completion: async () => { const checkpointDir = config?.getProjectTempDir() ? path.join(config.getProjectTempDir(), 'checkpoints') : undefined; if (!checkpointDir) { return []; } try { const files = await fs.readdir(checkpointDir); return files .filter((file) => file.endsWith('.json')) .map((file) => file.replace('.json', '')); } catch (_err) { return []; } }, action: async (_mainCommand, subCommand, _args) => { const checkpointDir = config?.getProjectTempDir() ? path.join(config.getProjectTempDir(), 'checkpoints') : undefined; if (!checkpointDir) { addMessage({ type: MessageType.ERROR, content: 'Could not determine the .gemini directory path.', timestamp: new Date(), }); return; } try { // Ensure the directory exists before trying to read it. await fs.mkdir(checkpointDir, { recursive: true }); const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); if (!subCommand) { if (jsonFiles.length === 0) { addMessage({ type: MessageType.INFO, content: 'No restorable tool calls found.', timestamp: new Date(), }); return; } const truncatedFiles = jsonFiles.map((file) => { const components = file.split('.'); if (components.length <= 1) { return file; } components.pop(); return components.join('.'); }); const fileList = truncatedFiles.join('\n'); addMessage({ type: MessageType.INFO, content: `Available tool calls to restore:\n\n${fileList}`, timestamp: new Date(), }); return; } const selectedFile = subCommand.endsWith('.json') ? subCommand : `${subCommand}.json`; if (!jsonFiles.includes(selectedFile)) { addMessage({ type: MessageType.ERROR, content: `File not found: ${selectedFile}`, timestamp: new Date(), }); return; } const filePath = path.join(checkpointDir, selectedFile); const data = await fs.readFile(filePath, 'utf-8'); const toolCallData = JSON.parse(data); if (toolCallData.history) { loadHistory(toolCallData.history); } if (toolCallData.clientHistory) { await config ?.getGeminiClient() ?.setHistory(toolCallData.clientHistory); } if (toolCallData.commitHash) { await gitService?.restoreProjectFromSnapshot( toolCallData.commitHash, ); addMessage({ type: MessageType.INFO, content: `Restored project to the state before the tool call.`, timestamp: new Date(), }); } return { shouldScheduleTool: true, toolName: toolCallData.toolCall.name, toolArgs: toolCallData.toolCall.args, }; } catch (error) { addMessage({ type: MessageType.ERROR, content: `Could not read restorable tool calls. This is the error: ${error}`, timestamp: new Date(), }); } }, }); } return commands; }, [ onDebugMessage, setShowHelp, refreshStatic, openThemeDialog, openAuthDialog, openEditorDialog, clearItems, performMemoryRefresh, showMemoryAction, addMemoryAction, addMessage, toggleCorgiMode, config, showToolDescriptions, session, gitService, loadHistory, addItem, setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, ]); const handleSlashCommand = useCallback( async ( rawQuery: PartListUnion, ): Promise => { 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( { type: MessageType.USER, text: trimmed }, userMessageTimestamp, ); } let subCommand: string | undefined; let args: string | undefined; const commandToMatch = (() => { if (trimmed.startsWith('?')) { return 'help'; } const parts = trimmed.substring(1).trim().split(/\s+/); if (parts.length > 1) { subCommand = parts[1]; } if (parts.length > 2) { args = parts.slice(2).join(' '); } return parts[0]; })(); const mainCommand = commandToMatch; for (const cmd of slashCommands) { 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 } return true; // Command was handled, but no tool to schedule } } addMessage({ type: MessageType.ERROR, content: `Unknown command: ${trimmed}`, timestamp: new Date(), }); return true; // Indicate command was processed (even if unknown) }, [addItem, slashCommands, addMessage], ); return { handleSlashCommand, slashCommands, pendingHistoryItems }; };