mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feature(commands) - Refactor Slash Command + Vision For the Future (#3175)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user