diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx index 932176665..8ac608a70 100644 --- a/apps/dev-playground/client/src/routes/agent.route.tsx +++ b/apps/dev-playground/client/src/routes/agent.route.tsx @@ -159,6 +159,8 @@ function AgentRoute() { const [pendingApprovals, setPendingApprovals] = useState( [], ); + // Highlighted row in the `/skill` menu. + const [skillIndex, setSkillIndex] = useState(0); const decideApproval = useCallback( async (approvalId: string, decision: "approve" | "deny") => { @@ -191,8 +193,11 @@ function AgentRoute() { const agentConfig = getPluginClientConfig<{ agents?: string[]; defaultAgent?: string; + skills?: Record; }>("agents"); const hasAutocomplete = (agentConfig.agents ?? []).includes("autocomplete"); + // Skills visible to the selected agent, from the boot config. + const activeSkills = agentConfig.skills?.[agent] ?? []; const { suggestion, @@ -201,6 +206,28 @@ function AgentRoute() { clear: clearSuggestion, } = useAutocomplete(hasAutocomplete); + // Slash-command menu: when the input is a leading `/token` (no space yet), + // surface matching skills for the active agent. + const slashQuery = input.match(/^\/([^\s]*)$/)?.[1] ?? null; + const skillMatches = + slashQuery !== null && activeSkills.length > 0 + ? activeSkills.filter((s) => + s.name.toLowerCase().includes(slashQuery.toLowerCase()), + ) + : []; + const skillMenuOpen = skillMatches.length > 0; + + const pickSkill = (name: string) => { + setInput(`/${name} `); + clearSuggestion(); + inputRef.current?.focus(); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset highlight as the query changes + useEffect(() => { + setSkillIndex(0); + }, [input, agent]); + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -219,13 +246,25 @@ function AgentRoute() { setEvents([]); setIsLoading(true); + // `/skill-name …` forces a skill for this turn (the agents plugin injects + // its instructions); the model can still auto-load others via load_skill. + let messageBody = userMessage; + let skill: string | undefined; + const skillMatch = messageBody.match(/^\/([A-Za-z0-9][\w.:-]*)\s*/); + if (skillMatch) { + skill = skillMatch[1]; + messageBody = messageBody.slice(skillMatch[0].length); + if (messageBody.trim() === "") messageBody = `Use the ${skill} skill.`; + } + try { const response = await fetch("/api/agents/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - message: userMessage, + message: messageBody, agent, + ...(skill && { skill }), ...(threadId && { threadId }), }), }); @@ -507,6 +546,34 @@ function AgentRoute() { value={input} onChange={(e) => handleInputChange(e.target.value)} onKeyDown={(e) => { + if (skillMenuOpen) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSkillIndex((i) => (i + 1) % skillMatches.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSkillIndex( + (i) => + (i - 1 + skillMatches.length) % + skillMatches.length, + ); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + pickSkill( + (skillMatches[skillIndex] ?? skillMatches[0]).name, + ); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setInput(""); + return; + } + } if (e.key === "Tab" && suggestion) { e.preventDefault(); acceptSuggestion(); @@ -519,11 +586,38 @@ function AgentRoute() { sendMessage(); } }} - placeholder="Ask a question..." + placeholder={ + activeSkills.length > 0 + ? "Ask a question… (type / for skills)" + : "Ask a question..." + } disabled={isLoading} rows={1} className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 resize-none" /> + {skillMenuOpen && ( + + )}