From fb7996c0337a3446a455d21b8e86db06ba845ea5 Mon Sep 17 00:00:00 2001 From: jarvis24young <749843026@qq.com> Date: Thu, 4 Jun 2026 09:32:01 +0800 Subject: [PATCH] feat: add /reload-skills slash command (CLI + Web UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Claude Code 2.1.152: a slash command that re-scans skill directories from disk without requiring a session restart. Both the CLI and the Web UI now expose the same command via a single shared capability that was already in place end-to-end: the Tauri RPC get_skill_configs(force_refresh: true) calls SkillRegistry::global().refresh() and returns the new view, and configAPI.getSkillConfigs({ forceRefresh: true }) wraps it on the TS side. The only thing missing was a user-facing entry point on both surfaces — this PR adds it. Branch: feat/reload-skills-command, rebased on top of origin/main (e8755651). Single commit containing the full implementation. --- CLI - Register /reload-skills in COMMAND_SPECS. - handle_command dispatches it to a new reload_skills_from_disk method that calls SkillRegistry::global().refresh() directly (no Tauri bridge needed in the CLI — it shares the same global registry as the desktop runtime) and shows a status line with the reloaded skill count. Not gated on is_processing: the cache swap is atomic and a held SkillInfo reference is not kept across the call. - reload_skills_from_disk takes rt_handle as a parameter and uses it via rt_handle.block_on, matching the convention used by every other async CLI helper in this file. Web UI - New entry in getFilteredActions: id='reload-skills', command= '/reload-skills'. Picker-routed. - selectSlashCommandAction sets the input to the bare command (no args); user presses Enter to dispatch. - New submitReloadSkillsFromInput function modeled on submitUsageFromInput: validates the bare command, clears the input, calls configAPI.getSkillConfigs with both forceRefresh: true AND workspacePath so project-level skills (in .bitfun/skills/, .cursor/skills/, etc.) are included in the count, and surfaces success / failure via the existing notificationService toasts. - New /reload-skills matcher in handleSendOrCancel wrapped in the localSlashCommandsEnabled guard (matching the existing /usage, /init, /DeepReview matcher pattern that landed on main while this branch was in review). - New 'startsWith' warning for the '/reload-skills foo' case so the message does not fall through to the agent as a regular user message. - i18n defaultValue fallbacks are kept as plain t('key') calls with no string-literal fallback — i18n audit governance (scripts/i18n-literal-fallback-baseline.json) treats literal defaultValue as anti-pattern because it silently masks missing translations. i18n (4 keys × 3 locales = 12 insertions): - chatInput.reloadSkillsAction — picker label - chatInput.reloadSkillsUsage — arg-validation warning - chatInput.reloadSkillsDone — success toast (with {{count}}) - chatInput.reloadSkillsFailed — failure toast title --- Verification: - cargo check -p bitfun-cli: 0 errors - npx tsc --noEmit: 0 errors - npx eslint src/flow_chat/components/ChatInput.tsx: 0 errors (incl. react-hooks/exhaustive-deps) - npx vitest run: 921/921 pass - node scripts/i18n-contract.test.mjs: 37/37 pass (incl. literal-fallback-baseline check) --- src/apps/cli/src/commands.rs | 4 ++ src/apps/cli/src/modes/chat.rs | 41 ++++++++++++ .../src/flow_chat/components/ChatInput.tsx | 63 +++++++++++++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 4 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 4 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 4 ++ 6 files changed, 120 insertions(+) diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 08dc35295..a35e41e66 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -44,6 +44,10 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ name: "/skills", description: "List and configure skills", }, + CommandSpec { + name: "/reload-skills", + description: "Re-scan skill directories without restarting", + }, CommandSpec { name: "/subagents", description: "List and configure subagents", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b51fb0280..3552d9631 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1731,6 +1731,9 @@ impl ChatMode { "/skills" => { self.show_skill_selector(chat_view, chat_state, rt_handle); } + "/reload-skills" => { + self.reload_skills_from_disk(chat_view, chat_state, rt_handle); + } "/subagents" => { self.show_subagent_selector(chat_view, chat_state, rt_handle); } @@ -2883,6 +2886,44 @@ impl ChatMode { chat_view.show_skill_menu(); } + /// Re-scan skill directories from disk and rebuild the registry cache. + /// + /// Mirrors Claude Code 2.1.152 `/reload-skills`. Safe to call at any + /// time — does not require `is_processing` to be false because the + /// registry swap is atomic and a held `SkillInfo` reference is not + /// kept across the call. + fn reload_skills_from_disk( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = SkillRegistry::global(); + let workspace = self.agent.workspace_path_buf(); + let outcome = tokio::task::block_in_place(|| { + // refresh() is the global re-scan entry point; the workspace + // arg of refresh_for_workspace is currently a no-op upstream, + // so we call refresh() directly and re-resolve the workspace + // count afterwards. + rt_handle.block_on(async { + registry.refresh().await; + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), None) + .await + }) + }); + + let count = outcome.len(); + chat_state.add_system_message(format!( + "Reloaded {} skill(s) from disk.", + count + )); + chat_view.set_status(Some(format!( + "Skills reloaded ({} available)", + count + ))); + } + fn show_available_skill_list( &self, chat_view: &mut ChatView, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index f29957f90..7609d5eec 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1443,6 +1443,12 @@ export const ChatInput: React.FC = ({ command: DEEP_REVIEW_SLASH_COMMAND, label: t('chatInput.deepreviewAction'), }, + { + kind: 'action' as const, + id: 'reload-skills', + command: '/reload-skills', + label: t('chatInput.reloadSkillsAction'), + }, ...(!derivedState?.isProcessing ? [ { @@ -1943,6 +1949,47 @@ export const ChatInput: React.FC = ({ threadGoalController, ]); + const submitReloadSkillsFromInput = useCallback(async () => { + const message = inputState.value.trim(); + if (!/^\/reload-skills\s*$/i.test(message)) { + notificationService.warning(t('chatInput.reloadSkillsUsage')); + return; + } + + dispatchInput({ type: 'CLEAR_VALUE' }); + setQueuedInput(null); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + + try { + // Re-fetch skill configs with forceRefresh=true. The Tauri command + // (skill_api.rs::get_skill_configs) calls SkillRegistry::global().refresh() + // before serializing the result, so this single call both refreshes + // the registry cache and returns the new view. Pass workspacePath so + // workspace-level skills (`.bitfun/skills/`, `.cursor/skills/`, etc.) + // are included in the count — without it, the registry falls back + // to user + built-in slots only and the toast would undercount. + const skills = await configAPI.getSkillConfigs({ + forceRefresh: true, + workspacePath: workspacePath || undefined, + }); + notificationService.success( + t('chatInput.reloadSkillsDone', { count: skills.length }), + { duration: 3000 } + ); + } catch (error) { + log.error('Failed to trigger /reload-skills', { error }); + dispatchInput({ type: 'ACTIVATE' }); + dispatchInput({ type: 'SET_VALUE', payload: message }); + notificationService.error( + error instanceof Error ? error.message : t('error.unknown'), + { + title: t('chatInput.reloadSkillsFailed'), + duration: 5000, + } + ); + } + }, [inputState.value, setQueuedInput, t, workspacePath]); + const submitDeepreviewFromInput = useCallback(async () => { if (!effectiveTargetSessionId || !effectiveTargetSession) { notificationService.error( @@ -2205,6 +2252,11 @@ export const ChatInput: React.FC = ({ return; } + if (localSlashCommandsEnabled && /^\/reload-skills\s*$/i.test(message)) { + await submitReloadSkillsFromInput(); + return; + } + if (localSlashCommandsEnabled && resolveTypedMcpPromptCommand(message)) { await submitMcpPromptFromInput(); return; @@ -2237,6 +2289,11 @@ export const ChatInput: React.FC = ({ ); return; } + + if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/reload-skills')) { + notificationService.warning(t('chatInput.reloadSkillsUsage')); + return; + } if (messageCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) { notificationService.error( @@ -2304,6 +2361,7 @@ export const ChatInput: React.FC = ({ submitInitFromInput, submitDeepreviewFromInput, submitMcpPromptFromInput, + submitReloadSkillsFromInput, confirmPromptCacheGuardIfNeeded, t, resolveTypedMcpPromptCommand, @@ -2414,6 +2472,11 @@ export const ChatInput: React.FC = ({ next = '/init'; } else if (actionId === 'deepreview') { next = `${DEEP_REVIEW_SLASH_COMMAND} `; + } else if (actionId === 'reload-skills') { + // /reload-skills takes no arguments. Setting the value to the bare + // command lets the user immediately press Enter to dispatch it + // (which is the same path /usage and /init use). + next = '/reload-skills'; } else { return; } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 1556634cc..94594852d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -595,6 +595,10 @@ "deepreviewBusy": "A review is already running for this session. Stop or finish it before starting another Deep Review.", "deepreviewNestedDisabled": "Deep Review can only be started from the main session.", "deepreviewThreadTitle": "Deep review", + "reloadSkillsAction": "Reload skills", + "reloadSkillsUsage": "Use /reload-skills without extra arguments.", + "reloadSkillsDone": "Reloaded skills ({{count}} available)", + "reloadSkillsFailed": "Failed to reload skills", "currentMode": "Current mode: {{mode}}", "noMatchingMode": "No matching mode", "noMatchingCommand": "No matching command", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 0b733536f..535e73cf6 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -589,6 +589,10 @@ "deepreviewBusy": "当前会话已有审核正在进行,请停止或等待完成后再开始新的深度审核。", "deepreviewNestedDisabled": "深度审核只能从主会话启动。", "deepreviewThreadTitle": "深度审核", + "reloadSkillsAction": "重新加载技能", + "reloadSkillsUsage": "使用 /reload-skills 时不要带额外参数。", + "reloadSkillsDone": "技能已重新加载(当前可用 {{count}} 个)", + "reloadSkillsFailed": "技能重新加载失败", "currentMode": "当前模式: {{mode}}", "noMatchingMode": "没有匹配的模式", "noMatchingCommand": "没有匹配的命令", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 853ca7fdd..91515c2da 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -589,6 +589,10 @@ "deepreviewBusy": "目前會話已有審核正在進行,請停止或等待完成後再開始新的深度審核。", "deepreviewNestedDisabled": "深度審核只能從主會話啟動。", "deepreviewThreadTitle": "深度審核", + "reloadSkillsAction": "重新載入技能", + "reloadSkillsUsage": "使用 /reload-skills 時不要帶額外參數。", + "reloadSkillsDone": "技能已重新載入(目前可用 {{count}} 個)", + "reloadSkillsFailed": "技能重新載入失敗", "currentMode": "目前模式: {{mode}}", "noMatchingMode": "沒有匹配的模式", "noMatchingCommand": "沒有匹配的命令",