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 &&
}
+
+
);
}
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 (
+
+
+
+
+ {state.info.commandName}
+
+
+ {state.info.description && (
+
+ {t('skillInfoDialog.fields.description')}
+ {state.info.description}
+
+ )}
+
+ {state.info.compatibility && (
+
+ {t('skillInfoDialog.fields.compatibility')}
+ {state.info.compatibility}
+
+ )}
+
+ {state.info.argumentHint && (
+
+ {t('skillInfoDialog.fields.argumentHint')}
+
+ {state.info.argumentHint}
+
+
+ )}
+
+ {state.info.allowedTools && state.info.allowedTools.length > 0 && (
+
+ {t('skillInfoDialog.fields.allowedTools')}
+ {state.info.allowedTools.join(', ')}
+
+ )}
+
+ {metadataText && (
+
+
+ {t('skillInfoDialog.fields.metadata')}
+
+
+ {metadataText}
+
+
+ )}
+
+ {state.mode === 'menu-mobile' && (
+
+
+
+ )}
+
+
+ {state.mode === 'token-touch' && onClear && (
+
+ )}
+
+ {state.mode === 'menu-mobile' && onUsageApply && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/SkillInfoTooltip.tsx b/src/components/chat/view/subcomponents/SkillInfoTooltip.tsx
new file mode 100644
index 000000000..6bfe8c2f6
--- /dev/null
+++ b/src/components/chat/view/subcomponents/SkillInfoTooltip.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+type SkillInfoTooltipProps = {
+ info: {
+ commandName: string;
+ description?: string;
+ compatibility?: string;
+ metadata?: Record;
+ argumentHint?: string;
+ allowedTools?: string[];
+ };
+};
+
+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 SkillInfoTooltip({ info }: SkillInfoTooltipProps) {
+ const { t } = useTranslation('chat');
+ const metadataText = formatMetadata(info.metadata);
+
+ return (
+
+
{info.commandName}
+
+ {info.description && (
+
+ {t('skillInfoDialog.fields.description')}
+ {info.description}
+
+ )}
+
+ {info.compatibility && (
+
+ {t('skillInfoDialog.fields.compatibility')}
+ {info.compatibility}
+
+ )}
+
+ {info.argumentHint && (
+
+ {t('skillInfoDialog.fields.argumentHint')}
+
+ {info.argumentHint}
+
+
+ )}
+
+ {info.allowedTools && info.allowedTools.length > 0 && (
+
+ {t('skillInfoDialog.fields.allowedTools')}
+ {info.allowedTools.join(', ')}
+
+ )}
+
+ {metadataText && (
+
+
{t('skillInfoDialog.fields.metadata')}
+
event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ >
+ {metadataText}
+
+
+ )}
+
+ );
+}
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index dc2b31091..cc988daa0 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -117,6 +117,7 @@
}
},
"input": {
+ "dropImagesHere": "Drop images here",
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
"placeholderDefault": "Type your message...",
"disabled": "Input disabled",
@@ -238,5 +239,39 @@
},
"tasks": {
"nextTaskPrompt": "Start the next task"
+ },
+ "commandMenu": {
+ "noCommands": "No commands available",
+ "viewSkillInfo": "View skill info",
+ "aria": {
+ "availableCommands": "Available commands"
+ },
+ "namespace": {
+ "frequent": "Frequently Used",
+ "builtin": "Built-in Commands",
+ "project": "Project Commands",
+ "user": "User Commands",
+ "skills": "Skills",
+ "other": "Other Commands"
+ }
+ },
+ "skillInfoDialog": {
+ "closeAriaLabel": "Close skill info",
+ "fields": {
+ "description": "description:",
+ "compatibility": "compatibility:",
+ "argumentHint": "argument-hint:",
+ "allowedTools": "allowed-tools:",
+ "metadata": "metadata:"
+ },
+ "usage": {
+ "label": "usage:",
+ "placeholder": "Arguments for this skill"
+ },
+ "actions": {
+ "clear": "Clear",
+ "usage": "Usage",
+ "close": "Close"
+ }
}
}
\ No newline at end of file
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 617008a2a..963ccd2c7 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -72,6 +72,12 @@
"draggingStatus": "Dragging...",
"toggleAndMove": "Click to toggle, drag to move"
},
+ "reloadSkills": {
+ "label": "Reload skills",
+ "description": "Refresh provider skills",
+ "loading": "Reloading...",
+ "title": "Reload provider skills"
+ },
"whisper": {
"modes": {
"default": "Default Mode",
diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json
index 2c3dda4d6..a137cbcc7 100644
--- a/src/i18n/locales/ja/chat.json
+++ b/src/i18n/locales/ja/chat.json
@@ -98,6 +98,7 @@
"technicalDetails": "技術的な詳細"
},
"input": {
+ "dropImagesHere": "ここに画像をドロップ",
"placeholder": "/ でコマンド、@ でファイル指定、または {{provider}} に何でも聞いてください...",
"placeholderDefault": "メッセージを入力...",
"disabled": "入力無効",
@@ -205,5 +206,39 @@
"runCommand": "{{projectName}}で{{command}}を実行",
"startCli": "{{projectName}}でClaude CLIを起動しています",
"defaultCommand": "コマンド"
+ },
+ "commandMenu": {
+ "noCommands": "利用可能なコマンドがありません",
+ "viewSkillInfo": "スキル情報を表示",
+ "aria": {
+ "availableCommands": "利用可能なコマンド"
+ },
+ "namespace": {
+ "frequent": "よく使うコマンド",
+ "builtin": "組み込みコマンド",
+ "project": "プロジェクトコマンド",
+ "user": "ユーザーコマンド",
+ "skills": "スキル",
+ "other": "その他のコマンド"
+ }
+ },
+ "skillInfoDialog": {
+ "closeAriaLabel": "スキル情報を閉じる",
+ "fields": {
+ "description": "description:",
+ "compatibility": "compatibility:",
+ "argumentHint": "argument-hint:",
+ "allowedTools": "allowed-tools:",
+ "metadata": "metadata:"
+ },
+ "usage": {
+ "label": "usage:",
+ "placeholder": "このスキルの引数"
+ },
+ "actions": {
+ "clear": "クリア",
+ "usage": "Usage",
+ "close": "閉じる"
+ }
}
}
diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json
index 05b944082..3eb19592b 100644
--- a/src/i18n/locales/ja/settings.json
+++ b/src/i18n/locales/ja/settings.json
@@ -72,6 +72,12 @@
"draggingStatus": "ドラッグ中...",
"toggleAndMove": "クリックで切替、ドラッグで移動"
},
+ "reloadSkills": {
+ "label": "スキルを再読み込み",
+ "description": "プロバイダーのスキルを更新",
+ "loading": "再読み込み中...",
+ "title": "プロバイダーのスキルを再読み込み"
+ },
"whisper": {
"modes": {
"default": "標準モード",
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index ca66e3a95..8681ab8f1 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -99,6 +99,7 @@
"technicalDetails": "기술 상세"
},
"input": {
+ "dropImagesHere": "여기에 이미지를 드롭하세요",
"placeholder": "/를 입력하여 명령어, @를 입력하여 파일, 또는 {{provider}}에게 무엇이든 물어보세요...",
"placeholderDefault": "메시지를 입력하세요...",
"disabled": "입력 비활성화",
@@ -220,5 +221,39 @@
},
"tasks": {
"nextTaskPrompt": "다음 작업 시작"
+ },
+ "commandMenu": {
+ "noCommands": "사용 가능한 명령어가 없습니다",
+ "viewSkillInfo": "스킬 정보 보기",
+ "aria": {
+ "availableCommands": "사용 가능한 명령어"
+ },
+ "namespace": {
+ "frequent": "자주 사용하는 명령어",
+ "builtin": "내장 명령어",
+ "project": "프로젝트 명령어",
+ "user": "사용자 명령어",
+ "skills": "스킬",
+ "other": "기타 명령어"
+ }
+ },
+ "skillInfoDialog": {
+ "closeAriaLabel": "스킬 정보 닫기",
+ "fields": {
+ "description": "description:",
+ "compatibility": "compatibility:",
+ "argumentHint": "argument-hint:",
+ "allowedTools": "allowed-tools:",
+ "metadata": "metadata:"
+ },
+ "usage": {
+ "label": "usage:",
+ "placeholder": "이 스킬에 대한 인수"
+ },
+ "actions": {
+ "clear": "Clear",
+ "usage": "Usage",
+ "close": "Close"
+ }
}
}
\ No newline at end of file
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index bc1074c36..418837663 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -72,6 +72,12 @@
"draggingStatus": "드래그 중...",
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
},
+ "reloadSkills": {
+ "label": "스킬 다시 불러오기",
+ "description": "프로바이더 스킬 새로고침",
+ "loading": "다시 불러오는 중...",
+ "title": "프로바이더 스킬 다시 불러오기"
+ },
"whisper": {
"modes": {
"default": "기본 모드",
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index 1fdbc462f..ff476a0ee 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -99,6 +99,7 @@
"technicalDetails": "技术细节"
},
"input": {
+ "dropImagesHere": "将图片拖放到此处",
"placeholder": "输入 / 调用命令,@ 选择文件,或向 {{provider}} 提问...",
"placeholderDefault": "输入您的消息...",
"disabled": "输入已禁用",
@@ -220,5 +221,39 @@
},
"tasks": {
"nextTaskPrompt": "开始下一个任务"
+ },
+ "commandMenu": {
+ "noCommands": "没有可用命令",
+ "viewSkillInfo": "查看技能信息",
+ "aria": {
+ "availableCommands": "可用命令"
+ },
+ "namespace": {
+ "frequent": "常用命令",
+ "builtin": "内置命令",
+ "project": "项目命令",
+ "user": "用户命令",
+ "skills": "技能",
+ "other": "其他命令"
+ }
+ },
+ "skillInfoDialog": {
+ "closeAriaLabel": "关闭技能信息",
+ "fields": {
+ "description": "description:",
+ "compatibility": "compatibility:",
+ "argumentHint": "argument-hint:",
+ "allowedTools": "allowed-tools:",
+ "metadata": "metadata:"
+ },
+ "usage": {
+ "label": "usage:",
+ "placeholder": "该技能的参数"
+ },
+ "actions": {
+ "clear": "清除",
+ "usage": "用法",
+ "close": "关闭"
+ }
}
}
\ No newline at end of file
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index fc9105068..8d41eacad 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -72,6 +72,12 @@
"draggingStatus": "正在拖拽...",
"toggleAndMove": "点击切换,拖拽移动"
},
+ "reloadSkills": {
+ "label": "重新加载技能",
+ "description": "刷新提供方技能",
+ "loading": "重新加载中...",
+ "title": "重新加载提供方技能"
+ },
"whisper": {
"modes": {
"default": "默认模式",