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

1188 lines
39 KiB
TypeScript

/**
* @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<string[]>;
action: (
mainCommand: string,
subCommand?: string,
args?: string,
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>;
}
/**
* 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<React.SetStateAction<boolean>>,
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<SlashCommand[]>([]);
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<HistoryItemWithoutId | null>(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 <list|save|resume> <tag>',
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 <list|save|resume> <tag>',
timestamp: new Date(),
});
return;
}
switch (subCommand) {
case 'save': {
if (!tag) {
addMessage({
type: MessageType.ERROR,
content: 'Missing tag. Usage: /chat save <tag>',
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 <tag>',
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<SlashCommandProcessorResult | false> => {
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,
};
};