diff --git a/src-tauri/src/commands/experts.rs b/src-tauri/src/commands/experts.rs index a083077..bdfb040 100644 --- a/src-tauri/src/commands/experts.rs +++ b/src-tauri/src/commands/experts.rs @@ -682,6 +682,47 @@ pub async fn experts_list() -> Result, ExpertsError> { Ok(out) } +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn experts_list_for_agent( + agent_type: AgentType, +) -> Result, ExpertsError> { + let _ = skill_storage_spec(agent_type) + .ok_or(ExpertsError::UnsupportedAgent(agent_type))?; + + let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None) + .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?; + + let meta_list = bundled_metadata().to_vec(); + let manifest = load_manifest(); + let mut out = Vec::new(); + + for meta in meta_list { + let central_path = expert_central_path(&meta.id); + let is_linked = dirs.iter().any(|dir| { + let candidate = dir.join(&meta.id); + classify_link(&candidate, ¢ral_path) == ExpertLinkState::LinkedToCodeg + }); + if !is_linked { + continue; + } + + let installed_centrally = central_path.exists(); + let user_modified = manifest + .experts + .get(&meta.id) + .map(|e| e.pending_user_review) + .unwrap_or(false); + + out.push(ExpertListItem { + metadata: meta, + installed_centrally, + user_modified, + central_path: central_path.to_string_lossy().to_string(), + }); + } + Ok(out) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_get_install_status( expert_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1de8e7..0f3c2da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -376,6 +376,7 @@ mod tauri_app { acp_commands::acp_save_agent_skill, acp_commands::acp_delete_agent_skill, experts_commands::experts_list, + experts_commands::experts_list_for_agent, experts_commands::experts_get_install_status, experts_commands::experts_link_to_agent, experts_commands::experts_unlink_from_agent, diff --git a/src-tauri/src/web/handlers/experts.rs b/src-tauri/src/web/handlers/experts.rs index 4c32b6e..960a5c2 100644 --- a/src-tauri/src/web/handlers/experts.rs +++ b/src-tauri/src/web/handlers/experts.rs @@ -12,6 +12,12 @@ pub struct ExpertIdParams { pub expert_id: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTypeOnlyParams { + pub agent_type: AgentType, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExpertAgentParams { @@ -26,6 +32,15 @@ pub async fn experts_list() -> Result>, AppCommandError Ok(Json(result)) } +pub async fn experts_list_for_agent( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = experts_commands::experts_list_for_agent(params.agent_type) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + pub async fn experts_get_install_status( Json(params): Json, ) -> Result>, AppCommandError> { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index e50d053..0e5bac9 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -177,6 +177,7 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/acp_delete_agent_skill", post(handlers::acp::acp_delete_agent_skill)) // ─── Experts ─── .route("/experts_list", post(handlers::experts::experts_list)) + .route("/experts_list_for_agent", post(handlers::experts::experts_list_for_agent)) .route("/experts_get_install_status", post(handlers::experts::experts_get_install_status)) .route("/experts_link_to_agent", post(handlers::experts::experts_link_to_agent)) .route("/experts_unlink_from_agent", post(handlers::experts::experts_unlink_from_agent)) diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 0fb8015..cfc1f97 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl" import type { + AgentType, ConnectionStatus, PromptCapabilitiesInfo, PromptDraft, @@ -29,6 +30,7 @@ interface ChatInputProps { selectedModeId?: string | null onModeChange?: (modeId: string) => void onConfigOptionChange?: (configId: string, valueId: string) => void + agentType?: AgentType | null availableCommands?: AvailableCommandInfo[] | null attachmentTabId?: string | null draftStorageKey?: string | null @@ -62,6 +64,7 @@ export function ChatInput({ selectedModeId, onModeChange, onConfigOptionChange, + agentType, availableCommands, attachmentTabId, draftStorageKey, @@ -113,6 +116,7 @@ export function ChatInput({ selectedModeId={selectedModeId} onModeChange={onModeChange} onConfigOptionChange={onConfigOptionChange} + agentType={agentType} availableCommands={availableCommands} attachmentTabId={attachmentTabId} draftStorageKey={draftStorageKey} diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index a4b3aec..ba9a248 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react" import type { + AgentType, ConnectionStatus, PromptCapabilitiesInfo, PromptDraft, @@ -38,6 +39,7 @@ interface ConversationShellProps { selectedModeId?: string | null onModeChange?: (modeId: string) => void onConfigOptionChange?: (configId: string, valueId: string) => void + agentType?: AgentType | null availableCommands?: AvailableCommandInfo[] | null attachmentTabId?: string | null draftStorageKey?: string | null @@ -78,6 +80,7 @@ export function ConversationShell({ selectedModeId, onModeChange, onConfigOptionChange, + agentType, availableCommands, attachmentTabId, draftStorageKey, @@ -123,6 +126,7 @@ export function ConversationShell({ selectedModeId={selectedModeId} onModeChange={onModeChange} onConfigOptionChange={onConfigOptionChange} + agentType={agentType} availableCommands={availableCommands} attachmentTabId={attachmentTabId} draftStorageKey={draftStorageKey} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 7197fea..a3e04e1 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -41,6 +41,7 @@ import { readFileBase64 } from "@/lib/api" import { openFileDialog } from "@/lib/platform" import { disposeTauriListener } from "@/lib/tauri-listener" import type { + AgentType, AvailableCommandInfo, ExpertListItem, PromptCapabilitiesInfo, @@ -65,6 +66,7 @@ import { FileMentionMenu } from "@/components/chat/file-mention-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" import { useFileTree } from "@/hooks/use-file-tree" import { useBuiltInExperts } from "@/hooks/use-built-in-experts" +import { useAgentExperts } from "@/hooks/use-agent-experts" import { joinFsPath } from "@/lib/path-utils" import { clearMessageInputDraft, @@ -89,6 +91,7 @@ interface MessageInputProps { selectedModeId?: string | null onModeChange?: (modeId: string) => void onConfigOptionChange?: (configId: string, valueId: string) => void + agentType?: AgentType | null availableCommands?: AvailableCommandInfo[] | null promptCapabilities: PromptCapabilitiesInfo attachmentTabId?: string | null @@ -294,6 +297,7 @@ export function MessageInput({ selectedModeId, onModeChange, onConfigOptionChange, + agentType, availableCommands, promptCapabilities, attachmentTabId, @@ -315,19 +319,10 @@ export function MessageInput({ () => new Set(builtInExperts.map((item) => item.metadata.id)), [builtInExperts] ) - // Derive the list of experts this specific agent session actually knows - // about. The backend advertises every enabled expert via its skill - // directory, so any expert whose id appears in `availableCommands` is - // guaranteed to be linked for the current agent. Using this intersection - // keeps the experts button in lockstep with what the agent will accept - // — an expert disabled in settings simply never reaches this dropdown. - const availableExperts = useMemo(() => { - if (!availableCommands || availableCommands.length === 0) return [] - const agentCommandNames = new Set(availableCommands.map((cmd) => cmd.name)) - return builtInExperts.filter((item) => - agentCommandNames.has(item.metadata.id) - ) - }, [availableCommands, builtInExperts]) + // Experts linked to the current agent via symlinks in the settings page. + // This is the single source of truth — no dependency on ACP availableCommands. + const availableExperts = useAgentExperts(agentType ?? null) + const expertPrefix = agentType === "codex" ? "$" : "/" // Stable presentation order for expert categories in the button // dropdown. Keep this in sync with experts-settings.tsx so both surfaces // group experts the same way. @@ -519,6 +514,15 @@ export function MessageInput({ () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), [availableCommands, expertIdSet] ) + // For Codex the menu triggers on both "/" (commands) and "$" (experts). + const menuTriggerRegex = useMemo( + () => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/), + [agentType] + ) + const expertPrefixRegex = useMemo( + () => (agentType === "codex" ? /^\$(\S*)$/ : /^\/(\S*)$/), + [agentType] + ) const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0) return [] const match = text.match(/^\/(\S*)$/) @@ -530,13 +534,13 @@ export function MessageInput({ }, [slashMenuOpen, slashCommands, text]) const filteredSlashExperts = useMemo(() => { if (!slashMenuOpen || availableExperts.length === 0) return [] - const match = text.match(/^\/(\S*)$/) + const match = text.match(expertPrefixRegex) if (!match) return [] const filter = match[1].toLowerCase() return availableExperts.filter((item) => item.metadata.id.toLowerCase().startsWith(filter) ) - }, [slashMenuOpen, availableExperts, text]) + }, [slashMenuOpen, availableExperts, text, expertPrefixRegex]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashExperts.length @@ -916,24 +920,27 @@ export function MessageInput({ const handleExpertAutocompleteSelect = useCallback( (expert: ExpertListItem) => { - setText(`/${expert.metadata.id} `) + setText(`${expertPrefix}${expert.metadata.id} `) setSlashMenuOpen(false) }, - [] + [expertPrefix] ) - // Experts always inject `/expert-id ` at the very front of the input, - // never at the cursor. The expert skill is a whole-turn directive that - // the agent inspects first, so prepending keeps semantics unambiguous + // Experts always inject `prefix + expert-id ` at the very front of the + // input, never at the cursor. The expert skill is a whole-turn directive + // that the agent inspects first, so prepending keeps semantics unambiguous // regardless of what the user has already typed. If another expert prefix // is already at the front (from a prior click), replace it instead of - // stacking — the agent only honors the first slash command, so a stacked - // prefix would silently drop the earlier choice. + // stacking — the agent only honors the first command, so a stacked prefix + // would silently drop the earlier choice. const handleExpertPopoverSelect = useCallback( (expert: ExpertListItem) => { const current = textRef.current - const insertion = `/${expert.metadata.id} ` - const existingPrefix = current.match(/^\/([A-Za-z0-9_-]+)\s/) + const insertion = `${expertPrefix}${expert.metadata.id} ` + const escapedPrefix = expertPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const existingPrefix = current.match( + new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`) + ) let base = current if (existingPrefix && expertIdSet.has(existingPrefix[1])) { base = current.slice(existingPrefix[0].length) @@ -951,7 +958,7 @@ export function MessageInput({ } }) }, - [expertIdSet] + [expertIdSet, expertPrefix] ) const atTriggerPosRef = useRef(atTriggerPos) @@ -994,7 +1001,7 @@ export function MessageInput({ // the menu whenever at least one of them is available. const hasSlashSource = slashCommands.length > 0 || availableExperts.length > 0 - if (hasSlashSource && /^\/(\S*)$/.test(value)) { + if (hasSlashSource && menuTriggerRegex.test(value)) { setSlashSelectedIndex(0) setSlashMenuOpen(true) setAtMenuOpen(false) @@ -1020,7 +1027,12 @@ export function MessageInput({ } setAtMenuOpen(false) }, - [slashCommands.length, availableExperts.length, defaultPath] + [ + slashCommands.length, + availableExperts.length, + defaultPath, + menuTriggerRegex, + ] ) const handlePickFiles = useCallback(async () => { @@ -1626,7 +1638,8 @@ export function MessageInput({
{name} - /{expert.metadata.id} + {expertPrefix} + {expert.metadata.id}
{description && ( @@ -1744,7 +1757,7 @@ export function MessageInput({