mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 22:23:29 +00:00
feat: better skill ux (#43523)
This commit is contained in:
parent
f8c46684eb
commit
d912202cf2
13 changed files with 36 additions and 80 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}.`
|
||||
|
|
|
|||
|
|
@ -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 ?? []),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue