mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
638 lines
20 KiB
TypeScript
638 lines
20 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 type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import {
|
|
type Logger,
|
|
type Config,
|
|
GitService,
|
|
logSlashCommand,
|
|
makeSlashCommandEvent,
|
|
SlashCommandStatus,
|
|
ToolConfirmationOutcome,
|
|
IdeClient,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
|
import type {
|
|
Message,
|
|
HistoryItemWithoutId,
|
|
SlashCommandProcessorResult,
|
|
HistoryItem,
|
|
ConfirmationRequest,
|
|
} from '../types.js';
|
|
import { MessageType } from '../types.js';
|
|
import type { LoadedSettings } from '../../config/settings.js';
|
|
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
|
import { CommandService } from '../../services/CommandService.js';
|
|
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
|
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
|
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
|
import { parseSlashCommand } from '../../utils/commands.js';
|
|
import {
|
|
type ExtensionUpdateAction,
|
|
type ExtensionUpdateStatus,
|
|
} from '../state/extensions.js';
|
|
|
|
type SerializableHistoryItem = Record<string, unknown>;
|
|
|
|
function serializeHistoryItemForRecording(
|
|
item: Omit<HistoryItem, 'id'>,
|
|
): SerializableHistoryItem {
|
|
const clone: SerializableHistoryItem = { ...item };
|
|
if ('timestamp' in clone && clone['timestamp'] instanceof Date) {
|
|
clone['timestamp'] = clone['timestamp'].toISOString();
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
|
'quit',
|
|
'exit',
|
|
'clear',
|
|
'reset',
|
|
'new',
|
|
'resume',
|
|
]);
|
|
|
|
interface SlashCommandProcessorActions {
|
|
openAuthDialog: () => void;
|
|
openThemeDialog: () => void;
|
|
openEditorDialog: () => void;
|
|
openSettingsDialog: () => void;
|
|
openModelDialog: () => void;
|
|
openPermissionsDialog: () => void;
|
|
openApprovalModeDialog: () => void;
|
|
openResumeDialog: () => void;
|
|
quit: (messages: HistoryItem[]) => void;
|
|
setDebugMessage: (message: string) => void;
|
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
|
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
|
openSubagentCreateDialog: () => void;
|
|
openAgentsManagerDialog: () => void;
|
|
}
|
|
|
|
/**
|
|
* Hook to define and process slash commands (e.g., /help, /clear).
|
|
*/
|
|
export const useSlashCommandProcessor = (
|
|
config: Config | null,
|
|
settings: LoadedSettings,
|
|
addItem: UseHistoryManagerReturn['addItem'],
|
|
clearItems: UseHistoryManagerReturn['clearItems'],
|
|
loadHistory: UseHistoryManagerReturn['loadHistory'],
|
|
refreshStatic: () => void,
|
|
toggleVimEnabled: () => Promise<boolean>,
|
|
setIsProcessing: (isProcessing: boolean) => void,
|
|
setGeminiMdFileCount: (count: number) => void,
|
|
actions: SlashCommandProcessorActions,
|
|
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
|
isConfigInitialized: boolean,
|
|
logger: Logger | null,
|
|
) => {
|
|
const { stats: sessionStats, startNewSession } = useSessionStats();
|
|
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
|
|
const [reloadTrigger, setReloadTrigger] = useState(0);
|
|
|
|
const reloadCommands = useCallback(() => {
|
|
setReloadTrigger((v) => v + 1);
|
|
}, []);
|
|
const [shellConfirmationRequest, setShellConfirmationRequest] =
|
|
useState<null | {
|
|
commands: string[];
|
|
onConfirm: (
|
|
outcome: ToolConfirmationOutcome,
|
|
approvedCommands?: string[],
|
|
) => void;
|
|
}>(null);
|
|
const [confirmationRequest, setConfirmationRequest] = useState<null | {
|
|
prompt: React.ReactNode;
|
|
onConfirm: (confirmed: boolean) => void;
|
|
}>(null);
|
|
|
|
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
|
new Set<string>(),
|
|
);
|
|
const gitService = useMemo(() => {
|
|
if (!config?.getProjectRoot()) {
|
|
return;
|
|
}
|
|
return new GitService(config.getProjectRoot(), config.storage);
|
|
}, [config]);
|
|
|
|
const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>(
|
|
null,
|
|
);
|
|
|
|
const pendingHistoryItems = useMemo(() => {
|
|
const items: HistoryItemWithoutId[] = [];
|
|
if (pendingItem != null) {
|
|
items.push(pendingItem);
|
|
}
|
|
return items;
|
|
}, [pendingItem]);
|
|
|
|
const addMessage = useCallback(
|
|
(message: Message) => {
|
|
// Convert Message to HistoryItemWithoutId
|
|
let historyItemContent: HistoryItemWithoutId;
|
|
if (message.type === MessageType.ABOUT) {
|
|
historyItemContent = {
|
|
type: 'about',
|
|
systemInfo: message.systemInfo,
|
|
};
|
|
} else if (message.type === MessageType.HELP) {
|
|
historyItemContent = {
|
|
type: 'help',
|
|
timestamp: message.timestamp,
|
|
};
|
|
} 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 if (message.type === MessageType.SUMMARY) {
|
|
historyItemContent = {
|
|
type: 'summary',
|
|
summary: message.summary,
|
|
};
|
|
} 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();
|
|
},
|
|
loadHistory,
|
|
setDebugMessage: actions.setDebugMessage,
|
|
pendingItem,
|
|
setPendingItem,
|
|
toggleVimEnabled,
|
|
setGeminiMdFileCount,
|
|
reloadCommands,
|
|
extensionsUpdateState,
|
|
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
|
addConfirmUpdateExtensionRequest:
|
|
actions.addConfirmUpdateExtensionRequest,
|
|
},
|
|
session: {
|
|
stats: sessionStats,
|
|
sessionShellAllowlist,
|
|
startNewSession,
|
|
},
|
|
}),
|
|
[
|
|
config,
|
|
settings,
|
|
gitService,
|
|
logger,
|
|
loadHistory,
|
|
addItem,
|
|
clearItems,
|
|
refreshStatic,
|
|
sessionStats,
|
|
startNewSession,
|
|
actions,
|
|
pendingItem,
|
|
setPendingItem,
|
|
toggleVimEnabled,
|
|
sessionShellAllowlist,
|
|
setGeminiMdFileCount,
|
|
reloadCommands,
|
|
extensionsUpdateState,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
const listener = () => {
|
|
reloadCommands();
|
|
};
|
|
|
|
(async () => {
|
|
const ideClient = await IdeClient.getInstance();
|
|
ideClient.addStatusChangeListener(listener);
|
|
})();
|
|
|
|
return () => {
|
|
(async () => {
|
|
const ideClient = await IdeClient.getInstance();
|
|
ideClient.removeStatusChangeListener(listener);
|
|
})();
|
|
};
|
|
}, [config, reloadCommands]);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const load = async () => {
|
|
const loaders = [
|
|
new McpPromptLoader(config),
|
|
new BuiltinCommandLoader(config),
|
|
new FileCommandLoader(config),
|
|
];
|
|
const commandService = await CommandService.create(
|
|
loaders,
|
|
controller.signal,
|
|
);
|
|
setCommands(commandService.getCommands());
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [config, reloadTrigger, isConfigInitialized]);
|
|
|
|
const handleSlashCommand = useCallback(
|
|
async (
|
|
rawQuery: PartListUnion,
|
|
oneTimeShellAllowlist?: Set<string>,
|
|
overwriteConfirmed?: boolean,
|
|
): Promise<SlashCommandProcessorResult | false> => {
|
|
if (typeof rawQuery !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const trimmed = rawQuery.trim();
|
|
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
|
|
return false;
|
|
}
|
|
|
|
const recordedItems: Array<Omit<HistoryItem, 'id'>> = [];
|
|
const recordItem = (item: Omit<HistoryItem, 'id'>) => {
|
|
recordedItems.push(item);
|
|
};
|
|
const addItemWithRecording: UseHistoryManagerReturn['addItem'] = (
|
|
item,
|
|
timestamp,
|
|
) => {
|
|
recordItem(item);
|
|
return addItem(item, timestamp);
|
|
};
|
|
|
|
setIsProcessing(true);
|
|
|
|
const userMessageTimestamp = Date.now();
|
|
addItemWithRecording(
|
|
{ type: MessageType.USER, text: trimmed },
|
|
userMessageTimestamp,
|
|
);
|
|
|
|
let hasError = false;
|
|
const {
|
|
commandToExecute,
|
|
args,
|
|
canonicalPath: resolvedCommandPath,
|
|
} = parseSlashCommand(trimmed, commands);
|
|
|
|
const subcommand =
|
|
resolvedCommandPath.length > 1
|
|
? resolvedCommandPath.slice(1).join(' ')
|
|
: undefined;
|
|
|
|
try {
|
|
if (commandToExecute) {
|
|
if (commandToExecute.action) {
|
|
const fullCommandContext: CommandContext = {
|
|
...commandContext,
|
|
ui: {
|
|
...commandContext.ui,
|
|
addItem: addItemWithRecording,
|
|
},
|
|
invocation: {
|
|
raw: trimmed,
|
|
name: commandToExecute.name,
|
|
args,
|
|
},
|
|
overwriteConfirmed,
|
|
};
|
|
|
|
// If a one-time list is provided for a "Proceed" action, temporarily
|
|
// augment the session allowlist for this single execution.
|
|
if (oneTimeShellAllowlist && oneTimeShellAllowlist.size > 0) {
|
|
fullCommandContext.session = {
|
|
...fullCommandContext.session,
|
|
sessionShellAllowlist: new Set([
|
|
...fullCommandContext.session.sessionShellAllowlist,
|
|
...oneTimeShellAllowlist,
|
|
]),
|
|
};
|
|
}
|
|
const result = await commandToExecute.action(
|
|
fullCommandContext,
|
|
args,
|
|
);
|
|
|
|
if (result) {
|
|
switch (result.type) {
|
|
case 'tool':
|
|
return {
|
|
type: 'schedule_tool',
|
|
toolName: result.toolName,
|
|
toolArgs: result.toolArgs,
|
|
};
|
|
case 'message':
|
|
if (result.messageType === 'info') {
|
|
addMessage({
|
|
type: MessageType.INFO,
|
|
content: result.content,
|
|
timestamp: new Date(),
|
|
});
|
|
} else {
|
|
addMessage({
|
|
type: MessageType.ERROR,
|
|
content: result.content,
|
|
timestamp: new Date(),
|
|
});
|
|
}
|
|
return { type: 'handled' };
|
|
case 'dialog':
|
|
switch (result.dialog) {
|
|
case 'auth':
|
|
actions.openAuthDialog();
|
|
return { type: 'handled' };
|
|
case 'theme':
|
|
actions.openThemeDialog();
|
|
return { type: 'handled' };
|
|
case 'editor':
|
|
actions.openEditorDialog();
|
|
return { type: 'handled' };
|
|
case 'settings':
|
|
actions.openSettingsDialog();
|
|
return { type: 'handled' };
|
|
case 'model':
|
|
actions.openModelDialog();
|
|
return { type: 'handled' };
|
|
case 'permissions':
|
|
actions.openPermissionsDialog();
|
|
return { type: 'handled' };
|
|
case 'subagent_create':
|
|
actions.openSubagentCreateDialog();
|
|
return { type: 'handled' };
|
|
case 'subagent_list':
|
|
actions.openAgentsManagerDialog();
|
|
return { type: 'handled' };
|
|
case 'approval-mode':
|
|
actions.openApprovalModeDialog();
|
|
return { type: 'handled' };
|
|
case 'resume':
|
|
actions.openResumeDialog();
|
|
return { type: 'handled' };
|
|
case 'help':
|
|
return { type: 'handled' };
|
|
default: {
|
|
const unhandled: never = result.dialog;
|
|
throw new Error(
|
|
`Unhandled slash command result: ${unhandled}`,
|
|
);
|
|
}
|
|
}
|
|
case 'load_history': {
|
|
config?.getGeminiClient()?.setHistory(result.clientHistory);
|
|
config?.getGeminiClient()?.stripThoughtsFromHistory();
|
|
fullCommandContext.ui.clear();
|
|
result.history.forEach((item, index) => {
|
|
fullCommandContext.ui.addItem(item, index);
|
|
});
|
|
return { type: 'handled' };
|
|
}
|
|
|
|
case 'quit':
|
|
actions.quit(result.messages);
|
|
return { type: 'handled' };
|
|
|
|
case 'submit_prompt':
|
|
return {
|
|
type: 'submit_prompt',
|
|
content: result.content,
|
|
};
|
|
case 'confirm_shell_commands': {
|
|
const { outcome, approvedCommands } = await new Promise<{
|
|
outcome: ToolConfirmationOutcome;
|
|
approvedCommands?: string[];
|
|
}>((resolve) => {
|
|
setShellConfirmationRequest({
|
|
commands: result.commandsToConfirm,
|
|
onConfirm: (
|
|
resolvedOutcome,
|
|
resolvedApprovedCommands,
|
|
) => {
|
|
setShellConfirmationRequest(null); // Close the dialog
|
|
resolve({
|
|
outcome: resolvedOutcome,
|
|
approvedCommands: resolvedApprovedCommands,
|
|
});
|
|
},
|
|
});
|
|
});
|
|
|
|
if (
|
|
outcome === ToolConfirmationOutcome.Cancel ||
|
|
!approvedCommands ||
|
|
approvedCommands.length === 0
|
|
) {
|
|
return { type: 'handled' };
|
|
}
|
|
|
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
|
setSessionShellAllowlist(
|
|
(prev) => new Set([...prev, ...approvedCommands]),
|
|
);
|
|
}
|
|
|
|
return await handleSlashCommand(
|
|
result.originalInvocation.raw,
|
|
// Pass the approved commands as a one-time grant for this execution.
|
|
new Set(approvedCommands),
|
|
);
|
|
}
|
|
case 'confirm_action': {
|
|
const { confirmed } = await new Promise<{
|
|
confirmed: boolean;
|
|
}>((resolve) => {
|
|
setConfirmationRequest({
|
|
prompt: result.prompt,
|
|
onConfirm: (resolvedConfirmed) => {
|
|
setConfirmationRequest(null);
|
|
resolve({ confirmed: resolvedConfirmed });
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!confirmed) {
|
|
addItemWithRecording(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Operation cancelled.',
|
|
},
|
|
Date.now(),
|
|
);
|
|
return { type: 'handled' };
|
|
}
|
|
|
|
return await handleSlashCommand(
|
|
result.originalInvocation.raw,
|
|
undefined,
|
|
true,
|
|
);
|
|
}
|
|
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' };
|
|
}
|
|
}
|
|
|
|
addMessage({
|
|
type: MessageType.ERROR,
|
|
content: `Unknown command: ${trimmed}`,
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
return { type: 'handled' };
|
|
} catch (e: unknown) {
|
|
hasError = true;
|
|
if (config) {
|
|
const event = makeSlashCommandEvent({
|
|
command: resolvedCommandPath[0],
|
|
subcommand,
|
|
status: SlashCommandStatus.ERROR,
|
|
});
|
|
logSlashCommand(config, event);
|
|
}
|
|
addItemWithRecording(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: e instanceof Error ? e.message : String(e),
|
|
},
|
|
Date.now(),
|
|
);
|
|
return { type: 'handled' };
|
|
} finally {
|
|
if (config?.getChatRecordingService) {
|
|
const chatRecorder = config.getChatRecordingService();
|
|
const primaryCommand =
|
|
resolvedCommandPath[0] ||
|
|
trimmed.replace(/^[/?]/, '').split(/\s+/)[0] ||
|
|
trimmed;
|
|
const shouldRecord =
|
|
!SLASH_COMMANDS_SKIP_RECORDING.has(primaryCommand);
|
|
try {
|
|
if (shouldRecord) {
|
|
chatRecorder?.recordSlashCommand({
|
|
phase: 'invocation',
|
|
rawCommand: trimmed,
|
|
});
|
|
const outputItems = recordedItems
|
|
.filter((item) => item.type !== 'user')
|
|
.map(serializeHistoryItemForRecording);
|
|
chatRecorder?.recordSlashCommand({
|
|
phase: 'result',
|
|
rawCommand: trimmed,
|
|
outputHistoryItems: outputItems,
|
|
});
|
|
}
|
|
} catch (recordError) {
|
|
if (config.getDebugMode()) {
|
|
console.error(
|
|
'[slashCommand] Failed to record slash command:',
|
|
recordError,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (config && resolvedCommandPath[0] && !hasError) {
|
|
const event = makeSlashCommandEvent({
|
|
command: resolvedCommandPath[0],
|
|
subcommand,
|
|
status: SlashCommandStatus.SUCCESS,
|
|
});
|
|
logSlashCommand(config, event);
|
|
}
|
|
setIsProcessing(false);
|
|
}
|
|
},
|
|
[
|
|
config,
|
|
addItem,
|
|
actions,
|
|
commands,
|
|
commandContext,
|
|
addMessage,
|
|
setShellConfirmationRequest,
|
|
setSessionShellAllowlist,
|
|
setIsProcessing,
|
|
setConfirmationRequest,
|
|
],
|
|
);
|
|
|
|
return {
|
|
handleSlashCommand,
|
|
slashCommands: commands,
|
|
pendingHistoryItems,
|
|
commandContext,
|
|
shellConfirmationRequest,
|
|
confirmationRequest,
|
|
};
|
|
};
|