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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/apps/cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions src/apps/cli/src/modes/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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
? [
{
Expand Down Expand Up @@ -1943,6 +1949,47 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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(
Expand Down Expand Up @@ -2205,6 +2252,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

if (localSlashCommandsEnabled && /^\/reload-skills\s*$/i.test(message)) {
await submitReloadSkillsFromInput();
return;
}

if (localSlashCommandsEnabled && resolveTypedMcpPromptCommand(message)) {
await submitMcpPromptFromInput();
return;
Expand Down Expand Up @@ -2237,6 +2289,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
);
return;
}

if (localSlashCommandsEnabled && message.toLowerCase().startsWith('/reload-skills')) {
notificationService.warning(t('chatInput.reloadSkillsUsage'));
return;
}

if (messageCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) {
notificationService.error(
Expand Down Expand Up @@ -2304,6 +2361,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
submitInitFromInput,
submitDeepreviewFromInput,
submitMcpPromptFromInput,
submitReloadSkillsFromInput,
confirmPromptCacheGuardIfNeeded,
t,
resolveTypedMcpPromptCommand,
Expand Down Expand Up @@ -2414,6 +2472,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,10 @@
"deepreviewBusy": "当前会话已有审核正在进行,请停止或等待完成后再开始新的深度审核。",
"deepreviewNestedDisabled": "深度审核只能从主会话启动。",
"deepreviewThreadTitle": "深度审核",
"reloadSkillsAction": "重新加载技能",
"reloadSkillsUsage": "使用 /reload-skills 时不要带额外参数。",
"reloadSkillsDone": "技能已重新加载(当前可用 {{count}} 个)",
"reloadSkillsFailed": "技能重新加载失败",
"currentMode": "当前模式: {{mode}}",
"noMatchingMode": "没有匹配的模式",
"noMatchingCommand": "没有匹配的命令",
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,10 @@
"deepreviewBusy": "目前會話已有審核正在進行,請停止或等待完成後再開始新的深度審核。",
"deepreviewNestedDisabled": "深度審核只能從主會話啟動。",
"deepreviewThreadTitle": "深度審核",
"reloadSkillsAction": "重新載入技能",
"reloadSkillsUsage": "使用 /reload-skills 時不要帶額外參數。",
"reloadSkillsDone": "技能已重新載入(目前可用 {{count}} 個)",
"reloadSkillsFailed": "技能重新載入失敗",
"currentMode": "目前模式: {{mode}}",
"noMatchingMode": "沒有匹配的模式",
"noMatchingCommand": "沒有匹配的命令",
Expand Down
Loading