feat(core): add native skill activation

This commit is contained in:
Dax Raad 2026-06-30 01:14:44 -04:00
commit 23adaaaeab
26 changed files with 814 additions and 76 deletions

View file

@ -2,26 +2,32 @@ import { TextAttributes } from "@opentui/core"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo, createSignal } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import { useData } from "../context/data"
import type { LocationRef } from "@opencode-ai/sdk/v2"
export type DialogSkillProps = {
location?: LocationRef
onSelect: (skill: string) => void
}
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const sdk = useSDK()
const data = useData()
const { theme } = useTheme()
dialog.setSize("large")
const [loadError, setLoadError] = createSignal<unknown>()
const [skills] = createResource(() =>
sdk.client.app
.skills({}, { throwOnError: true })
.then((result) => result.data ?? [])
Promise.resolve()
.then(async () => {
const current = data.location.skill.list(props.location)
if (current) return current
await data.location.skill.refresh(props.location)
return data.location.skill.list(props.location) ?? []
})
// Catch so the rejected resource never reaches the memo below: reading
// skills() in an errored state re-throws and tears down the dialog.
.catch((error) => {

View file

@ -443,12 +443,12 @@ export function Autocomplete(props: {
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...slashes()]
const commandNames = new Set<string>()
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
for (const serverCommand of data.location.command.list(location()) ?? []) {
commandNames.add(serverCommand.name)
results.push({
display: "/" + serverCommand.name + label,
display: "/" + serverCommand.name,
description: serverCommand.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
@ -460,6 +460,22 @@ export function Autocomplete(props: {
})
}
for (const skill of data.location.skill
.list(location())
?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) {
results.push({
display: "/" + skill.name,
description: skill.description,
onSelect: () => {
const newText = "/" + skill.name + " "
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

View file

@ -56,6 +56,7 @@ import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
export type PromptProps = {
sessionID?: string
@ -154,6 +155,7 @@ export function Prompt(props: PromptProps) {
const project = useProject()
const sync = useSync()
const data = useData()
const currentLocation = useLocation()
const tuiConfig = useTuiConfig()
const dialog = useDialog()
const toast = useToast()
@ -533,6 +535,7 @@ export function Prompt(props: PromptProps) {
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation()}
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
@ -1088,7 +1091,9 @@ export function Prompt(props: PromptProps) {
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1))
(data.location.command.list(currentLocation()) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
@ -1107,6 +1112,17 @@ export function Prompt(props: PromptProps) {
variant,
parts: nonTextParts.filter((x) => x.type === "file"),
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation()) ?? []).some(
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit()
void sdk.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
})
} else {
move.startSubmit()
if (!session) {