feat: better skill ux (#43523)

This commit is contained in:
Filip 2026-08-20 01:27:57 +02:00 committed by GitHub
parent f8c46684eb
commit d912202cf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 36 additions and 80 deletions

View file

@ -244,7 +244,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@ -2567,7 +2567,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@ -2836,7 +2835,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@ -3105,7 +3103,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"

View file

@ -982,7 +982,6 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
return Effect.succeed({
id: skill.id,
name: skill.name,
text: Skill.toModelOutput(skill, []),
mention: attachment.mention,
})
})

View file

@ -138,8 +138,7 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`

View file

@ -227,7 +227,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]

View file

@ -207,7 +207,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,

View file

@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),

View file

@ -12,7 +12,7 @@ export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
})
export const Output = Schema.Struct({
@ -23,7 +23,7 @@ export const Output = Schema.Struct({
export const description = [
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
"",
"The skill ID must match one of the available skills in the instructions.",
"The skill ID must match an available skill or a skill explicitly referenced by the user.",
].join("\n")
export const toModelOutput = Skill.toModelOutput

View file

@ -205,18 +205,18 @@ Recent work
})
})
test("lowers selected skill instructions with the original user prompt", () => {
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill"),
id: id("user-skill-reference"),
type: "user",
text: "Design this API",
text: "Use @api-design",
skills: [
SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "Start from the ideal call site.",
mention: { start: 4, end: 15, text: "@api-design" },
}),
],
time: { created },
@ -225,17 +225,9 @@ Recent work
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
id: id("user-skill"),
role: "user",
content: [
{
type: "text",
text: "Start from the ideal call site.",
},
{ type: "text", text: "Design this API" },
],
content: [{ type: "text", text: "Use @api-design" }],
})
})

View file

@ -56,7 +56,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("attaches a resolved skill snapshot to a normal prompt", () =>
it.effect("keeps skill mentions as references on a normal prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@ -67,8 +67,8 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply this guidance",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
@ -77,13 +77,12 @@ describe("Session.skill", () => {
expect.objectContaining({
id,
type: "user",
text: "Apply this guidance",
text: "Apply @effect",
skills: [
{
id: "effect",
name: "Effect",
text: expect.stringContaining("Use Effect"),
mention: { start: 20, end: 27, text: "/effect" },
mention: { start: 6, end: 13, text: "@effect" },
},
],
}),

View file

@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",

View file

@ -57,7 +57,6 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String,
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })

View file

@ -176,7 +176,7 @@ export function Autocomplete(props: {
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const prefix = part.type === "skill" ? "/" : "@"
const prefix = "@"
const append = prefix + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
@ -478,6 +478,22 @@ export function Autocomplete(props: {
)
})
const skillOptions = createMemo(() =>
(data.location.skill.list(location.current) ?? []).map(
(skill): AutocompleteOption => ({
display: "@" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () => {
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
})
},
}),
),
)
const referenceAliases = createMemo(() =>
references()
.filter((reference) => !reference.hidden)
@ -537,11 +553,7 @@ export function Autocomplete(props: {
display: "/" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () =>
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
}),
onSelect: () => insertSlash(skill.id),
})
}
@ -592,10 +604,10 @@ export function Autocomplete(props: {
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "reference"
? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue, ...mcpResources()]
: store.index === 0
? [...commandsValue]
: commandsValue.filter((item) => item.kind === "skill")
: []
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]

View file

@ -30,7 +30,6 @@ import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
@ -45,7 +44,6 @@ import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
@ -582,44 +580,6 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = stringWidth(normalized)
},
},
{
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slash: { name: "skills" },
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation.current}
onSelect={(skill) => {
if (store.prompt.skills?.some((item) => item.id === skill)) return
const text = `/${skill}`
const start = input.cursorOffset
input.insertText(text + " ")
const extmarkId = input.extmarks.create({
start,
end: start + promptOffsetWidth(text),
virtual: true,
styleId: skillStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
draft.prompt.text = input.plainText
const skills = (draft.prompt.skills ??= [])
const index = skills.length
skills.push({
id: Skill.ID.make(skill),
mention: { start, end: start + promptOffsetWidth(text), text },
})
draft.extmarkToPart.set(extmarkId, { type: "skill", index })
}),
)
}}
/>
))
},
},
{
title: "Move session",
desc: "Move to another project dir",
@ -661,7 +621,6 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"session.move",