Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions ui/src/components/chat/hooks/useChatComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,7 @@ export function useChatComposerState({
input,
setInput,
textareaRef,
onExecuteCommand: executeCommand,
inputValueRef,
handleSubmitRef,
});

const {
Expand Down
139 changes: 89 additions & 50 deletions ui/src/components/chat/hooks/useSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ interface UseSlashCommandsOptions {
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
inputValueRef?: { current: string };
handleSubmitRef?: { current: ((event: any) => Promise<void>) | null };
}

const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
Expand All @@ -48,17 +46,56 @@ 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';
const getCommandKey = (command: SlashCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;

const getCommandNamespace = (command: SlashCommand) =>
command.namespace || command.type || 'other';

const groupCommandsForDisplay = (
commands: SlashCommand[],
frequentCommands: SlashCommand[],
): SlashCommand[] => {
const preferredOrder = frequentCommands.length > 0
? ['pinned', 'frequent', 'builtin', 'project', 'user', 'other']
: ['pinned', 'builtin', 'project', 'user', 'other'];
const groups = new Map<string, SlashCommand[]>();
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));

for (const command of commands) {
if (frequentCommandKeys.has(getCommandKey(command))) {
continue;
}
const namespace = getCommandNamespace(command);
const group = groups.get(namespace) || [];
group.push(command);
groups.set(namespace, group);
}

if (frequentCommands.length > 0) {
groups.set(
'frequent',
frequentCommands.map((command) => ({
...command,
namespace: 'frequent',
})),
);
}

const extraNamespaces = [...groups.keys()].filter(
(namespace) => !preferredOrder.includes(namespace),
);
return [...preferredOrder, ...extraNamespaces].flatMap(
(namespace) => groups.get(namespace) || [],
);
};

export function useSlashCommands({
selectedProject,
input,
setInput,
textareaRef,
onExecuteCommand,
inputValueRef: externalInputValueRef,
handleSubmitRef: externalHandleSubmitRef,
}: UseSlashCommandsOptions) {
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
Expand Down Expand Up @@ -223,6 +260,29 @@ export function useSlashCommands({
.slice(0, 5);
}, [selectedProject, slashCommands]);

const displayedCommands = useMemo(() => {
return groupCommandsForDisplay(
filteredCommands,
commandQuery ? [] : frequentCommands,
);
}, [commandQuery, filteredCommands, frequentCommands]);

useEffect(() => {
if (!showCommandMenu) {
return;
}

setSelectedCommandIndex((previousIndex) => {
if (displayedCommands.length === 0) {
return -1;
}
if (previousIndex >= displayedCommands.length) {
return displayedCommands.length - 1;
}
return previousIndex;
});
}, [displayedCommands.length, showCommandMenu]);

const trackCommandUsage = useCallback(
(command: SlashCommand) => {
if (!selectedProject) {
Expand All @@ -236,34 +296,10 @@ export function useSlashCommands({
[selectedProject],
);

const shouldAutoExecute = useCallback((command: SlashCommand): boolean => {
const type = command.metadata?.type as string | undefined;
const hasArgHint = Boolean(command.metadata?.argumentHint);
return !hasArgHint && (type === 'skill' || type === 'bundled-skill');
}, []);

const autoExecuteCommand = useCallback(
(command: SlashCommand) => {
trackCommandUsage(command);
resetCommandMenuState();
const commandText = command.name;
setInput(commandText);
if (externalInputValueRef) {
externalInputValueRef.current = commandText;
}
setTimeout(() => {
if (externalHandleSubmitRef?.current) {
externalHandleSubmitRef.current({ preventDefault: () => {} });
}
}, 0);
},
[trackCommandUsage, resetCommandMenuState, setInput, externalInputValueRef, externalHandleSubmitRef],
);

// Insert the picked command name into the textarea and leave the caret right
// after `<command> `. We DO NOT auto-submit — the user reviews/edits args
// and presses Enter themselves, mirroring how the TUI behaves and avoiding
// surprise sends (e.g. /add-project with no path runs blindly).
// surprise sends from slash suggestions that still need arguments.
//
// The replacement spans from the active `/` to the next whitespace so a
// partial query like `hello /skill_inst` becomes `hello /skill_install ` and
Expand All @@ -282,6 +318,9 @@ export function useSlashCommands({
const newInput = `${head}${textAfterQuery}`;

setInput(newInput);
if (externalInputValueRef) {
externalInputValueRef.current = newInput;
}
resetCommandMenuState();

// Defer focus + caret placement until after React commits the new input
Expand All @@ -298,18 +337,15 @@ export function useSlashCommands({
}
}, 0);
},
[input, slashPosition, setInput, resetCommandMenuState, textareaRef],
[externalInputValueRef, input, slashPosition, setInput, resetCommandMenuState, textareaRef],
);

const selectCommandFromKeyboard = useCallback(
(command: SlashCommand) => {
if (shouldAutoExecute(command)) {
autoExecuteCommand(command);
return;
}
trackCommandUsage(command);
insertCommandIntoInput(command);
},
[shouldAutoExecute, autoExecuteCommand, insertCommandIntoInput],
[trackCommandUsage, insertCommandIntoInput],
);

const handleCommandSelect = useCallback(
Expand All @@ -324,13 +360,9 @@ export function useSlashCommands({
}

trackCommandUsage(command);
if (shouldAutoExecute(command)) {
autoExecuteCommand(command);
return;
}
insertCommandIntoInput(command);
},
[selectedProject, trackCommandUsage, shouldAutoExecute, autoExecuteCommand, insertCommandIntoInput],
[selectedProject, trackCommandUsage, insertCommandIntoInput],
);

const handleToggleCommandMenu = useCallback(() => {
Expand Down Expand Up @@ -382,7 +414,7 @@ export function useSlashCommands({
return false;
}

if (!filteredCommands.length) {
if (!displayedCommands.length) {
if (event.key === 'Escape') {
event.preventDefault();
resetCommandMenuState();
Expand All @@ -394,15 +426,15 @@ export function useSlashCommands({
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedCommandIndex((previousIndex) =>
previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
previousIndex < displayedCommands.length - 1 ? previousIndex + 1 : 0,
);
return true;
}

if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedCommandIndex((previousIndex) =>
previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
previousIndex > 0 ? previousIndex - 1 : displayedCommands.length - 1,
);
return true;
}
Expand All @@ -413,9 +445,9 @@ export function useSlashCommands({
}
event.preventDefault();
if (selectedCommandIndex >= 0) {
selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
} else if (filteredCommands.length > 0) {
selectCommandFromKeyboard(filteredCommands[0]);
selectCommandFromKeyboard(displayedCommands[selectedCommandIndex]);
} else if (displayedCommands.length > 0) {
selectCommandFromKeyboard(displayedCommands[0]);
}
return true;
}
Expand All @@ -428,7 +460,14 @@ export function useSlashCommands({

return false;
},
[showCommandMenu, filteredCommands, dismissCommandMenu, selectCommandFromKeyboard, selectedCommandIndex],
[
showCommandMenu,
displayedCommands,
resetCommandMenuState,
dismissCommandMenu,
selectCommandFromKeyboard,
selectedCommandIndex,
],
);

useEffect(
Expand All @@ -441,7 +480,7 @@ export function useSlashCommands({
return {
slashCommands,
slashCommandsCount: slashCommands.length,
filteredCommands,
filteredCommands: displayedCommands,
frequentCommands,
commandQuery,
showCommandMenu,
Expand Down
Loading