/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useMemo, useEffect, useState } 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 '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { Message, MessageType, HistoryItemWithoutId, HistoryItem, SlashCommandProcessorResult, } from '../types.js'; import { promises as fs } from 'fs'; import path from 'path'; 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'; // 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; completion?: () => Promise; action: ( mainCommand: string, subCommand?: string, args?: string, ) => | void | SlashCommandActionReturn | Promise; } /** * 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, toggleCorgiMode: () => void, showToolDescriptions: boolean = false, setQuittingMessages: (message: HistoryItem[]) => void, openPrivacyNotice: () => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); const gitService = useMemo(() => { if (!config?.getProjectRoot()) { return; } return new GitService(config.getProjectRoot()); }, [config]); 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(null); const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingCompressionItemRef.current != null) { items.push(pendingCompressionItemRef.current); } return items; }, [pendingCompressionItemRef]); 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, }; } 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(); 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) { 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 []; } }, [config]); // 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', action: async (_mainCommand, _subCommand, _args) => { const docsUrl = 'https://goo.gle/gemini-cli-docs'; if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { addMessage({ type: MessageType.INFO, content: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, timestamp: new Date(), }); } else { addMessage({ type: MessageType.INFO, content: `Opening documentation in your browser: ${docsUrl}`, timestamp: new Date(), }); await open(docsUrl); } }, }, { name: 'editor', description: 'set external editor preference', action: (_mainCommand, _subCommand, _args) => openEditorDialog(), }, { 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) { const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { addMessage({ type: MessageType.INFO, content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`, timestamp: new Date(), }); } else { addMessage({ type: MessageType.INFO, content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`, timestamp: new Date(), }); await open(docsUrl); } 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 (const descLine of descLines) { message += ` ${greenColor}${descLine}${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 (const descLine of descLines) { message += ` ${greenColor}${descLine}${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 (const paramsLine of paramsLines) { message += ` ${greenColor}${paramsLine}${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: 'extensions', description: 'list active extensions', action: async () => { const activeExtensions = config?.getActiveExtensions(); if (!activeExtensions || activeExtensions.length === 0) { addMessage({ type: MessageType.INFO, content: 'No active extensions.', timestamp: new Date(), }); return; } let message = 'Active extensions:\n\n'; for (const ext of activeExtensions) { message += ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m\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: '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 (const descLine of descLines) { message += ` ${greenColor}${descLine}${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: '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 info = ` * **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.yml&title={title}&info={info}'; const bugCommand = config?.getBugCommand(); if (bugCommand?.urlTemplate) { bugReportUrl = bugCommand.urlTemplate; } bugReportUrl = bugReportUrl .replace('{title}', encodeURIComponent(bugDescription)) .replace('{info}', encodeURIComponent(info)); 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: 'chat', description: 'Manage conversation history. Usage: /chat ', action: async (_mainCommand, subCommand, args) => { const tag = (args || '').trim(); const logger = new Logger(config?.getSessionId() || ''); await logger.initialize(); const chat = await config?.getGeminiClient()?.getChat(); if (!chat) { addMessage({ type: MessageType.ERROR, content: 'No chat client available for conversation status.', timestamp: new Date(), }); return; } if (!subCommand) { addMessage({ type: MessageType.ERROR, content: 'Missing command\nUsage: /chat ', timestamp: new Date(), }); return; } switch (subCommand) { case 'save': { if (!tag) { addMessage({ type: MessageType.ERROR, content: 'Missing tag. Usage: /chat save ', timestamp: new Date(), }); return; } const history = chat.getHistory(); if (history.length > 0) { await logger.saveCheckpoint(chat?.getHistory() || [], tag); addMessage({ type: MessageType.INFO, content: `Conversation checkpoint saved with tag: ${tag}.`, timestamp: new Date(), }); } else { addMessage({ type: MessageType.INFO, content: 'No conversation found to save.', timestamp: new Date(), }); } return; } case 'resume': case 'restore': case 'load': { if (!tag) { addMessage({ type: MessageType.ERROR, content: 'Missing tag. Usage: /chat resume ', timestamp: new Date(), }); return; } const conversation = await logger.loadCheckpoint(tag); if (conversation.length === 0) { addMessage({ type: MessageType.INFO, content: `No saved checkpoint found with tag: ${tag}.`, timestamp: new Date(), }); return; } clearItems(); chat.clearHistory(); const rolemap: { [key: string]: MessageType } = { user: MessageType.USER, model: MessageType.GEMINI, }; let hasSystemPrompt = false; let i = 0; for (const item of conversation) { i += 1; // Add each item to history regardless of whether we display // it. chat.addHistory(item); const text = item.parts ?.filter((m) => !!m.text) .map((m) => m.text) .join('') || ''; if (!text) { // Parsing Part[] back to various non-text output not yet implemented. continue; } if (i === 1 && text.match(/context for our chat/)) { hasSystemPrompt = true; } if (i > 2 || !hasSystemPrompt) { addItem( { type: (item.role && rolemap[item.role]) || MessageType.GEMINI, text, } as HistoryItemWithoutId, i, ); } } console.clear(); refreshStatic(); return; } case 'list': addMessage({ type: MessageType.INFO, content: 'list of saved conversations: ' + (await savedChatTags()).join(', '), timestamp: new Date(), }); return; default: addMessage({ type: MessageType.ERROR, content: `Unknown /chat command: ${subCommand}. Available: list, save, resume`, timestamp: new Date(), }); return; } }, completion: async () => (await savedChatTags()).map((tag) => 'resume ' + tag), }, { name: 'quit', altName: 'exit', description: 'exit the cli', action: async (mainCommand, _subCommand, _args) => { const now = new Date(); const { sessionStartTime } = session.stats; const wallDuration = now.getTime() - sessionStartTime.getTime(); setQuittingMessages([ { type: 'user', text: `/${mainCommand}`, id: now.getTime() - 1, }, { type: 'quit', 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()! // TODO: Set Prompt id for CompressChat from SlashCommandProcessor. .tryCompressChat('Prompt Id not set', 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 { type: 'tool', 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; }, [ addMessage, openEditorDialog, toggleCorgiMode, savedChatTags, config, showToolDescriptions, session, gitService, loadHistory, addItem, setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, clearItems, refreshStatic, ]); 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, ); } const parts = trimmed.substring(1).trim().split(/\s+/); const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] // --- 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 (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' }; case 'auth': openAuthDialog(); return { type: 'handled' }; case 'theme': openThemeDialog(); return { type: 'handled' }; case 'privacy': openPrivacyNotice(); 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' }; } } // --- End of New Tree Traversal Logic --- // --- Legacy Fallback Logic (for commands not yet migrated) --- 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, legacyArgs, ); if (actionResult?.type === 'tool') { return { type: 'schedule_tool', toolName: actionResult.toolName, toolArgs: actionResult.toolArgs, }; } if (actionResult?.type === 'message') { addItem( { type: actionResult.messageType === 'error' ? MessageType.ERROR : MessageType.INFO, text: actionResult.content, }, Date.now(), ); } return { type: 'handled' }; } } addMessage({ type: MessageType.ERROR, content: `Unknown command: ${trimmed}`, timestamp: new Date(), }); return { type: 'handled' }; }, [ addItem, setShowHelp, openAuthDialog, commands, legacyCommands, commandContext, addMessage, openThemeDialog, openPrivacyNotice, ], ); 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, }; };