From 953d1514db6323c197eb8a753f62693024c2f869 Mon Sep 17 00:00:00 2001 From: Titouan V Date: Mon, 19 Jan 2026 02:20:41 +0100 Subject: [PATCH] feat: add /skills command and skill autocomplete Add /skills command to list and quick-invoke skills from TUI. Skills also appear in slash autocomplete as / for direct invocation. Closes #7846 --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 +++ .../cmd/tui/component/dialog-skill-list.tsx | 79 +++++++++++++++++++ .../cmd/tui/component/prompt/autocomplete.tsx | 24 ++++++ 3 files changed, 115 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-skill-list.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf..9a1b331cbdb 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogSkillList } from "@tui/component/dialog-skill-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -393,6 +394,17 @@ function App() { dialog.replace(() => ) }, }, + { + title: "List skills", + value: "skill.list", + category: "Agent", + slash: { + name: "skills", + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "Agent cycle", value: "agent.cycle", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill-list.tsx new file mode 100644 index 00000000000..75672bab9d3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill-list.tsx @@ -0,0 +1,79 @@ +import { createMemo, createSignal, createResource } from "solid-js" +import { useSDK } from "@tui/context/sdk" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useLocal } from "@tui/context/local" +import { useRoute } from "@tui/context/route" +import { Identifier } from "@/id/id" +import { Global } from "@/global" + +function getSource(location: string): "global" | "project" { + const home = Global.Path.home + if ( + location.startsWith(`${home}/.claude/`) || + location.startsWith(`${home}/.opencode/`) || + location.startsWith(`${home}/.config/opencode/`) + ) { + return "global" + } + return "project" +} + +export function DialogSkillList() { + const sdk = useSDK() + const dialog = useDialog() + const local = useLocal() + const route = useRoute() + const [query, setQuery] = createSignal("") + + const [skills] = createResource(async () => { + const result = await sdk.client.app.skills() + return result.data ?? [] + }) + + const options = createMemo(() => { + const list = skills() ?? [] + const q = query().toLowerCase() + + return list + .filter((skill) => !q || skill.name.toLowerCase().includes(q) || skill.description.toLowerCase().includes(q)) + .map((skill) => ({ + value: skill, + title: skill.name, + category: getSource(skill.location) === "global" ? "Global" : "Project", + onSelect: async () => { + dialog.clear() + await invoke(skill.name) + }, + })) + }) + + async function invoke(name: string) { + const selectedModel = local.model.current() + if (!selectedModel) return + + const sessionID = + route.data.type === "session" ? route.data.sessionID : await sdk.client.session.create({}).then((x) => x.data!.id) + + await sdk.client.session.prompt({ + sessionID, + ...selectedModel, + messageID: Identifier.ascending("message"), + agent: local.agent.current().name, + model: selectedModel, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: `Use the skill tool to load the "${name}" skill`, + }, + ], + }) + + if (route.data.type !== "session") { + route.navigate({ type: "session", sessionID }) + } + } + + return +} 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 e27c32dfb2e..1715274fdd6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -310,6 +310,16 @@ export function Autocomplete(props: { return options }) + const [skills] = createResource( + () => store.visible === "/", + async (active) => { + if (!active) return [] + const result = await sdk.client.app.skills() + return result.data ?? [] + }, + { initialValue: [] }, + ) + const agents = createMemo(() => { const agents = sync.data.agent return agents @@ -349,6 +359,20 @@ export function Autocomplete(props: { }) } + for (const skill of skills() ?? []) { + results.push({ + display: "/" + skill.name, + description: skill.description, + onSelect: () => { + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + const text = `Use the skill tool to load the "${skill.name}" skill` + props.input().insertText(text) + props.input().cursorOffset = Bun.stringWidth(text) + }, + }) + } + results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length