mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-07 05:45:39 +08:00
* feat(providers): surface skills in slash command menu Provider skills were hidden behind provider-specific filesystem rules. That made the backend and UI unable to offer one discovery path for skills. Add a normalized skills contract, provider service, and provider skills API. Keep provider-specific lookup rules inside adapters so routes and UI stay generic. Claude needs plugin handling because enabled plugins resolve through installed_plugins.json. Plugin folders can expose commands or skills, so Claude scans both forms. Claude plugin commands are namespaced to avoid collisions with user and project skills. Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract. The slash menu now shows skills beside built-in and custom commands for discovery. The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap. Provider tests cover discovery locations and Claude plugin edge cases. * fix(providers): guard invalid skill command namespaces Claude plugin ids come from local settings and installed plugin metadata. Invalid ids such as empty strings or @ should not become command namespaces. Skip plugin folders when no safe plugin name can be derived. This prevents malformed slash commands like /:command from reaching the UI. Add regression coverage for empty and @ plugin ids. Keyboard selection in the slash menu should match mouse selection. Only skills are inserted into the composer because they are provider invocations. Built-in and custom commands execute directly and close the menu on success or failure. * fix(security): centralize safe frontmatter parsing Move frontmatter parsing into server/shared/frontmatter.ts so every backend caller uses the same gray-matter configuration instead of importing gray-matter directly. The goal is to keep executable JS and JSON frontmatter engines disabled for all markdown discovered from the filesystem, not only command routes. Provider skills and shared skill metadata now go through parseFrontMatter too. That closes the gap where plugin or provider markdown could regain default gray-matter behavior simply because it lived outside the original command path. Classify the new parser in backend boundaries so modules can depend on the safe shared API without reaching into legacy utility paths. * feat(providers): add comprehensive guide for provider module setup and usage
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
|
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import { safeLocalStorage } from '../utils/chatStorage';
|
|
import type { LLMProvider, Project } from '../../../types/app';
|
|
|
|
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
|
|
|
export interface SlashCommand {
|
|
name: string;
|
|
description?: string;
|
|
namespace?: string;
|
|
path?: string;
|
|
type?: 'built-in' | 'custom' | 'skill' | string;
|
|
metadata?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface UseSlashCommandsOptions {
|
|
selectedProject: Project | null;
|
|
provider: LLMProvider;
|
|
input: string;
|
|
setInput: Dispatch<SetStateAction<string>>;
|
|
textareaRef: RefObject<HTMLTextAreaElement>;
|
|
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
|
}
|
|
|
|
type ProviderSkill = {
|
|
name: string;
|
|
description?: string;
|
|
command: string;
|
|
scope: string;
|
|
sourcePath?: string;
|
|
pluginName?: string;
|
|
pluginId?: string;
|
|
};
|
|
|
|
type ProviderSkillsResponse = {
|
|
success?: boolean;
|
|
data?: {
|
|
skills?: ProviderSkill[];
|
|
};
|
|
};
|
|
|
|
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';
|
|
|
|
const isSkillCommand = (command: SlashCommand) =>
|
|
command.type === 'skill' || command.metadata?.type === 'skill';
|
|
|
|
const dedupeProviderSkills = (skills: ProviderSkill[]): ProviderSkill[] => {
|
|
const seenCommands = new Set<string>();
|
|
|
|
return skills.filter((skill) => {
|
|
// Multiple physical Claude plugin folders can expose the same invocation.
|
|
// The slash menu should show each executable command only once.
|
|
const key = skill.command;
|
|
if (seenCommands.has(key)) {
|
|
return false;
|
|
}
|
|
|
|
seenCommands.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const mapSkillToSlashCommand = (skill: ProviderSkill): SlashCommand => ({
|
|
name: skill.command,
|
|
description: skill.description,
|
|
namespace: 'skill',
|
|
path: skill.sourcePath,
|
|
type: 'skill',
|
|
metadata: {
|
|
type: skill.scope,
|
|
scope: skill.scope,
|
|
sourcePath: skill.sourcePath,
|
|
pluginName: skill.pluginName,
|
|
pluginId: skill.pluginId,
|
|
skillName: skill.name,
|
|
},
|
|
});
|
|
|
|
const filterSlashCommands = (
|
|
commands: SlashCommand[],
|
|
query: string,
|
|
): SlashCommand[] => {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (!normalizedQuery) {
|
|
return commands;
|
|
}
|
|
|
|
const commandPrefix = normalizedQuery.startsWith('/')
|
|
? normalizedQuery
|
|
: `/${normalizedQuery}`;
|
|
const namePrefixMatches = commands.filter((command) =>
|
|
command.name.toLowerCase().startsWith(commandPrefix),
|
|
);
|
|
|
|
// Namespaced commands should behave like path completion. Once a provider
|
|
// namespace is typed, only exact command-prefix matches should stay visible.
|
|
if (normalizedQuery.includes(':') || namePrefixMatches.length > 0) {
|
|
return namePrefixMatches;
|
|
}
|
|
|
|
const nameSubstringMatches = commands.filter((command) =>
|
|
command.name.toLowerCase().includes(normalizedQuery),
|
|
);
|
|
if (nameSubstringMatches.length > 0) {
|
|
return nameSubstringMatches;
|
|
}
|
|
|
|
return commands.filter((command) =>
|
|
command.description?.toLowerCase().includes(normalizedQuery),
|
|
);
|
|
};
|
|
|
|
export function useSlashCommands({
|
|
selectedProject,
|
|
provider,
|
|
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(() => {
|
|
let cancelled = false;
|
|
|
|
const fetchCommands = async () => {
|
|
if (!selectedProject) {
|
|
setSlashCommands([]);
|
|
setFilteredCommands([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const workspacePath = selectedProject.fullPath || selectedProject.path || '';
|
|
const response = await authenticatedFetch('/api/commands/list', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
projectPath: workspacePath || selectedProject.path,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch commands');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const skillsParams = new URLSearchParams();
|
|
if (workspacePath) {
|
|
skillsParams.set('workspacePath', workspacePath);
|
|
}
|
|
|
|
const skillsResponse = await authenticatedFetch(
|
|
`/api/providers/${encodeURIComponent(provider)}/skills${skillsParams.toString() ? `?${skillsParams.toString()}` : ''}`,
|
|
);
|
|
const skillsData = skillsResponse.ok
|
|
? ((await skillsResponse.json()) as ProviderSkillsResponse)
|
|
: null;
|
|
const skillCommands = dedupeProviderSkills(skillsData?.data?.skills || [])
|
|
.map(mapSkillToSlashCommand);
|
|
const allCommands: SlashCommand[] = [
|
|
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
|
...command,
|
|
type: 'built-in',
|
|
})),
|
|
...skillCommands,
|
|
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
|
...command,
|
|
type: 'custom',
|
|
})),
|
|
];
|
|
|
|
const parsedHistory = readCommandHistory(selectedProject.projectId);
|
|
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
|
|
const commandAUsage = parsedHistory[commandA.name] || 0;
|
|
const commandBUsage = parsedHistory[commandB.name] || 0;
|
|
return commandBUsage - commandAUsage;
|
|
});
|
|
|
|
if (!cancelled) {
|
|
setSlashCommands(sortedCommands);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching slash commands:', error);
|
|
if (!cancelled) {
|
|
setSlashCommands([]);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchCommands();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedProject, provider]);
|
|
|
|
useEffect(() => {
|
|
if (!showCommandMenu) {
|
|
setSelectedCommandIndex(-1);
|
|
}
|
|
}, [showCommandMenu]);
|
|
|
|
useEffect(() => {
|
|
setFilteredCommands(filterSlashCommands(slashCommands, commandQuery));
|
|
}, [commandQuery, slashCommands]);
|
|
|
|
const frequentCommands = useMemo(() => {
|
|
if (!selectedProject || slashCommands.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const parsedHistory = readCommandHistory(selectedProject.projectId);
|
|
|
|
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.projectId);
|
|
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
|
saveCommandHistory(selectedProject.projectId, parsedHistory);
|
|
},
|
|
[selectedProject],
|
|
);
|
|
|
|
const insertCommandIntoInput = useCallback(
|
|
(command: SlashCommand) => {
|
|
const currentTextarea = textareaRef.current;
|
|
const insertionStart = slashPosition >= 0
|
|
? slashPosition
|
|
: currentTextarea?.selectionStart ?? input.length;
|
|
const textBeforeCommand = input.slice(0, insertionStart);
|
|
const textAfterCommandStart = input.slice(insertionStart);
|
|
const spaceIndex = textAfterCommandStart.indexOf(' ');
|
|
const textAfterCommand = slashPosition >= 0 && spaceIndex !== -1
|
|
? textAfterCommandStart.slice(spaceIndex).trimStart()
|
|
: input.slice(currentTextarea?.selectionEnd ?? insertionStart);
|
|
const separator = textBeforeCommand && !/\s$/.test(textBeforeCommand) ? ' ' : '';
|
|
const newInput = `${textBeforeCommand}${separator}${command.name}${textAfterCommand ? ` ${textAfterCommand}` : ' '}`;
|
|
|
|
setInput(newInput);
|
|
resetCommandMenuState();
|
|
|
|
window.requestAnimationFrame(() => {
|
|
currentTextarea?.focus();
|
|
const nextCursorPosition = `${textBeforeCommand}${separator}${command.name} `.length;
|
|
currentTextarea?.setSelectionRange(nextCursorPosition, nextCursorPosition);
|
|
});
|
|
},
|
|
[input, resetCommandMenuState, setInput, slashPosition, textareaRef],
|
|
);
|
|
|
|
const executeNonSkillCommand = useCallback(
|
|
(command: SlashCommand) => {
|
|
const executionResult = onExecuteCommand(command);
|
|
if (isPromiseLike(executionResult)) {
|
|
executionResult.then(
|
|
() => {
|
|
resetCommandMenuState();
|
|
},
|
|
() => {
|
|
resetCommandMenuState();
|
|
// Keep behavior silent; execution errors are handled by caller.
|
|
},
|
|
);
|
|
} else {
|
|
resetCommandMenuState();
|
|
}
|
|
},
|
|
[onExecuteCommand, resetCommandMenuState],
|
|
);
|
|
|
|
const selectCommandFromKeyboard = useCallback(
|
|
(command: SlashCommand) => {
|
|
if (isSkillCommand(command)) {
|
|
insertCommandIntoInput(command);
|
|
return;
|
|
}
|
|
|
|
executeNonSkillCommand(command);
|
|
},
|
|
[executeNonSkillCommand, insertCommandIntoInput],
|
|
);
|
|
|
|
const handleCommandSelect = useCallback(
|
|
(command: SlashCommand | null, index: number, isHover: boolean) => {
|
|
if (!command || !selectedProject) {
|
|
return;
|
|
}
|
|
|
|
if (isHover) {
|
|
setSelectedCommandIndex(index);
|
|
return;
|
|
}
|
|
|
|
trackCommandUsage(command);
|
|
if (isSkillCommand(command)) {
|
|
insertCommandIntoInput(command);
|
|
return;
|
|
}
|
|
|
|
executeNonSkillCommand(command);
|
|
},
|
|
[selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand],
|
|
);
|
|
|
|
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*)$/;
|
|
const match = textBeforeCursor.match(slashPattern);
|
|
|
|
if (!match) {
|
|
resetCommandMenuState();
|
|
return;
|
|
}
|
|
|
|
const slashPos = 0;
|
|
const query = match[1];
|
|
|
|
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,
|
|
};
|
|
}
|