buroventures-harald-code
Version:
Harald Code - AI-powered coding assistant CLI
377 lines • 16.5 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo, useEffect, useState } from 'react';
import process from 'node:process';
import { useStateAndRef } from './useStateAndRef.js';
import { GitService, Logger, logSlashCommand, SlashCommandEvent, ToolConfirmationOutcome, } from 'buroventures-harald-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { MessageType, } from '../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';
/**
* Hook to define and process slash commands (e.g., /help, /clear).
*/
export const useSlashCommandProcessor = (config, settings, addItem, clearItems, loadHistory, refreshStatic, onDebugMessage, openThemeDialog, openAuthDialog, openEditorDialog, toggleCorgiMode, setQuittingMessages, openPrivacyNotice, toggleVimEnabled, setIsProcessing) => {
const session = useSessionStats();
const [commands, setCommands] = useState([]);
const [shellConfirmationRequest, setShellConfirmationRequest] = useState(null);
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(new Set());
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(null);
const pendingHistoryItems = useMemo(() => {
const items = [];
if (pendingCompressionItemRef.current != null) {
items.push(pendingCompressionItemRef.current);
}
return items;
}, [pendingCompressionItemRef]);
const addMessage = useCallback((message) => {
// Convert Message to HistoryItemWithoutId
let historyItemContent;
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.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 {
historyItemContent = {
type: message.type,
text: message.content,
};
}
addItem(historyItemContent, message.timestamp.getTime());
}, [addItem]);
const commandContext = useMemo(() => ({
services: {
config,
settings,
git: gitService,
logger,
},
ui: {
addItem,
clear: () => {
clearItems();
console.clear();
refreshStatic();
},
loadHistory,
setDebugMessage: onDebugMessage,
pendingItem: pendingCompressionItemRef.current,
setPendingItem: setPendingCompressionItem,
toggleCorgiMode,
toggleVimEnabled,
},
session: {
stats: session.stats,
sessionShellAllowlist,
resetSession: session.resetSession,
},
}), [
config,
settings,
gitService,
logger,
loadHistory,
addItem,
clearItems,
refreshStatic,
session.stats,
session.resetSession,
onDebugMessage,
pendingCompressionItemRef,
setPendingCompressionItem,
toggleCorgiMode,
toggleVimEnabled,
sessionShellAllowlist,
]);
const ideMode = config?.getIdeMode();
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, ideMode]);
const handleSlashCommand = useCallback(async (rawQuery, oneTimeShellAllowlist) => {
setIsProcessing(true);
try {
if (typeof rawQuery !== 'string') {
return false;
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
const userMessageTimestamp = Date.now();
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']
let currentCommands = commands;
let commandToExecute;
let pathIndex = 0;
const canonicalPath = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
// search could be replaced. A more optimal approach would be to
// pre-compute a single lookup map in `CommandService.ts` that resolves
// all name and alias conflicts during the initial loading phase. The
// processor would then perform a single, fast lookup on that map.
// First pass: check for an exact match on the primary command name.
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
// Second pass: if no primary name matches, check for an alias.
if (!foundCommand) {
foundCommand = currentCommands.find((cmd) => cmd.altNames?.includes(part));
}
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
}
else {
break;
}
}
else {
break;
}
}
if (commandToExecute) {
const args = parts.slice(pathIndex).join(' ');
if (commandToExecute.action) {
if (config) {
const resolvedCommandPath = canonicalPath;
const event = new SlashCommandEvent(resolvedCommandPath[0], resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined);
logSlashCommand(config, event);
}
const fullCommandContext = {
...commandContext,
invocation: {
raw: trimmed,
name: commandToExecute.name,
args,
},
};
// 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':
addItem({
type: result.messageType === 'error'
? MessageType.ERROR
: MessageType.INFO,
text: result.content,
}, Date.now());
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
case 'auth':
openAuthDialog();
return { type: 'handled' };
case 'theme':
openThemeDialog();
return { type: 'handled' };
case 'editor':
openEditorDialog();
return { type: 'handled' };
case 'privacy':
openPrivacyNotice();
return { type: 'handled' };
default: {
const unhandled = result.dialog;
throw new Error(`Unhandled slash command result: ${unhandled}`);
}
}
case 'load_history': {
await config
?.getGeminiClient()
?.setHistory(result.clientHistory);
fullCommandContext.ui.clear();
result.history.forEach((item, index) => {
fullCommandContext.ui.addItem(item, index);
});
return { type: 'handled' };
}
case 'quit':
setQuittingMessages(result.messages);
setTimeout(() => {
process.exit(0);
}, 100);
return { type: 'handled' };
case 'submit_prompt':
return {
type: 'submit_prompt',
content: result.content,
};
case 'confirm_shell_commands': {
const { outcome, approvedCommands } = await new Promise((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));
}
default: {
const unhandled = 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) {
addItem({
type: MessageType.ERROR,
text: e instanceof Error ? e.message : String(e),
}, Date.now());
return { type: 'handled' };
}
finally {
setIsProcessing(false);
}
}, [
config,
addItem,
openAuthDialog,
commands,
commandContext,
addMessage,
openThemeDialog,
openPrivacyNotice,
openEditorDialog,
setQuittingMessages,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,
]);
return {
handleSlashCommand,
slashCommands: commands,
pendingHistoryItems,
commandContext,
shellConfirmationRequest,
};
};
//# sourceMappingURL=slashCommandProcessor.js.map