gemini-cli/packages/cli/src/ui/hooks/slashCommandProcessor.ts

826 lines
27 KiB
TypeScript

/**
* @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 {
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';
export interface SlashCommandActionReturn {
shouldScheduleTool?: boolean;
toolName?: string;
toolArgs?: Record<string, unknown>;
message?: string; // For simple messages or errors
}
export interface SlashCommand {
name: string;
altName?: string;
description?: string;
action: (
mainCommand: string,
subCommand?: string,
args?: string,
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>; // Action can now return this object
}
/**
* Hook to define and process slash commands (e.g., /help, /clear).
*/
export const useSlashCommandProcessor = (
config: Config | null,
history: HistoryItem[],
addItem: UseHistoryManagerReturn['addItem'],
clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'],
refreshStatic: () => void,
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
performMemoryRefresh: () => Promise<void>,
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 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 {
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, addMessage);
await actionFn();
}, [config, addMessage]);
const addMemoryAction = useCallback(
(
_mainCommand: string,
_subCommand?: string,
args?: string,
): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
addMessage({
type: MessageType.ERROR,
content: 'Usage: /memory add <text to remember>',
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',
action: (_mainCommand, _subCommand, _args) => {
onDebugMessage('Clearing terminal.');
clearItems();
console.clear();
refreshStatic();
},
},
{
name: 'theme',
description: 'change the theme',
action: (_mainCommand, _subCommand, _args) => {
openThemeDialog();
},
},
{
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;
}
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 && server?.description) {
const greenColor = '\u001b[32m';
const resetColor = '\u001b[0m';
const descLines = server.description.split('\n');
message += `: ${greenColor}${descLines[0]}${resetColor}`;
message += '\n';
// If there are multiple lines, add proper indentation for each line
if (descLines.length > 1) {
for (let i = 1; i < descLines.length; i++) {
// Skip empty lines at the end
if (i === descLines.length - 1 && descLines[i].trim() === '')
continue;
message += ` ${greenColor}${descLines[i]}${resetColor}\n`;
}
}
} else {
message += '\n';
}
// Reset formatting after server entry
message += '\u001b[0m';
if (serverTools.length > 0) {
serverTools.forEach((tool) => {
if (useShowDescriptions && 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.split('\n');
message += `${greenColor}${descLines[0]}${resetColor}\n`;
// If there are multiple lines, add proper indentation for each line
if (descLines.length > 1) {
for (let i = 1; i < descLines.length; i++) {
// Skip empty lines at the end
if (
i === descLines.length - 1 &&
descLines[i].trim() === ''
)
continue;
message += ` ${greenColor}${descLines[i]}${resetColor}\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`;
}
});
} 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 <show|refresh|add> [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) => {
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));
const geminiToolList = geminiTools.map((tool) => tool.displayName);
addMessage({
type: MessageType.INFO,
content: `Available Gemini CLI tools:\n\n${geminiToolList.join('\n')}`,
timestamp: new Date(),
});
},
},
{
name: 'corgi',
action: (_mainCommand, _subCommand, _args) => {
toggleCorgiMode();
},
},
{
name: 'about',
description: 'show version info',
action: (_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 = getCliVersion();
addMessage({
type: MessageType.ABOUT,
timestamp: new Date(),
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
});
},
},
{
name: 'bug',
description: 'submit a bug report',
action: (_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 memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const cliVersion = getCliVersion();
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';
if (bugDescription) {
const encodedArgs = encodeURIComponent(bugDescription);
bugReportUrl += `&title=${encodedArgs}`;
}
const encodedBody = encodeURIComponent(diagnosticInfo);
bugReportUrl += `&body=${encodedBody}`;
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]',
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);
},
},
];
if (config?.getCheckpointEnabled()) {
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',
action: async (_mainCommand, subCommand, _args) => {
const checkpointDir = config?.getGeminiDir()
? path.join(config.getGeminiDir(), '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,
clearItems,
performMemoryRefresh,
showMemoryAction,
addMemoryAction,
addMessage,
toggleCorgiMode,
config,
showToolDescriptions,
session,
gitService,
loadHistory,
addItem,
setQuittingMessages,
]);
const handleSlashCommand = useCallback(
async (
rawQuery: PartListUnion,
): Promise<SlashCommandActionReturn | boolean> => {
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 };
};