Jelajahi Sumber

feat(app): add skill slash commands (#11369)

Ryan Vogel 2 bulan lalu
induk
melakukan
786ae0a584

+ 34 - 2
packages/app/src/components/prompt-input.tsx

@@ -111,7 +111,7 @@ interface SlashCommand {
   title: string
   description?: string
   keybind?: string
-  type: "builtin" | "custom"
+  type: "builtin" | "custom" | "skill"
 }
 
 export const PromptInput: Component<PromptInputProps> = (props) => {
@@ -519,7 +519,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       type: "custom" as const,
     }))
 
-    return [...custom, ...builtin]
+    const skills = sync.data.skill.map((skill) => ({
+      id: `skill.${skill.name}`,
+      trigger: `skill:${skill.name}`,
+      title: skill.name,
+      description: skill.description,
+      type: "skill" as const,
+    }))
+
+    return [...skills, ...custom, ...builtin]
   })
 
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
@@ -543,6 +551,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
+    if (cmd.type === "skill") {
+      // Extract skill name from the id (skill.{name})
+      const skillName = cmd.id.replace("skill.", "")
+      const text = `Load the "${skillName}" skill and follow its instructions.`
+      editorRef.innerHTML = ""
+      editorRef.textContent = text
+      prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+      requestAnimationFrame(() => {
+        editorRef.focus()
+        const range = document.createRange()
+        const sel = window.getSelection()
+        range.selectNodeContents(editorRef)
+        range.collapse(false)
+        sel?.removeAllRanges()
+        sel?.addRange(range)
+      })
+      return
+    }
+
     editorRef.innerHTML = ""
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     command.trigger(cmd.id, "slash")
@@ -1706,6 +1733,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                             {language.t("prompt.slash.badge.custom")}
                           </span>
                         </Show>
+                        <Show when={cmd.type === "skill"}>
+                          <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+                            {language.t("prompt.slash.badge.skill")}
+                          </span>
+                        </Show>
                         <Show when={command.keybind(cmd.id)}>
                           <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
                         </Show>

+ 6 - 0
packages/app/src/context/global-sync.tsx

@@ -17,6 +17,7 @@ import {
   type VcsInfo,
   type PermissionRequest,
   type QuestionRequest,
+  type AppSkillsResponse,
   createOpencodeClient,
 } from "@opencode-ai/sdk/v2/client"
 import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
@@ -56,10 +57,13 @@ type ProjectMeta = {
   }
 }
 
+export type Skill = AppSkillsResponse[number]
+
 type State = {
   status: "loading" | "partial" | "complete"
   agent: Agent[]
   command: Command[]
+  skill: Skill[]
   project: string
   projectMeta: ProjectMeta | undefined
   icon: string | undefined
@@ -388,6 +392,7 @@ function createGlobalSync() {
           status: "loading" as const,
           agent: [],
           command: [],
+          skill: [],
           session: [],
           sessionTotal: 0,
           session_status: {},
@@ -528,6 +533,7 @@ function createGlobalSync() {
       Promise.all([
         sdk.path.get().then((x) => setStore("path", x.data!)),
         sdk.command.list().then((x) => setStore("command", x.data ?? [])),
+        sdk.app.skills().then((x) => setStore("skill", x.data ?? [])),
         sdk.session.status().then((x) => setStore("session_status", x.data!)),
         loadSessions(directory),
         sdk.mcp.status().then((x) => setStore("mcp", x.data!)),

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -216,6 +216,7 @@ export const dict = {
   "prompt.popover.emptyCommands": "No matching commands",
   "prompt.dropzone.label": "Drop images or PDFs here",
   "prompt.slash.badge.custom": "custom",
+  "prompt.slash.badge.skill": "skill",
   "prompt.context.active": "active",
   "prompt.context.includeActiveFile": "Include active file",
   "prompt.context.removeActiveFile": "Remove active file from context",

+ 14 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -359,6 +359,20 @@ export function Autocomplete(props: {
       })
     }
 
+    for (const skill of sync.data.skill) {
+      results.push({
+        display: "/skill:" + skill.name,
+        description: skill.description,
+        onSelect: () => {
+          const newText = `Load the "${skill.name}" skill and follow its instructions.`
+          const cursor = props.input().logicalCursor
+          props.input().deleteRange(0, 0, cursor.row, cursor.col)
+          props.input().insertText(newText)
+          props.input().cursorOffset = Bun.stringWidth(newText)
+        },
+      })
+    }
+
     results.sort((a, b) => a.display.localeCompare(b.display))
 
     const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length

+ 4 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -17,6 +17,7 @@ import type {
   ProviderListResponse,
   ProviderAuthMethod,
   VcsInfo,
+  AppSkillsResponse,
 } from "@opencode-ai/sdk/v2"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
@@ -40,6 +41,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       provider_auth: Record<string, ProviderAuthMethod[]>
       agent: Agent[]
       command: Command[]
+      skill: AppSkillsResponse
       permission: {
         [sessionID: string]: PermissionRequest[]
       }
@@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       permission: {},
       question: {},
       command: [],
+      skill: [],
       provider: [],
       provider_default: {},
       session: [],
@@ -385,6 +388,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           Promise.all([
             ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
             sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
+            sdk.client.app.skills().then((x) => setStore("skill", reconcile(x.data ?? []))),
             sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
             sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
             sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),