feature(commands) - Refactor Slash Command + Vision For the Future (#3175)

This commit is contained in:
Abhi
2025-07-07 16:45:44 -04:00
committed by GitHub
parent 6eccb474c7
commit aa10ccba71
26 changed files with 2436 additions and 726 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useEffect, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import open from 'open';
import process from 'node:process';
@@ -25,23 +25,24 @@ import {
MessageType,
HistoryItemWithoutId,
HistoryItem,
SlashCommandProcessorResult,
} 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';
import {
type CommandContext,
type SlashCommandActionReturn,
type SlashCommand,
} from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
export interface SlashCommandActionReturn {
shouldScheduleTool?: boolean;
toolName?: string;
toolArgs?: Record<string, unknown>;
message?: string; // For simple messages or errors
}
export interface SlashCommand {
// 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;
@@ -53,7 +54,7 @@ export interface SlashCommand {
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>; // Action can now return this object
| Promise<void | SlashCommandActionReturn>;
}
/**
@@ -72,13 +73,13 @@ export const useSlashCommandProcessor = (
openThemeDialog: () => void,
openAuthDialog: () => void,
openEditorDialog: () => void,
performMemoryRefresh: () => Promise<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;
@@ -86,12 +87,23 @@ export const useSlashCommandProcessor = (
return new GitService(config.getProjectRoot());
}, [config]);
const pendingHistoryItems: HistoryItemWithoutId[] = [];
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);
if (pendingCompressionItemRef.current != null) {
pendingHistoryItems.push(pendingCompressionItemRef.current);
}
const pendingHistoryItems = useMemo(() => {
const items: HistoryItemWithoutId[] = [];
if (pendingCompressionItemRef.current != null) {
items.push(pendingCompressionItemRef.current);
}
return items;
}, [pendingCompressionItemRef]);
const addMessage = useCallback(
(message: Message) => {
@@ -141,41 +153,51 @@ export const useSlashCommandProcessor = (
[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 <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 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) {
@@ -193,17 +215,12 @@ export const useSlashCommandProcessor = (
}
}, [config]);
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);
},
},
// 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',
@@ -225,17 +242,6 @@ export const useSlashCommandProcessor = (
}
},
},
{
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',
@@ -246,23 +252,17 @@ export const useSlashCommandProcessor = (
{
name: 'auth',
description: 'change the auth method',
action: (_mainCommand, _subCommand, _args) => {
openAuthDialog();
},
action: (_mainCommand, _subCommand, _args) => openAuthDialog(),
},
{
name: 'editor',
description: 'set external editor preference',
action: (_mainCommand, _subCommand, _args) => {
openEditorDialog();
},
action: (_mainCommand, _subCommand, _args) => openEditorDialog(),
},
{
name: 'privacy',
description: 'display the privacy notice',
action: (_mainCommand, _subCommand, _args) => {
openPrivacyNotice();
},
action: (_mainCommand, _subCommand, _args) => openPrivacyNotice(),
},
{
name: 'stats',
@@ -493,38 +493,6 @@ export const useSlashCommandProcessor = (
});
},
},
{
name: 'memory',
description:
'manage memory. Usage: /memory <show|refresh|add> [text for add]',
action: (mainCommand, subCommand, args) => {
switch (subCommand) {
case 'show':
showMemoryAction();
return;
case 'refresh':
performMemoryRefresh();
return;
case 'add':
return addMemoryAction(mainCommand, subCommand, args); // Return the object
case undefined:
addMessage({
type: MessageType.ERROR,
content:
'Missing command\nUsage: /memory <show|refresh|add> [text for add]',
timestamp: new Date(),
});
return;
default:
addMessage({
type: MessageType.ERROR,
content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`,
timestamp: new Date(),
});
return;
}
},
},
{
name: 'tools',
description: 'list available Gemini CLI tools',
@@ -1020,7 +988,7 @@ export const useSlashCommandProcessor = (
}
return {
shouldScheduleTool: true,
type: 'tool',
toolName: toolCallData.toolCall.name,
toolArgs: toolCallData.toolCall.args,
};
@@ -1036,17 +1004,11 @@ export const useSlashCommandProcessor = (
}
return commands;
}, [
onDebugMessage,
setShowHelp,
refreshStatic,
addMessage,
openThemeDialog,
openAuthDialog,
openEditorDialog,
clearItems,
performMemoryRefresh,
showMemoryAction,
addMemoryAction,
addMessage,
openPrivacyNotice,
toggleCorgiMode,
savedChatTags,
config,
@@ -1059,20 +1021,23 @@ export const useSlashCommandProcessor = (
setQuittingMessages,
pendingCompressionItemRef,
setPendingCompressionItem,
openPrivacyNotice,
clearItems,
refreshStatic,
]);
const handleSlashCommand = useCallback(
async (
rawQuery: PartListUnion,
): Promise<SlashCommandActionReturn | boolean> => {
): 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(
@@ -1081,35 +1046,128 @@ export const useSlashCommandProcessor = (
);
}
let subCommand: string | undefined;
let args: string | undefined;
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
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];
})();
// --- Start of New Tree Traversal Logic ---
const mainCommand = commandToMatch;
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
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
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;
}
return true; // Command was handled, but no tool to schedule
} 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' };
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' };
}
}
@@ -1118,10 +1176,51 @@ export const useSlashCommandProcessor = (
content: `Unknown command: ${trimmed}`,
timestamp: new Date(),
});
return true; // Indicate command was processed (even if unknown)
return { type: 'handled' };
},
[addItem, slashCommands, addMessage],
[
addItem,
setShowHelp,
commands,
legacyCommands,
commandContext,
addMessage,
],
);
return { handleSlashCommand, slashCommands, pendingHistoryItems };
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,
};
};