mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-18 09:38:33 +00:00
* fix: intercept slash commands in handleSubmit to pass arguments correctly When a user types a slash command with arguments (e.g. `/feature implement dark mode`) and presses Enter, the command was not being intercepted as a slash command. Instead, the raw text was sent as a regular message to the Claude API, which responded with "Unknown skill: feature". Root cause: the command autocomplete menu (useSlashCommands) detects commands via the regex `/(^|\s)\/(\S*)$/` which only matches when the cursor is right after the command name with no spaces. As soon as the user types a space to add arguments, the pattern stops matching, the menu closes, and pressing Enter falls through to handleSubmit which sends the text as a plain message — completely bypassing command execution. This fix adds a slash command interceptor at the top of handleSubmit: - Checks if the trimmed input starts with `/` - Extracts the command name (text before the first space) - Looks up the command in the loaded slashCommands list - If found, delegates to executeCommand() which properly extracts arguments via regex and sends them to the backend /api/commands/execute endpoint - The backend then replaces $ARGUMENTS, $1, $2 etc. in the command template Changes: - Added `slashCommands` to the destructured return of useSlashCommands hook - Added slash command interception logic in handleSubmit before message dispatch - Added `executeCommand` and `slashCommands` to handleSubmit dependency array Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review — pass rawInput param and cleanup UI state - Pass trimmedInput to executeCommand to avoid stale closure reads - Add UI cleanup (images, command menu, textarea) before early return - Update executeCommand signature to accept optional rawInput parameter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
|
import Fuse from 'fuse.js';
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import { safeLocalStorage } from '../utils/chatStorage';
|
|
import type { Project } from '../../../types/app';
|
|
|
|
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
|
|
|
export interface SlashCommand {
|
|
name: string;
|
|
description?: string;
|
|
namespace?: string;
|
|
path?: string;
|
|
type?: string;
|
|
metadata?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface UseSlashCommandsOptions {
|
|
selectedProject: Project | null;
|
|
input: string;
|
|
setInput: Dispatch<SetStateAction<string>>;
|
|
textareaRef: RefObject<HTMLTextAreaElement>;
|
|
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
|
}
|
|
|
|
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
|
|
|
const readCommandHistory = (projectName: string): Record<string, number> => {
|
|
const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));
|
|
if (!history) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(history);
|
|
} catch (error) {
|
|
console.error('Error parsing command history:', error);
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const saveCommandHistory = (projectName: string, history: Record<string, number>) => {
|
|
safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));
|
|
};
|
|
|
|
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
|
|
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
|
|
|
|
export function useSlashCommands({
|
|
selectedProject,
|
|
input,
|
|
setInput,
|
|
textareaRef,
|
|
onExecuteCommand,
|
|
}: UseSlashCommandsOptions) {
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
|
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
|
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
const [commandQuery, setCommandQuery] = useState('');
|
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
|
const [slashPosition, setSlashPosition] = useState(-1);
|
|
|
|
const commandQueryTimerRef = useRef<number | null>(null);
|
|
|
|
const clearCommandQueryTimer = useCallback(() => {
|
|
if (commandQueryTimerRef.current !== null) {
|
|
window.clearTimeout(commandQueryTimerRef.current);
|
|
commandQueryTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const resetCommandMenuState = useCallback(() => {
|
|
setShowCommandMenu(false);
|
|
setSlashPosition(-1);
|
|
setCommandQuery('');
|
|
setSelectedCommandIndex(-1);
|
|
clearCommandQueryTimer();
|
|
}, [clearCommandQueryTimer]);
|
|
|
|
useEffect(() => {
|
|
const fetchCommands = async () => {
|
|
if (!selectedProject) {
|
|
setSlashCommands([]);
|
|
setFilteredCommands([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch('/api/commands/list', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
projectPath: selectedProject.path,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch commands');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const allCommands: SlashCommand[] = [
|
|
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
|
...command,
|
|
type: 'built-in',
|
|
})),
|
|
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
|
...command,
|
|
type: 'custom',
|
|
})),
|
|
];
|
|
|
|
const parsedHistory = readCommandHistory(selectedProject.name);
|
|
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
|
|
const commandAUsage = parsedHistory[commandA.name] || 0;
|
|
const commandBUsage = parsedHistory[commandB.name] || 0;
|
|
return commandBUsage - commandAUsage;
|
|
});
|
|
|
|
setSlashCommands(sortedCommands);
|
|
} catch (error) {
|
|
console.error('Error fetching slash commands:', error);
|
|
setSlashCommands([]);
|
|
}
|
|
};
|
|
|
|
fetchCommands();
|
|
}, [selectedProject]);
|
|
|
|
useEffect(() => {
|
|
if (!showCommandMenu) {
|
|
setSelectedCommandIndex(-1);
|
|
}
|
|
}, [showCommandMenu]);
|
|
|
|
const fuse = useMemo(() => {
|
|
if (!slashCommands.length) {
|
|
return null;
|
|
}
|
|
|
|
return new Fuse(slashCommands, {
|
|
keys: [
|
|
{ name: 'name', weight: 2 },
|
|
{ name: 'description', weight: 1 },
|
|
],
|
|
threshold: 0.4,
|
|
includeScore: true,
|
|
minMatchCharLength: 1,
|
|
});
|
|
}, [slashCommands]);
|
|
|
|
useEffect(() => {
|
|
if (!commandQuery) {
|
|
setFilteredCommands(slashCommands);
|
|
return;
|
|
}
|
|
|
|
if (!fuse) {
|
|
setFilteredCommands([]);
|
|
return;
|
|
}
|
|
|
|
const results = fuse.search(commandQuery);
|
|
setFilteredCommands(results.map((result) => result.item));
|
|
}, [commandQuery, slashCommands, fuse]);
|
|
|
|
const frequentCommands = useMemo(() => {
|
|
if (!selectedProject || slashCommands.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const parsedHistory = readCommandHistory(selectedProject.name);
|
|
|
|
return slashCommands
|
|
.map((command) => ({
|
|
...command,
|
|
usageCount: parsedHistory[command.name] || 0,
|
|
}))
|
|
.filter((command) => command.usageCount > 0)
|
|
.sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)
|
|
.slice(0, 5);
|
|
}, [selectedProject, slashCommands]);
|
|
|
|
const trackCommandUsage = useCallback(
|
|
(command: SlashCommand) => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
|
|
const parsedHistory = readCommandHistory(selectedProject.name);
|
|
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
|
saveCommandHistory(selectedProject.name, parsedHistory);
|
|
},
|
|
[selectedProject],
|
|
);
|
|
|
|
const selectCommandFromKeyboard = useCallback(
|
|
(command: SlashCommand) => {
|
|
const textBeforeSlash = input.slice(0, slashPosition);
|
|
const textAfterSlash = input.slice(slashPosition);
|
|
const spaceIndex = textAfterSlash.indexOf(' ');
|
|
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
|
|
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
|
|
|
|
setInput(newInput);
|
|
resetCommandMenuState();
|
|
|
|
const executionResult = onExecuteCommand(command);
|
|
if (isPromiseLike(executionResult)) {
|
|
executionResult.catch(() => {
|
|
// Keep behavior silent; execution errors are handled by caller.
|
|
});
|
|
}
|
|
},
|
|
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
|
|
);
|
|
|
|
const handleCommandSelect = useCallback(
|
|
(command: SlashCommand | null, index: number, isHover: boolean) => {
|
|
if (!command || !selectedProject) {
|
|
return;
|
|
}
|
|
|
|
if (isHover) {
|
|
setSelectedCommandIndex(index);
|
|
return;
|
|
}
|
|
|
|
trackCommandUsage(command);
|
|
const executionResult = onExecuteCommand(command);
|
|
|
|
if (isPromiseLike(executionResult)) {
|
|
executionResult.then(() => {
|
|
resetCommandMenuState();
|
|
});
|
|
executionResult.catch(() => {
|
|
// Keep behavior silent; execution errors are handled by caller.
|
|
});
|
|
} else {
|
|
resetCommandMenuState();
|
|
}
|
|
},
|
|
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
|
|
);
|
|
|
|
const handleToggleCommandMenu = useCallback(() => {
|
|
const isOpening = !showCommandMenu;
|
|
setShowCommandMenu(isOpening);
|
|
setCommandQuery('');
|
|
setSelectedCommandIndex(-1);
|
|
|
|
if (isOpening) {
|
|
setFilteredCommands(slashCommands);
|
|
}
|
|
|
|
textareaRef.current?.focus();
|
|
}, [showCommandMenu, slashCommands, textareaRef]);
|
|
|
|
const handleCommandInputChange = useCallback(
|
|
(newValue: string, cursorPos: number) => {
|
|
if (!newValue.trim()) {
|
|
resetCommandMenuState();
|
|
return;
|
|
}
|
|
|
|
const textBeforeCursor = newValue.slice(0, cursorPos);
|
|
const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
|
|
const inCodeBlock = backticksBefore % 2 === 1;
|
|
|
|
if (inCodeBlock) {
|
|
resetCommandMenuState();
|
|
return;
|
|
}
|
|
|
|
const slashPattern = /(^|\s)\/(\S*)$/;
|
|
const match = textBeforeCursor.match(slashPattern);
|
|
|
|
if (!match) {
|
|
resetCommandMenuState();
|
|
return;
|
|
}
|
|
|
|
const slashPos = (match.index || 0) + match[1].length;
|
|
const query = match[2];
|
|
|
|
setSlashPosition(slashPos);
|
|
setShowCommandMenu(true);
|
|
setSelectedCommandIndex(-1);
|
|
|
|
clearCommandQueryTimer();
|
|
commandQueryTimerRef.current = window.setTimeout(() => {
|
|
setCommandQuery(query);
|
|
}, COMMAND_QUERY_DEBOUNCE_MS);
|
|
},
|
|
[resetCommandMenuState, clearCommandQueryTimer],
|
|
);
|
|
|
|
const handleCommandMenuKeyDown = useCallback(
|
|
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
if (!showCommandMenu) {
|
|
return false;
|
|
}
|
|
|
|
if (!filteredCommands.length) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
resetCommandMenuState();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
setSelectedCommandIndex((previousIndex) =>
|
|
previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
setSelectedCommandIndex((previousIndex) =>
|
|
previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'Tab' || event.key === 'Enter') {
|
|
event.preventDefault();
|
|
if (selectedCommandIndex >= 0) {
|
|
selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
|
|
} else if (filteredCommands.length > 0) {
|
|
selectCommandFromKeyboard(filteredCommands[0]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
resetCommandMenuState();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
[showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
clearCommandQueryTimer();
|
|
},
|
|
[clearCommandQueryTimer],
|
|
);
|
|
|
|
return {
|
|
slashCommands,
|
|
slashCommandsCount: slashCommands.length,
|
|
filteredCommands,
|
|
frequentCommands,
|
|
commandQuery,
|
|
showCommandMenu,
|
|
selectedCommandIndex,
|
|
resetCommandMenuState,
|
|
handleCommandSelect,
|
|
handleToggleCommandMenu,
|
|
handleCommandInputChange,
|
|
handleCommandMenuKeyDown,
|
|
};
|
|
}
|