diff --git a/server/routes/commands.js b/server/routes/commands.js index 544673495..7f62c2abc 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -5,12 +5,57 @@ import { fileURLToPath } from 'url'; import os from 'os'; import matter from 'gray-matter'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; +import { loadSkillsList } from '../utils/loadSkillsList.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = express.Router(); +const providerToAgentMap = { + claude: 'claude-code', + cursor: 'cursor', + codex: 'codex', +}; + +const skillsCache = new Map(); + +function normalizeSkills(skills, provider, agent) { + return skills + .filter((skill) => skill && typeof skill === 'object' && typeof skill.name === 'string' && skill.name.trim().length > 0) + .map((skill) => ({ + name: skill.name.startsWith('/') ? skill.name : `/${skill.name}`, + description: typeof skill.description === 'string' ? skill.description : '', + namespace: 'skills', + type: 'skill', + metadata: { + ...skill, + skillName: skill.name, + provider, + agent, + type: 'skill', + }, + })); +} + +function getSkillsForProvider({ provider = 'claude', forceReloadSkills = false }) { + const mappedAgent = providerToAgentMap[provider] || providerToAgentMap.claude; + + if (!forceReloadSkills && skillsCache.has(mappedAgent)) { + return skillsCache.get(mappedAgent); + } + + try { + const skills = loadSkillsList(mappedAgent); + const normalizedSkills = normalizeSkills(skills, provider, mappedAgent); + skillsCache.set(mappedAgent, normalizedSkills); + return normalizedSkills; + } catch (error) { + console.error('Error loading skills list:', error); + return []; + } +} + /** * Recursively scan directory for command files (.md) * @param {string} dir - Directory to scan @@ -405,7 +450,12 @@ Custom commands can be created in: */ router.post('/list', async (req, res) => { try { - const { projectPath } = req.body; + const { + projectPath, + provider = 'claude', + includeSkills = true, + forceReloadSkills = false, + } = req.body; const allCommands = [...builtInCommands]; // Scan project-level commands (.claude/commands/) @@ -435,10 +485,15 @@ router.post('/list', async (req, res) => { // Sort commands alphabetically by name customCommands.sort((a, b) => a.name.localeCompare(b.name)); + const skills = includeSkills + ? getSkillsForProvider({ provider, forceReloadSkills }) + : []; + res.json({ builtIn: builtInCommands, custom: customCommands, - count: allCommands.length + skills, + count: allCommands.length + skills.length }); } catch (error) { console.error('Error listing commands:', error); diff --git a/server/utils/loadSkillsList.js b/server/utils/loadSkillsList.js new file mode 100644 index 000000000..697f1b8cc --- /dev/null +++ b/server/utils/loadSkillsList.js @@ -0,0 +1,462 @@ +import matter from 'gray-matter'; +import { homedir, platform } from 'os'; +import { join } from 'path'; +import { existsSync, readdirSync, readFileSync } from 'fs' + +/** + * Configs stolen from https://github.com/vercel-labs/skills/blob/main/src/agents.ts + */ +function findConfigHome() { + switch (platform()) { + case "win32": + case "darwin": + return join(home, '.config'); + } + + // Use xdg-basedir (not env-paths) to match OpenCode/Amp/Goose behavior on all platforms. + if (process.env.XDG_CONFIG_HOME) return process.env.XDG_CONFIG_HOME + + return join(home, '.config') +} + +const home = homedir(); +const configHome = findConfigHome(); +const codexHome = process.env.CODEX_HOME?.trim() || join(home, '.codex'); +const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude'); + +/** + * // from npm:skills/src/types.d.ts@AgentType + * @typedef {( + * 'claude-code' | + * 'codex' | + * 'cursor' + * )} AgentType + */ + +const agents = { + // amp: { + // name: 'amp', + // displayName: 'Amp', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(configHome, 'agents/skills'), + // detectInstalled: async () => { + // return existsSync(join(configHome, 'amp')); + // }, + // }, + // antigravity: { + // name: 'antigravity', + // displayName: 'Antigravity', + // skillsDir: '.agent/skills', + // globalSkillsDir: join(home, '.gemini/antigravity/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.gemini/antigravity')); + // }, + // }, + // augment: { + // name: 'augment', + // displayName: 'Augment', + // skillsDir: '.augment/skills', + // globalSkillsDir: join(home, '.augment/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.augment')); + // }, + // }, + 'claude-code': { + name: 'claude-code', + displayName: 'Claude Code', + skillsDir: '.claude/skills', + globalSkillsDir: join(claudeHome, 'skills'), + detectInstalled: async () => { + return existsSync(claudeHome); + }, + }, + // openclaw: { + // name: 'openclaw', + // displayName: 'OpenClaw', + // skillsDir: 'skills', + // globalSkillsDir: getOpenClawGlobalSkillsDir(), + // detectInstalled: async () => { + // return ( + // existsSync(join(home, '.openclaw')) || + // existsSync(join(home, '.clawdbot')) || + // existsSync(join(home, '.moltbot')) + // ); + // }, + // }, + // cline: { + // name: 'cline', + // displayName: 'Cline', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(home, '.agents', 'skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.cline')); + // }, + // }, + // codebuddy: { + // name: 'codebuddy', + // displayName: 'CodeBuddy', + // skillsDir: '.codebuddy/skills', + // globalSkillsDir: join(home, '.codebuddy/skills'), + // detectInstalled: async () => { + // return existsSync(join(process.cwd(), '.codebuddy')) || existsSync(join(home, '.codebuddy')); + // }, + // }, + codex: { + name: 'codex', + displayName: 'Codex', + skillsDir: '.agents/skills', + globalSkillsDir: join(codexHome, 'skills'), + detectInstalled: async () => { + return existsSync(codexHome) || existsSync('/etc/codex'); + }, + }, + // 'command-code': { + // name: 'command-code', + // displayName: 'Command Code', + // skillsDir: '.commandcode/skills', + // globalSkillsDir: join(home, '.commandcode/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.commandcode')); + // }, + // }, + // continue: { + // name: 'continue', + // displayName: 'Continue', + // skillsDir: '.continue/skills', + // globalSkillsDir: join(home, '.continue/skills'), + // detectInstalled: async () => { + // return existsSync(join(process.cwd(), '.continue')) || existsSync(join(home, '.continue')); + // }, + // }, + // cortex: { + // name: 'cortex', + // displayName: 'Cortex Code', + // skillsDir: '.cortex/skills', + // globalSkillsDir: join(home, '.snowflake/cortex/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.snowflake/cortex')); + // }, + // }, + // crush: { + // name: 'crush', + // displayName: 'Crush', + // skillsDir: '.crush/skills', + // globalSkillsDir: join(home, '.config/crush/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.config/crush')); + // }, + // }, + cursor: { + name: 'cursor', + displayName: 'Cursor', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.cursor/skills'), + detectInstalled: async () => { + return existsSync(join(home, '.cursor')); + }, + }, + // droid: { + // name: 'droid', + // displayName: 'Droid', + // skillsDir: '.factory/skills', + // globalSkillsDir: join(home, '.factory/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.factory')); + // }, + // }, + // 'gemini-cli': { + // name: 'gemini-cli', + // displayName: 'Gemini CLI', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(home, '.gemini/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.gemini')); + // }, + // }, + // 'github-copilot': { + // name: 'github-copilot', + // displayName: 'GitHub Copilot', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(home, '.copilot/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.copilot')); + // }, + // }, + // goose: { + // name: 'goose', + // displayName: 'Goose', + // skillsDir: '.goose/skills', + // globalSkillsDir: join(configHome, 'goose/skills'), + // detectInstalled: async () => { + // return existsSync(join(configHome, 'goose')); + // }, + // }, + // junie: { + // name: 'junie', + // displayName: 'Junie', + // skillsDir: '.junie/skills', + // globalSkillsDir: join(home, '.junie/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.junie')); + // }, + // }, + // 'iflow-cli': { + // name: 'iflow-cli', + // displayName: 'iFlow CLI', + // skillsDir: '.iflow/skills', + // globalSkillsDir: join(home, '.iflow/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.iflow')); + // }, + // }, + // kilo: { + // name: 'kilo', + // displayName: 'Kilo Code', + // skillsDir: '.kilocode/skills', + // globalSkillsDir: join(home, '.kilocode/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.kilocode')); + // }, + // }, + // 'kimi-cli': { + // name: 'kimi-cli', + // displayName: 'Kimi Code CLI', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(home, '.config/agents/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.kimi')); + // }, + // }, + // 'kiro-cli': { + // name: 'kiro-cli', + // displayName: 'Kiro CLI', + // skillsDir: '.kiro/skills', + // globalSkillsDir: join(home, '.kiro/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.kiro')); + // }, + // }, + // kode: { + // name: 'kode', + // displayName: 'Kode', + // skillsDir: '.kode/skills', + // globalSkillsDir: join(home, '.kode/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.kode')); + // }, + // }, + // mcpjam: { + // name: 'mcpjam', + // displayName: 'MCPJam', + // skillsDir: '.mcpjam/skills', + // globalSkillsDir: join(home, '.mcpjam/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.mcpjam')); + // }, + // }, + // 'mistral-vibe': { + // name: 'mistral-vibe', + // displayName: 'Mistral Vibe', + // skillsDir: '.vibe/skills', + // globalSkillsDir: join(home, '.vibe/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.vibe')); + // }, + // }, + // mux: { + // name: 'mux', + // displayName: 'Mux', + // skillsDir: '.mux/skills', + // globalSkillsDir: join(home, '.mux/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.mux')); + // }, + // }, + // opencode: { + // name: 'opencode', + // displayName: 'OpenCode', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(configHome, 'opencode/skills'), + // detectInstalled: async () => { + // return existsSync(join(configHome, 'opencode')); + // }, + // }, + // openhands: { + // name: 'openhands', + // displayName: 'OpenHands', + // skillsDir: '.openhands/skills', + // globalSkillsDir: join(home, '.openhands/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.openhands')); + // }, + // }, + // pi: { + // name: 'pi', + // displayName: 'Pi', + // skillsDir: '.pi/skills', + // globalSkillsDir: join(home, '.pi/agent/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.pi/agent')); + // }, + // }, + // qoder: { + // name: 'qoder', + // displayName: 'Qoder', + // skillsDir: '.qoder/skills', + // globalSkillsDir: join(home, '.qoder/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.qoder')); + // }, + // }, + // 'qwen-code': { + // name: 'qwen-code', + // displayName: 'Qwen Code', + // skillsDir: '.qwen/skills', + // globalSkillsDir: join(home, '.qwen/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.qwen')); + // }, + // }, + // replit: { + // name: 'replit', + // displayName: 'Replit', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(configHome, 'agents/skills'), + // showInUniversalList: false, + // detectInstalled: async () => { + // return existsSync(join(process.cwd(), '.replit')); + // }, + // }, + // roo: { + // name: 'roo', + // displayName: 'Roo Code', + // skillsDir: '.roo/skills', + // globalSkillsDir: join(home, '.roo/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.roo')); + // }, + // }, + // trae: { + // name: 'trae', + // displayName: 'Trae', + // skillsDir: '.trae/skills', + // globalSkillsDir: join(home, '.trae/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.trae')); + // }, + // }, + // 'trae-cn': { + // name: 'trae-cn', + // displayName: 'Trae CN', + // skillsDir: '.trae/skills', + // globalSkillsDir: join(home, '.trae-cn/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.trae-cn')); + // }, + // }, + // windsurf: { + // name: 'windsurf', + // displayName: 'Windsurf', + // skillsDir: '.windsurf/skills', + // globalSkillsDir: join(home, '.codeium/windsurf/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.codeium/windsurf')); + // }, + // }, + // zencoder: { + // name: 'zencoder', + // displayName: 'Zencoder', + // skillsDir: '.zencoder/skills', + // globalSkillsDir: join(home, '.zencoder/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.zencoder')); + // }, + // }, + // neovate: { + // name: 'neovate', + // displayName: 'Neovate', + // skillsDir: '.neovate/skills', + // globalSkillsDir: join(home, '.neovate/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.neovate')); + // }, + // }, + // pochi: { + // name: 'pochi', + // displayName: 'Pochi', + // skillsDir: '.pochi/skills', + // globalSkillsDir: join(home, '.pochi/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.pochi')); + // }, + // }, + // adal: { + // name: 'adal', + // displayName: 'AdaL', + // skillsDir: '.adal/skills', + // globalSkillsDir: join(home, '.adal/skills'), + // detectInstalled: async () => { + // return existsSync(join(home, '.adal')); + // }, + // }, + // universal: { + // name: 'universal', + // displayName: 'Universal', + // skillsDir: '.agents/skills', + // globalSkillsDir: join(configHome, 'agents/skills'), + // showInUniversalList: false, + // detectInstalled: async () => false, + // }, +}; + +/** @param {AgentType} agentName */ +export function loadSkillsList(agentName) { + const loadedSkills = []; + + const agentConfig = agents[agentName]; + if (!agentConfig) return loadedSkills; + + const skillsDirs = []; + if (agentConfig.globalSkillsDir) skillsDirs.push(agentConfig.globalSkillsDir); + if (agentConfig.skillsDir) skillsDirs.push(agentConfig.skillsDir); + + /** + * TODO: skipped + * Stolen from vercel-labs/skills + * - `skillsDir/` + * - `skillsDir/.curated/` + * - `skillsDir/.experimental/` + * - `skillsDir/.system/` + */ + const subSkillsDir = [ + '.', + // '.curated', + // '.experimental', + // '.system' + ]; + for (const skillsDir of skillsDirs) { + for (const subDir of subSkillsDir) { + const fullSkillsDir = join(skillsDir, subDir); + if (!existsSync(fullSkillsDir)) continue; + + for (const f of readdirSync(fullSkillsDir)) { + const skillDir = join(fullSkillsDir, f) + // find /SKILL.md files in skillDir + const skillFileMdPath = join(skillDir, 'SKILL.md'); + if (!existsSync(skillFileMdPath)) continue; + + const fileContents = readFileSync(skillFileMdPath, 'utf-8'); + const { data } = matter(fileContents); + if (!data.name) continue; + + // TODO: handle `.user-invocable`, `.disable-model-invocation`? + loadedSkills.push({ + ...data, + skillDir, + }) + } + } + } + + return loadedSkills; +} diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx index fc69d4926..6ec9f4178 100644 --- a/src/components/QuickSettingsPanel.jsx +++ b/src/components/QuickSettingsPanel.jsx @@ -13,7 +13,8 @@ import { Sparkles, FileText, Languages, - GripVertical + GripVertical, + RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import DarkModeToggle from './DarkModeToggle'; @@ -25,12 +26,13 @@ import LanguageSelector from './LanguageSelector'; import { useDeviceSettings } from '../hooks/useDeviceSettings'; -const QuickSettingsPanel = () => { +const QuickSettingsPanel = ({ provider, reloadSkillsForProvider }) => { const { t } = useTranslation('settings'); const [isOpen, setIsOpen] = useState(false); const [whisperMode, setWhisperMode] = useState(() => { return localStorage.getItem('whisperMode') || 'default'; }); + const [isReloadingSkills, setIsReloadingSkills] = useState(false); const { isDarkMode } = useTheme(); const { isMobile } = useDeviceSettings({ trackPWA: false }); @@ -201,6 +203,19 @@ const QuickSettingsPanel = () => { setIsOpen((previous) => !previous); }; + const handleReloadSkills = async () => { + if (isReloadingSkills || !reloadSkillsForProvider || !provider) { + return; + } + + setIsReloadingSkills(true); + try { + await reloadSkillsForProvider(provider); + } finally { + setTimeout(() => setIsReloadingSkills(false), 500); + } + }; + return ( <> {/* Pull Tab - Combined drag handle and toggle button */} @@ -312,6 +327,19 @@ const QuickSettingsPanel = () => { className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600" /> + + {/* View Options */}
@@ -355,7 +383,7 @@ const QuickSettingsPanel = () => { {/* Whisper Dictation Settings - HIDDEN */}

{t('quickSettings.sections.whisperDictation')}

- +
- + ); } diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 6ac150db7..b0947b614 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -4,7 +4,10 @@ import MicButton from '../../../mic-button/view/MicButton'; import ImageAttachment from './ImageAttachment'; import PermissionRequestsBanner from './PermissionRequestsBanner'; import ChatInputControls from './ChatInputControls'; +import SkillInfoTooltip from './SkillInfoTooltip'; +import SkillInfoDialog from './SkillInfoDialog'; import { useTranslation } from 'react-i18next'; +import type { ActiveSkillTooltip, SkillInfoDialogState } from '../../hooks/useChatComposerState'; import type { ChangeEvent, ClipboardEvent, @@ -77,7 +80,15 @@ interface ChatComposerProps { getInputProps: (...args: unknown[]) => Record; openImagePicker: () => void; inputHighlightRef: RefObject; - renderInputWithMentions: (text: string) => ReactNode; + renderInputWithSkillDecorations: (text: string) => ReactNode; + activeSkillTooltip: ActiveSkillTooltip; + skillInfoDialogState: SkillInfoDialogState; + onOpenSkillInfoFromMenu: (command: SlashCommand) => void; + onCloseSkillInfoDialog: () => void; + onClearSkillToken: () => void; + mobileSkillUsageText: string; + onSkillUsageTextChange: (value: string) => void; + onApplySkillUsage: () => void; textareaRef: RefObject; input: string; onInputChange: (event: ChangeEvent) => void; @@ -134,7 +145,15 @@ export default function ChatComposer({ getInputProps, openImagePicker, inputHighlightRef, - renderInputWithMentions, + renderInputWithSkillDecorations, + activeSkillTooltip, + skillInfoDialogState, + onOpenSkillInfoFromMenu, + onCloseSkillInfoDialog, + onClearSkillToken, + mobileSkillUsageText, + onSkillUsageTextChange, + onApplySkillUsage, textareaRef, input, onInputChange, @@ -206,6 +225,8 @@ export default function ChatComposer({
{!hasQuestionPanel &&
) => void} className="relative max-w-4xl mx-auto"> + {activeSkillTooltip && } + {isDragActive && (
@@ -217,7 +238,7 @@ export default function ChatComposer({ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> -

Drop images here

+

{t('input.dropImagesHere')}

)} @@ -269,6 +290,7 @@ export default function ChatComposer({ commands={filteredCommands} selectedIndex={selectedCommandIndex} onSelect={onCommandSelect} + onViewSkillInfo={onOpenSkillInfoFromMenu} onClose={onCloseCommandMenu} position={commandMenuPosition} isOpen={isCommandMenuOpen} @@ -282,9 +304,9 @@ export default function ChatComposer({ }`} > -
} + + ); } diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 4ca67bd96..1412c052f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -32,6 +32,7 @@ interface ChatMessagesPaneProps { isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; setInput: Dispatch>; + prefetchSkillsForProvider: (provider: SessionProvider) => Promise; isLoadingMoreMessages: boolean; hasMoreMessages: boolean; totalMessages: number; @@ -78,6 +79,7 @@ export default function ChatMessagesPane({ isTaskMasterInstalled, onShowAllTasks, setInput, + prefetchSkillsForProvider, isLoadingMoreMessages, hasMoreMessages, totalMessages, @@ -162,6 +164,7 @@ export default function ChatMessagesPane({ isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} setInput={setInput} + prefetchSkillsForProvider={prefetchSkillsForProvider} /> ) : ( <> diff --git a/src/components/chat/view/subcomponents/CommandMenu.tsx b/src/components/chat/view/subcomponents/CommandMenu.tsx index 92a598ea5..00c0811d9 100644 --- a/src/components/chat/view/subcomponents/CommandMenu.tsx +++ b/src/components/chat/view/subcomponents/CommandMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import type { CSSProperties } from 'react'; type CommandMenuCommand = { @@ -15,6 +16,7 @@ type CommandMenuProps = { commands?: CommandMenuCommand[]; selectedIndex?: number; onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void; + onViewSkillInfo?: (command: CommandMenuCommand) => void; onClose: () => void; position?: { top: number; left: number; bottom?: number }; isOpen?: boolean; @@ -31,12 +33,13 @@ const menuBaseStyle: CSSProperties = { transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out', }; -const namespaceLabels: Record = { - frequent: 'Frequently Used', - builtin: 'Built-in Commands', - project: 'Project Commands', - user: 'User Commands', - other: 'Other Commands', +const namespaceLabelKeys: Record = { + frequent: 'commandMenu.namespace.frequent', + builtin: 'commandMenu.namespace.builtin', + project: 'commandMenu.namespace.project', + user: 'commandMenu.namespace.user', + skills: 'commandMenu.namespace.skills', + other: 'commandMenu.namespace.other', }; const namespaceIcons: Record = { @@ -44,6 +47,7 @@ const namespaceIcons: Record = { builtin: '[B]', project: '[P]', user: '[U]', + skills: '[S]', other: '[O]', }; @@ -81,11 +85,13 @@ export default function CommandMenu({ commands = [], selectedIndex = -1, onSelect, + onViewSkillInfo, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [], }: CommandMenuProps) { + const { t } = useTranslation('chat'); const menuRef = useRef(null); const selectedItemRef = useRef(null); const menuPosition = getMenuPosition(position); @@ -159,7 +165,7 @@ export default function CommandMenu({ className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400" style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }} > - No commands available + {t('commandMenu.noCommands')} ); } @@ -168,7 +174,7 @@ export default function CommandMenu({
@@ -176,7 +182,7 @@ export default function CommandMenu({
{orderedNamespaces.length > 1 && (
- {namespaceLabels[namespace] || namespace} + {t(namespaceLabelKeys[namespace] || namespaceLabelKeys.other)}
)} @@ -184,6 +190,7 @@ export default function CommandMenu({ const commandKey = getCommandKey(command); const commandIndex = commandIndexByKey.get(commandKey) ?? -1; const isSelected = commandIndex === selectedIndex; + const isSkill = command.type === 'skill' || command.namespace === 'skills'; return (
onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)} + onTouchStart={() => { + if (onSelect && commandIndex >= 0) { + onSelect(command, commandIndex, true); + } + }} onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)} onMouseDown={(event) => event.preventDefault()} > @@ -213,7 +225,36 @@ export default function CommandMenu({
)}
- {isSelected && {'<-'}} +
+ {isSelected && {'<-'}} + {isSkill && onViewSkillInfo && ( + + )} +
); })} diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index e17b9a4b6..f0a10a5a0 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -24,6 +24,7 @@ interface ProviderSelectionEmptyStateProps { isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; setInput: React.Dispatch>; + prefetchSkillsForProvider: (provider: SessionProvider) => Promise; } type ProviderDef = { @@ -102,6 +103,7 @@ export default function ProviderSelectionEmptyState({ isTaskMasterInstalled, onShowAllTasks, setInput, + prefetchSkillsForProvider, }: ProviderSelectionEmptyStateProps) { const { t } = useTranslation('chat'); const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' }); @@ -109,6 +111,7 @@ export default function ProviderSelectionEmptyState({ const selectProvider = (next: SessionProvider) => { setProvider(next); localStorage.setItem('selected-provider', next); + void prefetchSkillsForProvider(next); setTimeout(() => textareaRef.current?.focus(), 100); }; diff --git a/src/components/chat/view/subcomponents/SkillInfoDialog.tsx b/src/components/chat/view/subcomponents/SkillInfoDialog.tsx new file mode 100644 index 000000000..01316ece9 --- /dev/null +++ b/src/components/chat/view/subcomponents/SkillInfoDialog.tsx @@ -0,0 +1,242 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +type SkillInfoDialogProps = { + state: + | { open: false } + | { + open: true; + mode: 'menu-mobile' | 'token-touch'; + info: { + commandName: string; + description?: string; + compatibility?: string; + metadata?: Record; + argumentHint?: string; + allowedTools?: string[]; + }; + usageText?: string; + }; + onClose: () => void; + onClear?: () => void; + onUsageChange?: (value: string) => void; + onUsageApply?: () => void; +}; + +const formatMetadata = (metadata?: Record): string | null => { + if (!metadata || Object.keys(metadata).length === 0) { + return null; + } + + const cleaned = { ...metadata }; + delete cleaned.description; + delete cleaned.compatibility; + delete cleaned['argument-hint']; + delete cleaned.argumentHint; + delete cleaned['allowed-tools']; + delete cleaned.allowedTools; + + if (Object.keys(cleaned).length === 0) { + return null; + } + + try { + return JSON.stringify(cleaned, null, 2); + } catch { + return null; + } +}; + +export default function SkillInfoDialog({ + state, + onClose, + onClear, + onUsageChange, + onUsageApply, +}: SkillInfoDialogProps) { + const { t } = useTranslation('chat'); + const dialogRef = useRef(null); + + useEffect(() => { + if (!state.open) { + return; + } + + const previouslyFocused = document.activeElement as HTMLElement | null; + + const focusableSelector = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + const getFocusableElements = () => + Array.from(dialogRef.current?.querySelectorAll(focusableSelector) ?? []); + + const focusFirst = () => { + const focusable = getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + return; + } + dialogRef.current?.focus(); + }; + + focusFirst(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + return; + } + + if (event.key !== 'Tab') { + return; + } + + const focusable = getFocusableElements(); + if (focusable.length === 0) { + event.preventDefault(); + dialogRef.current?.focus(); + return; + } + + const currentIndex = focusable.indexOf(document.activeElement as HTMLElement); + const lastIndex = focusable.length - 1; + + if (event.shiftKey) { + if (currentIndex <= 0) { + event.preventDefault(); + focusable[lastIndex].focus(); + } + return; + } + + if (currentIndex === -1 || currentIndex >= lastIndex) { + event.preventDefault(); + focusable[0].focus(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + previouslyFocused?.focus?.(); + }; + }, [state.open, onClose]); + + if (!state.open) { + return null; + } + + const metadataText = formatMetadata(state.info.metadata); + + return ( +
+