From 786ae0a584688214c99d613f18b6dc1b4ccefb9e Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 31 Jan 2026 09:59:28 -0500 Subject: [PATCH] feat(app): add skill slash commands (#11369) --- packages/app/src/components/prompt-input.tsx | 36 +++++++++++++++++-- packages/app/src/context/global-sync.tsx | 6 ++++ packages/app/src/i18n/en.ts | 1 + .../cmd/tui/component/prompt/autocomplete.tsx | 14 ++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 +++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c1d417eb0..2bf9acf326 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/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 = (props) => { @@ -519,7 +519,15 @@ export const PromptInput: Component = (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 = (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 = (props) => { {language.t("prompt.slash.badge.custom")} + + + {language.t("prompt.slash.badge.skill")} + + {command.keybind(cmd.id)} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c..6977b86a32 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/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!)), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a6a50506a0..7b18f54aff 100644 --- a/packages/app/src/i18n/en.ts +++ b/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", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index bd000e2ab0..0c2ef61a62 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bb..e7fedd6c35 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/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 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 ?? {}))),