mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(protocol): simplify skill and reference contracts
This commit is contained in:
@@ -88,15 +88,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
|
||||
| [ ] 012 | `POST` | `/api/plugin/check` | `plugin.check` | | |
|
||||
| [ ] 013 | `POST` | `/api/plugin/update` | `plugin.update` | | |
|
||||
| [ ] 014 | `GET` | `/api/model` | `model.list` | | |
|
||||
| [ ] 015 | `GET` | `/api/model/default` | `model.default` | | |
|
||||
| [ ] 016 | `GET` | `/api/provider` | `provider.list` | | |
|
||||
| [ ] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | | |
|
||||
| [ ] 018 | `GET` | `/api/command` | `command.list` | | |
|
||||
| [ ] 019 | `GET` | `/api/skill` | `skill.list` | | |
|
||||
| [ ] 020 | `GET` | `/api/reference` | `reference.list` | | |
|
||||
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
|
||||
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
|
||||
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
|
||||
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
|
||||
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
|
||||
| [ ] 021 | `GET` | `/api/config` | `config.get` | | |
|
||||
| [ ] 022 | `GET` | `/api/config/preferences` | `config.preferences` | | |
|
||||
| [ ] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | | |
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/slash-skills"
|
||||
const projectID = "proj_slash_skills"
|
||||
const sessionID = "ses_slash_skills"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
async function setup(page: Page, queued = false) {
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "slash-skills",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: "Slash skills",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
|
||||
inbox: queued
|
||||
? [
|
||||
{
|
||||
id: "inb_slash_skill",
|
||||
sessionID,
|
||||
timeCreated: 1700000000000,
|
||||
type: "user",
|
||||
payload: { text: "Explain caching" },
|
||||
delivery: "queue",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
findFiles: ({ query }) =>
|
||||
query.includes("cache")
|
||||
? [
|
||||
{
|
||||
name: "cache.ts",
|
||||
path: "src/cache.ts",
|
||||
absolute: `${directory}/src/cache.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
onPrompt: ({ body }) => prompts.push(body),
|
||||
})
|
||||
await page.route("**/api/skill?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [
|
||||
{ id: "show-me", slash: true, autoinvoke: false },
|
||||
{ id: "hidden", slash: false },
|
||||
{ id: "implicit" },
|
||||
{ id: "review", slash: true },
|
||||
{ id: "model", slash: true },
|
||||
].map((skill) => ({
|
||||
name: skill.id === "show-me" ? "Show Me" : skill.id,
|
||||
description: "Explain the current topic visually",
|
||||
location: `/skills/${skill.id}/SKILL.md`,
|
||||
content: "Explain visually",
|
||||
...skill,
|
||||
})),
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/command?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const editor = composer.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
return { prompts, composer, editor }
|
||||
}
|
||||
|
||||
for (const selection of ["keyboard", "pointer"]) {
|
||||
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page)
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toContainText("/show-me")
|
||||
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
|
||||
if (selection === "keyboard") await editor.press("Enter")
|
||||
if (selection === "pointer") await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.pressSequentially("explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
}
|
||||
|
||||
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
|
||||
const { editor } = await setup(page)
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
|
||||
for (const id of ["hidden", "implicit", "review", "model"]) {
|
||||
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
|
||||
}
|
||||
await editor.press("Escape")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.fill("@hidden")
|
||||
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
|
||||
await expect(hidden).toContainText("@hidden")
|
||||
await hidden.click()
|
||||
await expect(editor).toHaveText("@hidden ")
|
||||
await expect(editor).toBeFocused()
|
||||
})
|
||||
|
||||
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
|
||||
const { prompts, composer, editor } = await setup(page)
|
||||
await editor.fill("explain @cache")
|
||||
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
await expect(editor).toHaveText("explain @src/cache.ts ")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
|
||||
})
|
||||
})
|
||||
|
||||
for (const select of [true, false]) {
|
||||
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page, true)
|
||||
const row = page.locator('[data-component="session-queue-row"]')
|
||||
await row.getByText("Explain caching", { exact: true }).click()
|
||||
await expect(editor).toHaveText("Explain caching")
|
||||
if (select) {
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await editor.pressSequentially("explain caching")
|
||||
}
|
||||
if (!select) await editor.fill("/show-me explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
delivery: "queue",
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -404,7 +404,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
location: "/skills/summary/SKILL.md",
|
||||
path: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -42,7 +42,7 @@ for (const service of services) {
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
: [{ id: service.item, name: service.item, path: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -235,11 +235,11 @@ test("catalog submenus show project plugins and skills, refresh on reopen, and d
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{ id: "find-skills", name: "find-skills", path: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{
|
||||
id: "review-animations",
|
||||
name: "review-animations",
|
||||
location: "/skills/review/SKILL.md",
|
||||
path: "/skills/review/SKILL.md",
|
||||
content: "Review animations",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("Composer store", () => {
|
||||
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
|
||||
})
|
||||
|
||||
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
|
||||
test("prepends a skill mention to an attachment-only draft without flattening it", () => {
|
||||
const prompt = createPromptStore()
|
||||
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
|
||||
prompt.addMention(
|
||||
@@ -107,14 +107,14 @@ describe("Composer store", () => {
|
||||
type: "skill",
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
content: "/show-me",
|
||||
content: "@show-me",
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
{ start: 0, end: 0 },
|
||||
)
|
||||
expect(prompt.state.prompt).toMatchObject([
|
||||
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
|
||||
{ type: "skill", id: "show-me", content: "@show-me", start: 0, end: 8 },
|
||||
{ type: "text", content: " ", start: 8, end: 9 },
|
||||
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
|
||||
])
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit, withSlashSkill } from "./submit"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -243,9 +243,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const slashSkills = createMemo(() =>
|
||||
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
|
||||
)
|
||||
const commands = createMemo<ComposerSuggestion[]>(() => [
|
||||
...slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
@@ -256,29 +253,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
description: item.description,
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
...slashSkills().map((skill) => ({
|
||||
id: `skill:${skill.id}`,
|
||||
kind: "skill" as const,
|
||||
label: `/${skill.id}`,
|
||||
trigger: skill.id,
|
||||
title: skill.name,
|
||||
description: skill.description,
|
||||
mention: {
|
||||
type: "skill" as const,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content: `/${skill.id}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
})),
|
||||
])
|
||||
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
skills: slashSkills,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
@@ -407,7 +387,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
if (queue?.editing()) {
|
||||
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
|
||||
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
@@ -32,16 +31,6 @@ const selection = {
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
|
||||
const slashSkill = Skill.Info.make({
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
description: "Explain visually",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
|
||||
content: "Explain visually",
|
||||
})
|
||||
|
||||
function controls(): ComposerControls {
|
||||
return {
|
||||
agents: {
|
||||
@@ -64,13 +53,11 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
skills: () => readonly Skill.Info[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
skills,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -268,107 +255,6 @@ describe("Composer submission", () => {
|
||||
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
|
||||
})
|
||||
|
||||
test("submits a slash skill with its trailing text and attachments", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
|
||||
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
|
||||
])
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe("/show-me explain @src/cache.ts")
|
||||
expect(request.skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
expect(request.files).toEqual([
|
||||
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
|
||||
])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ text: "/show-me", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me", slash: false, expected: [] },
|
||||
{ text: "/show-me", slash: undefined, expected: [] },
|
||||
{ text: "/show-me-extra", slash: true, expected: [] },
|
||||
{ text: "Explain /show-me", slash: true, expected: [] },
|
||||
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
|
||||
const state = createMemoryComposerState({ prompt: text }).capture()
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [{ ...slashSkill, slash }],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe(text)
|
||||
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
|
||||
})
|
||||
|
||||
test("captures slash skills before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
|
||||
let skills: readonly Skill.Info[] | undefined = [slashSkill]
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
skills = undefined
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => skills,
|
||||
).submit(new Event("submit"))
|
||||
expect((await admitted.promise).skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
})
|
||||
|
||||
test("sends one captured value with explicit delivery after selection switches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -652,7 +538,6 @@ describe("Composer submission", () => {
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
@@ -744,7 +629,6 @@ describe("Composer submission", () => {
|
||||
undefined,
|
||||
"normal",
|
||||
() => catalog,
|
||||
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
|
||||
).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionMessageUser, SkillInfo } from "@opencode/client/promise"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
@@ -30,7 +29,6 @@ type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -71,7 +69,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -292,26 +289,6 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
|
||||
const first = prompt[0]
|
||||
if (first?.type !== "text") return prompt
|
||||
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
|
||||
const skill = skills?.find((item) => item.slash === true && item.id === name)
|
||||
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
|
||||
const content = `/${skill.id}`
|
||||
return [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content,
|
||||
start: 0,
|
||||
end: content.length,
|
||||
},
|
||||
{ ...first, content: first.content.slice(content.length), start: content.length },
|
||||
...prompt.slice(1),
|
||||
]
|
||||
}
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
|
||||
@@ -27,10 +27,10 @@ import "./project.css"
|
||||
|
||||
type SkillItem = {
|
||||
name: string
|
||||
location: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.path}`
|
||||
|
||||
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
|
||||
<SettingsList variant="catalog">{props.children}</SettingsList>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type OpenCodeClient,
|
||||
type SessionInfo,
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
@@ -71,7 +70,6 @@ type Catalog = {
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly commands: CommandInfo[]
|
||||
readonly skills: SkillInfo[]
|
||||
}
|
||||
|
||||
type Attached = {
|
||||
@@ -90,7 +88,6 @@ type PreparedPrompt = {
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
readonly skill?: SkillInfo
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -157,11 +154,8 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...state.catalog.commands,
|
||||
...state.catalog.skills.filter(
|
||||
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
|
||||
),
|
||||
].map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
],
|
||||
},
|
||||
})
|
||||
return state
|
||||
@@ -365,9 +359,8 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
|
||||
const start = turnStart(messageID, slash, skill)
|
||||
return { start, text, files, synthetic, slash, command, skill }
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
@@ -381,7 +374,6 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
|
||||
})
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
|
||||
if (prompt.command) {
|
||||
return client.session.command(
|
||||
{
|
||||
@@ -400,9 +392,8 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
|
||||
if (slash?.name === "compact") return { type: "compaction", id: messageID }
|
||||
if (skill) return { type: "skill", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
@@ -412,12 +403,11 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
client.skill.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
@@ -439,7 +429,6 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
skills: skillResult.data.filter((skill) => skill.slash !== false),
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
|
||||
@@ -32,7 +32,6 @@ describe("acp service directory behavior", () => {
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
"/api/skill",
|
||||
].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
@@ -43,7 +42,6 @@ describe("acp service directory behavior", () => {
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
@@ -73,9 +71,9 @@ describe("acp service directory behavior", () => {
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["review", "verify"],
|
||||
["review", "verify"],
|
||||
["review", "verify"],
|
||||
["review"],
|
||||
["review"],
|
||||
["review"],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type SessionInfo,
|
||||
type SkillInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
@@ -34,7 +33,6 @@ type FixtureOptions = {
|
||||
readonly defaultModel?: ModelInfo
|
||||
readonly agents?: readonly AgentInfo[]
|
||||
readonly commands?: readonly CommandInfo[]
|
||||
readonly skills?: readonly SkillInfo[]
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
@@ -89,15 +87,6 @@ export const reviewCommand = {
|
||||
description: "Review changes",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export const verifySkill = {
|
||||
id: "verify",
|
||||
name: "verify",
|
||||
description: "Verify work",
|
||||
slash: true,
|
||||
location: "/skills/verify.md",
|
||||
content: "verify",
|
||||
} satisfies SkillInfo
|
||||
|
||||
export function makeSession(
|
||||
id: string,
|
||||
input: {
|
||||
@@ -178,9 +167,6 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
if (request.path === "/api/command") {
|
||||
return Response.json({ location, data: options.commands ?? [reviewCommand] })
|
||||
}
|
||||
if (request.path === "/api/skill") {
|
||||
return Response.json({ location, data: options.skills ?? [verifySkill] })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
|
||||
|
||||
describe("acp service prompt routing and usage", () => {
|
||||
test("routes slash commands, skills, and compact through their session endpoints", async () => {
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
@@ -14,15 +14,6 @@ describe("acp service prompt routing and usage", () => {
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_routes", {
|
||||
id: id.replace(/^msg_/, "evt_"),
|
||||
type: "session.skill.activated",
|
||||
data: { sessionID: "ses_routes", skill: "verify" },
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_routes", {
|
||||
@@ -41,22 +32,13 @@ describe("acp service prompt routing and usage", () => {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/review now" }],
|
||||
})
|
||||
const skillResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/verify" }],
|
||||
})
|
||||
const compactResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
})
|
||||
|
||||
expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
|
||||
"end_turn",
|
||||
"end_turn",
|
||||
"end_turn",
|
||||
])
|
||||
expect([commandResult.stopReason, compactResult.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
|
||||
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
|
||||
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
|
||||
expect(command?.body).toMatchObject({
|
||||
command: "review",
|
||||
@@ -64,7 +46,6 @@ describe("acp service prompt routing and usage", () => {
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
})
|
||||
expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
|
||||
expect(compact?.body).toMatchObject({ id: expect.any(String) })
|
||||
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("acp service", () => {
|
||||
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
|
||||
if (url.pathname === "/api/command")
|
||||
return Response.json({ location, data: [{ name: "review", template: "" }] })
|
||||
if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
|
||||
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
|
||||
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
|
||||
return new Response(null, { status: 404 })
|
||||
@@ -63,7 +62,7 @@ describe("acp service", () => {
|
||||
sessionId: "ses_acp",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review" }, { name: "verify", description: "Verify work" }],
|
||||
availableCommands: [{ name: "review", description: "" }],
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -95,15 +94,6 @@ const agent = {
|
||||
permissions: [],
|
||||
}
|
||||
|
||||
const skill = {
|
||||
id: "verify",
|
||||
name: "verify",
|
||||
description: "Verify work",
|
||||
slash: true,
|
||||
location: "/skills/verify.md",
|
||||
content: "verify",
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "ses_acp",
|
||||
projectID: "global",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
|
||||
|
||||
describe("acp skills subprocess", () => {
|
||||
test("skill slash command appears through available_commands_update", async () => {
|
||||
await using fixture = await createAcpFixture({ skill: verifierSkill })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const update = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) =>
|
||||
params.sessionId === session.sessionId &&
|
||||
params.update.sessionUpdate === "available_commands_update" &&
|
||||
params.update.availableCommands.some(
|
||||
(command) => command.name === "verifier-skill" && command.description.length > 0,
|
||||
),
|
||||
)
|
||||
|
||||
expect(update.params.sessionId).toBe(session.sessionId)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -322,9 +322,8 @@ export type SkillInfo = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
slash?: boolean
|
||||
autoinvoke?: boolean
|
||||
location: string
|
||||
path: string
|
||||
content: string
|
||||
}
|
||||
|
||||
@@ -398,15 +397,9 @@ export type ShellInfo1 = {
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
|
||||
export type ReferenceLocalSource = { type: "local"; path: string }
|
||||
|
||||
export type ReferenceGitSource = {
|
||||
type: "git"
|
||||
repository: string
|
||||
branch?: string
|
||||
description?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
export type ReferenceGitSource = { type: "git"; repository: string; branch?: string }
|
||||
|
||||
export type WorktreeDirectory = { directory: string; strategy?: string }
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Skill } from "../../skill.js"
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
@@ -42,7 +41,6 @@ export function parse(directory: string, filepath: string, content: string): Par
|
||||
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
@@ -50,9 +48,8 @@ export function parse(directory: string, filepath: string, content: string): Par
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(frontmatter.name ?? id),
|
||||
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
||||
...(slash === undefined ? {} : { slash }),
|
||||
...(autoinvoke === undefined ? {} : { autoinvoke }),
|
||||
location: AbsolutePath.make(filepath),
|
||||
path: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Plugin = define({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
path: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
}),
|
||||
)
|
||||
@@ -39,8 +39,7 @@ export const Plugin = define({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
path: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
|
||||
skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
|
||||
|
||||
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.location)
|
||||
const directory = path.dirname(skill.path)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
@@ -54,9 +54,9 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
}
|
||||
|
||||
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
|
||||
const directory = path.dirname(skill.location)
|
||||
const directory = path.dirname(skill.path)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
path.basename(skill.path) === "SKILL.md"
|
||||
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("config plugin reloads", () => {
|
||||
Bun.write(skill, "---\nname: probe\ndescription: Hot reload\n---\nTest skill"),
|
||||
)
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.some((item) => item.id === "probe"))))
|
||||
expect((yield* skills.list())[0]?.location).toBe(AbsolutePath.make(skill))
|
||||
expect((yield* skills.list())[0]?.path).toBe(AbsolutePath.make(skill))
|
||||
yield* Effect.promise(() => fs.rm(root, { recursive: true }))
|
||||
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.length === 0)))
|
||||
yield* Effect.promise(() => Bun.write(skill, "---\nname: probe\ndescription: Recreated\n---\nTest skill"))
|
||||
|
||||
@@ -124,7 +124,6 @@ describe("SkillFile.parse", () => {
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
@@ -135,13 +134,12 @@ metadata:
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
path: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
@@ -152,11 +150,6 @@ metadata:
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -257,7 +250,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
const review = (yield* skill.list()).find((item) => item.id === "review")
|
||||
|
||||
expect(review?.description).toBe("Worktree")
|
||||
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
|
||||
expect(review?.path).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("SkillPlugin.Plugin", () => {
|
||||
description: expect.stringContaining("opencode issue"),
|
||||
}),
|
||||
)
|
||||
expect(report?.slash).toBe(true)
|
||||
expect(report?.content).toContain("- opencode version: 1.2.3")
|
||||
expect(report?.content).toContain("- install/channel: beta")
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,7 @@ const skillInfo = Skill.Info.make({
|
||||
id: Skill.ID.make("guide"),
|
||||
name: Skill.Name.make("Guide"),
|
||||
description: "Session guidance",
|
||||
location: AbsolutePath.make("/skills/guide/SKILL.md"),
|
||||
path: AbsolutePath.make("/skills/guide/SKILL.md"),
|
||||
content: " Raw guidance\n",
|
||||
})
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const info = Skill.Info.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/effect.md")),
|
||||
content: " Use Effect\n",
|
||||
})
|
||||
const locations = makeGlobalNode({
|
||||
|
||||
@@ -15,7 +15,7 @@ const info = (id: string, description: string) =>
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(id),
|
||||
description,
|
||||
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
|
||||
path: AbsolutePath.make(`/skills/${id}/SKILL.md`),
|
||||
content: `# ${id}`,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,20 +14,20 @@ const effect = Skill.Info.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Build applications with Effect",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
content: "Effect guidance",
|
||||
})
|
||||
const hidden = Skill.Info.make({
|
||||
id: Skill.ID.make("hidden"),
|
||||
name: Skill.Name.make("Hidden"),
|
||||
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
|
||||
content: "Undescribed guidance",
|
||||
})
|
||||
const denied = Skill.Info.make({
|
||||
id: Skill.ID.make("denied"),
|
||||
name: Skill.Name.make("Denied"),
|
||||
description: "Must not be advertised",
|
||||
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
||||
content: "Denied guidance",
|
||||
})
|
||||
const manual = Skill.Info.make({
|
||||
@@ -35,7 +35,7 @@ const manual = Skill.Info.make({
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Load only when explicitly selected",
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
|
||||
content: "Manual guidance",
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("SkillInstructions", () => {
|
||||
id: Skill.ID.make("debugging"),
|
||||
name: Skill.Name.make("Debugging"),
|
||||
description: "Diagnose hard bugs",
|
||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
path: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
content: "Debugging guidance",
|
||||
})
|
||||
let skills = [effect]
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("SkillTool", () => {
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Use Effect",
|
||||
location: AbsolutePath.make(location),
|
||||
path: AbsolutePath.make(location),
|
||||
content: "# Effect\n\nGuidance",
|
||||
}
|
||||
let current = [info]
|
||||
@@ -138,12 +138,12 @@ describe("SkillTool", () => {
|
||||
id: Skill.ID.make("public"),
|
||||
name: Skill.Name.make("Public"),
|
||||
description: "Public guidance",
|
||||
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
|
||||
path: AbsolutePath.make(path.join(tmp.path, "public.md")),
|
||||
content: "Public",
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(flat.location, "public"),
|
||||
fs.writeFile(flat.path, "public"),
|
||||
fs.writeFile(path.join(tmp.path, "secret.md"), "secret"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -16864,12 +16864,6 @@
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "repository"],
|
||||
@@ -16906,12 +16900,6 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
@@ -18850,20 +18838,17 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"slash": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoinvoke": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"location": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "location", "content"],
|
||||
"required": ["id", "name", "path", "content"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillNotFoundErrorEncoded": {
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Reference } from "@opencode/schema/reference"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const PublicLocalSource = Schema.Struct(Struct.omit(Reference.LocalSource.fields, ["description", "hidden"])).annotate({
|
||||
identifier: "Reference.LocalSource",
|
||||
})
|
||||
const PublicGitSource = Schema.Struct(Struct.omit(Reference.GitSource.fields, ["description", "hidden"])).annotate({
|
||||
identifier: "Reference.GitSource",
|
||||
})
|
||||
const PublicSource = Schema.Union([PublicLocalSource, PublicGitSource])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Reference.Source" })
|
||||
|
||||
const PublicInfo = Schema.Struct({
|
||||
...Struct.omit(Reference.Info.fields, ["source"]),
|
||||
source: PublicSource,
|
||||
}).annotate({ identifier: "Reference.Info" })
|
||||
|
||||
export const ReferenceGroup = HttpApiGroup.make("server.reference")
|
||||
.add(
|
||||
HttpApiEndpoint.get("reference.list", "/api/reference", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Reference.Info)),
|
||||
success: Location.response(Schema.Array(PublicInfo)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
||||
@@ -28,9 +28,8 @@ export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
name: Name,
|
||||
description: Schema.String.pipe(optional),
|
||||
slash: Schema.Boolean.pipe(optional),
|
||||
autoinvoke: Schema.Boolean.pipe(optional),
|
||||
location: AbsolutePath,
|
||||
path: AbsolutePath,
|
||||
content: Schema.String,
|
||||
}).annotate({ identifier: "Skill.Info" })
|
||||
|
||||
|
||||
@@ -520,17 +520,6 @@ export function Autocomplete(props: {
|
||||
})
|
||||
}
|
||||
|
||||
for (const skill of data.location.skill
|
||||
.list(location.current)
|
||||
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
|
||||
results.push({
|
||||
display: "/" + skill.id,
|
||||
description: skill.description,
|
||||
kind: "skill",
|
||||
onSelect: () => insertSlash(skill.id),
|
||||
})
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.display.localeCompare(b.display))
|
||||
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
|
||||
@@ -1144,19 +1144,9 @@ export function Prompt(props: PromptProps) {
|
||||
}),
|
||||
)
|
||||
const slashHead = parseSlashHead(inputText, /\s/)
|
||||
const isSkill =
|
||||
!(store.prompt.skills?.length ?? 0) &&
|
||||
slashHead !== undefined &&
|
||||
(data.location.skill.list(currentLocation.ref) ?? []).some(
|
||||
(skill) => skill.slash === true && skill.id === slashHead.name,
|
||||
)
|
||||
const isCommand =
|
||||
slashHead !== undefined &&
|
||||
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
||||
if (delivery === "queue" && isSkill) {
|
||||
toast.show({ message: "Skills cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
const editorSelection = editorContext()
|
||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||
if (delivery === "queue" && pendingEditorSelection) {
|
||||
@@ -1170,7 +1160,7 @@ export function Prompt(props: PromptProps) {
|
||||
void promptModelWarning()
|
||||
return false
|
||||
}
|
||||
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
|
||||
const usesModel = !props.sessionID || store.mode !== "shell"
|
||||
if (usesModel && !local.model.available(selection)) {
|
||||
toast.show({
|
||||
title: "Model unavailable",
|
||||
@@ -1311,9 +1301,6 @@ export function Prompt(props: PromptProps) {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
})
|
||||
} else if (isSkill) {
|
||||
move.startSubmit()
|
||||
dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name }))
|
||||
} else {
|
||||
move.startSubmit()
|
||||
try {
|
||||
|
||||
@@ -5,13 +5,11 @@ import type {
|
||||
ModelListOutput,
|
||||
OpenCodeClient,
|
||||
ProviderListOutput,
|
||||
SkillListOutput,
|
||||
} from "@opencode/client/promise"
|
||||
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
||||
|
||||
type CurrentAgent = AgentListOutput["data"][number]
|
||||
type CurrentCommand = CommandListOutput["data"][number]
|
||||
type CurrentSkill = SkillListOutput["data"][number]
|
||||
type CurrentProvider = ProviderListOutput["data"][number]
|
||||
type CurrentModel = ModelListOutput["data"][number]
|
||||
|
||||
@@ -46,14 +44,6 @@ function runCommand(input: CurrentCommand): RunCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function runSkill(input: CurrentSkill): RunCommand {
|
||||
return {
|
||||
name: input.id,
|
||||
description: input.description,
|
||||
source: "skill",
|
||||
}
|
||||
}
|
||||
|
||||
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
|
||||
const grouped = new Map<string, RunProvider>()
|
||||
|
||||
@@ -95,11 +85,8 @@ export async function loadRunCommands(
|
||||
ref: LocationRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RunCommand[]> {
|
||||
const [commands, skills] = await Promise.all([
|
||||
sdk.command.list(location(ref), ...requestOptions(signal)),
|
||||
sdk.skill.list(location(ref), ...requestOptions(signal)),
|
||||
])
|
||||
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
|
||||
const commands = await sdk.command.list(location(ref), ...requestOptions(signal))
|
||||
return commands.data.map(runCommand)
|
||||
}
|
||||
|
||||
export async function loadRunReferences(
|
||||
|
||||
@@ -38,7 +38,6 @@ type CommandEntry =
|
||||
| (PanelEntry & { action: "agent" })
|
||||
| (PanelEntry & { action: "model" })
|
||||
| (PanelEntry & { action: "editor" })
|
||||
| (PanelEntry & { action: "skill" })
|
||||
| (PanelEntry & { action: "queued" })
|
||||
| (PanelEntry & { action: "subagent" })
|
||||
| (PanelEntry & { action: "status" })
|
||||
@@ -66,10 +65,6 @@ type VariantEntry = PanelEntry & {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type SkillEntry = PanelEntry & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type QueuedPromptEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
@@ -404,7 +399,6 @@ export function RunCommandMenuBody(props: {
|
||||
onAgent: () => void
|
||||
onModel: () => void
|
||||
onEditor: () => void
|
||||
onSkill: () => void
|
||||
onSubagent: () => void
|
||||
onQueued: () => void
|
||||
onVariant: () => void
|
||||
@@ -418,7 +412,6 @@ export function RunCommandMenuBody(props: {
|
||||
clearShortcut?: string
|
||||
mono?: boolean
|
||||
}) {
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
const entries = createMemo<CommandEntry[]>(() => {
|
||||
const session: CommandEntry[] = [
|
||||
@@ -467,20 +460,6 @@ export function RunCommandMenuBody(props: {
|
||||
keywords: "new session clear",
|
||||
},
|
||||
]
|
||||
const prompt: CommandEntry[] =
|
||||
props.commands() === undefined || skills().length > 0
|
||||
? [
|
||||
{
|
||||
action: "skill" as const,
|
||||
category: "Prompt",
|
||||
display: "Skills",
|
||||
footer: "/skills",
|
||||
keywords: `skill skills ${skills()
|
||||
.map((item) => `${item.name} ${item.description ?? ""}`)
|
||||
.join(" ")}`.trim(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
const agent: CommandEntry[] = [
|
||||
{
|
||||
action: "agent",
|
||||
@@ -526,7 +505,6 @@ export function RunCommandMenuBody(props: {
|
||||
]
|
||||
return [
|
||||
...session,
|
||||
...prompt,
|
||||
...agent,
|
||||
{
|
||||
action: "clear",
|
||||
@@ -561,10 +539,6 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "skill") {
|
||||
props.onSkill()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "subagent") {
|
||||
props.onSubagent()
|
||||
@@ -1052,65 +1026,6 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function RunSkillSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
onClose: () => void
|
||||
onSelect: (name: string) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<SkillEntry[]>(() =>
|
||||
(props.commands() ?? [])
|
||||
.filter((item) => item.source === "skill")
|
||||
.map((item) => ({
|
||||
category: "",
|
||||
display: item.name,
|
||||
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
|
||||
keywords: `skill ${item.name} ${item.description ?? ""}`,
|
||||
name: item.name,
|
||||
}))
|
||||
.sort((a, b) => a.display.localeCompare(b.display)),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.name),
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Skills"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={false}
|
||||
background
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunVariantSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
variants: Accessor<string[]>
|
||||
|
||||
@@ -104,15 +104,10 @@ type Auto = RunFooterMenuItem & {
|
||||
type SlashOption = RunFooterMenuItem & {
|
||||
kind: "slash"
|
||||
name: string
|
||||
action?: "skill-menu" | "editor" | "settings"
|
||||
action?: "editor" | "settings"
|
||||
}
|
||||
|
||||
type SkillOption = RunFooterMenuItem & {
|
||||
kind: "skill"
|
||||
id: string
|
||||
}
|
||||
|
||||
type PromptOption = Auto | SlashOption | SkillOption
|
||||
type PromptOption = Auto | SlashOption
|
||||
|
||||
type MenuMode = false | "mention" | "slash"
|
||||
|
||||
@@ -141,7 +136,6 @@ type PromptInput = {
|
||||
onInputClear: () => void
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onSkillMenu: () => void
|
||||
onSettings: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
@@ -514,19 +508,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}),
|
||||
)
|
||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
|
||||
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const skillOptions = createMemo<SkillOption[]>(() =>
|
||||
skillCommands().map((item) => ({
|
||||
kind: "skill",
|
||||
id: item.name,
|
||||
display: `/${item.name}`,
|
||||
description: item.description,
|
||||
})),
|
||||
)
|
||||
const hasSkillsCommand = createMemo(() =>
|
||||
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
|
||||
)
|
||||
const slashOptions = createMemo<Array<SlashOption | SkillOption>>(() => {
|
||||
const slashOptions = createMemo<SlashOption[]>(() => {
|
||||
const builtins = [
|
||||
{
|
||||
kind: "slash",
|
||||
@@ -552,26 +534,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
|
||||
]
|
||||
const hidden = new Set(builtins.map((item) => item.name))
|
||||
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
|
||||
if (showSkillMenu) {
|
||||
hidden.add("skills")
|
||||
}
|
||||
|
||||
return [
|
||||
...skillOptions(),
|
||||
...(showSkillMenu
|
||||
? [
|
||||
{
|
||||
kind: "slash",
|
||||
action: "skill-menu" as const,
|
||||
name: "skills",
|
||||
display: "/skills",
|
||||
description: "browse available skills",
|
||||
} satisfies SlashOption,
|
||||
]
|
||||
: []),
|
||||
...(input.commands() ?? [])
|
||||
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
|
||||
.filter((item) => !hidden.has(item.name))
|
||||
.map(
|
||||
(item) =>
|
||||
({
|
||||
@@ -585,7 +550,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
].sort((a, b) => a.display.localeCompare(b.display))
|
||||
})
|
||||
const options = createMemo<PromptOption[]>(() => {
|
||||
const mixed: PromptOption[] = mode() === "slash" ? (at() === 0 ? slashOptions() : skillOptions()) : mentionOptions()
|
||||
const mixed: PromptOption[] = mode() === "slash" ? slashOptions() : mentionOptions()
|
||||
if (!query()) {
|
||||
return mixed
|
||||
}
|
||||
@@ -602,7 +567,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return fuzzysort
|
||||
.go(next, mixed, {
|
||||
keys: [
|
||||
(item) => (item.kind === "mention" ? item.value : item.kind === "skill" ? item.id : item.name).trimEnd(),
|
||||
(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(),
|
||||
"display",
|
||||
"description",
|
||||
],
|
||||
@@ -1073,39 +1038,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.kind === "skill") {
|
||||
if (parts.some((part) => part.type === "skill" && part.id === next.id)) {
|
||||
cancelAutocomplete()
|
||||
return
|
||||
}
|
||||
const cursor = area.cursorOffset
|
||||
const tail = displayCharAt(area.plainText, cursor)
|
||||
const append = `/${next.id}${tail === " " ? "" : " "}`
|
||||
area.cursorOffset = at()
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
const end = area.logicalCursor
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText(append)
|
||||
|
||||
const text = `/${next.id}`
|
||||
const startOffset = at()
|
||||
const endOffset = startOffset + stringWidth(text)
|
||||
const part: Extract<RunPromptPart, { type: "skill" }> = {
|
||||
type: "skill",
|
||||
id: next.id,
|
||||
source: { start: startOffset, end: endOffset, value: text },
|
||||
}
|
||||
const id = area.extmarks.create({ start: startOffset, end: endOffset, virtual: true, typeId: type })
|
||||
marks.set(id, parts.length)
|
||||
parts.push(part)
|
||||
hide()
|
||||
syncDraft()
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (next.kind === "slash") {
|
||||
if (next.action === "editor") {
|
||||
void openEditor({
|
||||
@@ -1114,12 +1046,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.action === "skill-menu") {
|
||||
cancelAutocomplete()
|
||||
input.onSkillMenu()
|
||||
return
|
||||
}
|
||||
|
||||
if (next.action === "settings" && !shell()) {
|
||||
cancelAutocomplete()
|
||||
input.onSettings()
|
||||
@@ -1463,7 +1389,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(next.mode === "shell" ||
|
||||
command?.source === "skill" ||
|
||||
isNewCommand(next.text) ||
|
||||
isCompactCommand(next.text) ||
|
||||
isExitCommand(next.text) ||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSettingsBody,
|
||||
RunSkillSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
} from "./footer.command"
|
||||
@@ -149,13 +148,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
||||
const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
|
||||
const agenting = createMemo(() => active().type === "prompt" && route().type === "agent")
|
||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||
@@ -167,7 +164,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
selectingQueued() ||
|
||||
selectingSubagent() ||
|
||||
commanding() ||
|
||||
skilling() ||
|
||||
agenting() ||
|
||||
modeling() ||
|
||||
varianting() ||
|
||||
@@ -298,14 +294,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
openRoute({ type: "agent" })
|
||||
}
|
||||
|
||||
const openSkillMenu = () => {
|
||||
if (props.commands() && skills().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
openRoute({ type: "skill" })
|
||||
}
|
||||
|
||||
const openVariant = () => {
|
||||
openRoute({ type: "variant" })
|
||||
}
|
||||
@@ -398,7 +386,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onInputClear: props.onInputClear,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onSkillMenu: openSkillMenu,
|
||||
onSettings: openSettings,
|
||||
onRows: setPromptRows,
|
||||
onStatus: props.onStatus,
|
||||
@@ -683,7 +670,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const current = route()
|
||||
if (
|
||||
current.type !== "command" &&
|
||||
current.type !== "skill" &&
|
||||
current.type !== "agent" &&
|
||||
current.type !== "model" &&
|
||||
current.type !== "variant" &&
|
||||
@@ -830,7 +816,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
closePanel()
|
||||
void composer.openEditor()
|
||||
}}
|
||||
onSkill={openSkillMenu}
|
||||
onSubagent={openSubagentMenu}
|
||||
onQueued={openQueuedMenu}
|
||||
onVariant={openVariant}
|
||||
@@ -860,26 +845,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={skilling()}>
|
||||
<RunSkillSelectBody
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
onClose={closePanel}
|
||||
onSelect={(name) => {
|
||||
composer.replacePrompt({
|
||||
text: `/${name} `,
|
||||
parts: [],
|
||||
command: {
|
||||
name,
|
||||
arguments: "",
|
||||
source: "skill",
|
||||
},
|
||||
})
|
||||
closePanel()
|
||||
}}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={agenting()}>
|
||||
<RunAgentSelectBody
|
||||
theme={theme}
|
||||
|
||||
@@ -172,19 +172,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
break
|
||||
}
|
||||
|
||||
const emittedUser = sent.mode !== "shell" && sent.command?.source === "skill" && !!sent.text.trim()
|
||||
if (emittedUser) {
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent, emittedUser)
|
||||
input.onSend?.(sent, false)
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
|
||||
@@ -1789,8 +1789,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
|
||||
return {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (next.prompt.mode === "shell") throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (!next.prompt.command && next.agent)
|
||||
@@ -1822,23 +1821,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
|
||||
const command = next.prompt.command
|
||||
if (command?.source === "skill") {
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
||||
await runTurnWait(
|
||||
next,
|
||||
messageID,
|
||||
client,
|
||||
() =>
|
||||
client.session.skill(
|
||||
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
||||
{ signal: next.signal },
|
||||
),
|
||||
admitted,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await admitPrompt(next, client, next.prompt.delivery ?? "steer")
|
||||
admitted?.()
|
||||
|
||||
@@ -85,7 +85,6 @@ export type RunPrompt = {
|
||||
command?: {
|
||||
name: string
|
||||
arguments: string
|
||||
// Catalog source of the matched slash entry ("skill" routes to session.skill).
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
@@ -295,7 +294,6 @@ export type FooterPromptRoute =
|
||||
| { type: "subagent-menu" }
|
||||
| { type: "subagent"; sessionID: string }
|
||||
| { type: "command" }
|
||||
| { type: "skill" }
|
||||
| { type: "agent" }
|
||||
| { type: "model" }
|
||||
| { type: "variant" }
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunSettingsBody,
|
||||
RunSkillSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
} from "../../src/mini/footer.command"
|
||||
@@ -836,7 +835,6 @@ test("direct command panel renders grouped actions without catalog commands", as
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({ name: "review", description: "Review code" }),
|
||||
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
|
||||
command({ name: "internal", description: "Skill command", source: "skill" }),
|
||||
])
|
||||
const [subagents] = createSignal([])
|
||||
const [variants] = createSignal(["high", "minimal"])
|
||||
@@ -856,7 +854,6 @@ test("direct command panel renders grouped actions without catalog commands", as
|
||||
onAgent={() => {}}
|
||||
onModel={() => {}}
|
||||
onEditor={() => {}}
|
||||
onSkill={() => {}}
|
||||
onSubagent={() => {}}
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
@@ -884,14 +881,11 @@ test("direct command panel renders grouped actions without catalog commands", as
|
||||
expect(frame).toContain("Search")
|
||||
expect(frame).toContain("Session")
|
||||
expect(frame).toContain("Agent")
|
||||
expect(frame).toContain("Prompt")
|
||||
expect(frame).toContain("Open editor")
|
||||
expect(frame).toContain("/editor")
|
||||
expect(frame).toContain("Show status")
|
||||
expect(frame).toContain("Compact session")
|
||||
expect(frame).toContain("/compact")
|
||||
expect(frame).toContain("Skills")
|
||||
expect(frame).toContain("/skills")
|
||||
expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
@@ -1054,93 +1048,6 @@ test.each([false, true])("settings change preferences and preview the work spinn
|
||||
}
|
||||
})
|
||||
|
||||
test("direct skill panel renders searchable skill list", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({ name: "review", description: "Review code" }),
|
||||
command({ name: "internal", description: "Skill command", source: "skill" }),
|
||||
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
|
||||
])
|
||||
const selected: string[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||
<RunSkillSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
commands={commands}
|
||||
onClose={() => {}}
|
||||
onSelect={(name) => {
|
||||
selected.push(name)
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{
|
||||
width: 100,
|
||||
height: RUN_COMMAND_PANEL_ROWS,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Skills")
|
||||
expect(frame).toContain("Search")
|
||||
expect(frame).toContain("internal")
|
||||
expect(frame).not.toContain("/internal")
|
||||
expect(frame).toContain("formatter")
|
||||
expect(frame).toContain("Apply formatter fixes")
|
||||
expect(frame).not.toContain("review")
|
||||
await app.mockInput.typeText("format")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("internal")
|
||||
app.mockInput.pressEnter()
|
||||
expect(selected).toEqual(["formatter"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct skill panel truncates long descriptions from the end", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({
|
||||
name: "terminal-control",
|
||||
description:
|
||||
"Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
|
||||
source: "skill",
|
||||
}),
|
||||
])
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||
<RunSkillSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
commands={commands}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{
|
||||
width: 100,
|
||||
height: RUN_COMMAND_PANEL_ROWS,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("terminal-control")
|
||||
expect(frame).toContain("Control and test terminal applications")
|
||||
expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct command panel shows subagent entry when available", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([])
|
||||
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
|
||||
@@ -1160,7 +1067,6 @@ test("direct command panel shows subagent entry when available", async () => {
|
||||
onAgent={() => {}}
|
||||
onModel={() => {}}
|
||||
onEditor={() => {}}
|
||||
onSkill={() => {}}
|
||||
onSubagent={() => {}}
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
@@ -1211,7 +1117,6 @@ test("direct command panel keeps completed subagents available", async () => {
|
||||
onAgent={() => {}}
|
||||
onModel={() => {}}
|
||||
onEditor={() => {}}
|
||||
onSkill={() => {}}
|
||||
onSubagent={() => {}}
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
@@ -1627,35 +1532,6 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer slash autocomplete keeps a real skills command", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [
|
||||
command({ name: "skills", description: "Run the real skills command" }),
|
||||
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
|
||||
],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
|
||||
])
|
||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer closes settings with ctrl-c instead of arming exit", async () => {
|
||||
const app = await renderFooter({ height: 20 })
|
||||
|
||||
@@ -1677,11 +1553,6 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async (
|
||||
})
|
||||
|
||||
test("selectedCommand validates the bound command and refreshes its arguments", () => {
|
||||
expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
|
||||
name: "opencode-ts",
|
||||
arguments: "",
|
||||
source: "skill",
|
||||
})
|
||||
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({
|
||||
name: "deploy",
|
||||
arguments: "prod",
|
||||
@@ -1689,178 +1560,6 @@ test("selectedCommand validates the bound command and refreshes its arguments",
|
||||
expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("direct footer tags skill slash submissions with their catalog source", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/formatter src".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{
|
||||
text: "/formatter src",
|
||||
parts: [],
|
||||
command: { name: "formatter", arguments: "src", source: "skill" },
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer submits a selected leading skill as a prompt attachment", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/forma".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
"src".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{
|
||||
text: "/formatter src",
|
||||
parts: [
|
||||
{
|
||||
type: "skill",
|
||||
id: "formatter",
|
||||
source: { start: 0, end: 10, value: "/formatter" },
|
||||
},
|
||||
],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer preserves a selected skill after wide text", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"中 /forma".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits[0]?.parts).toEqual([
|
||||
{
|
||||
type: "skill",
|
||||
id: "formatter",
|
||||
source: { start: 3, end: 13, value: "/formatter" },
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
|
||||
// Re-enable after the upstream renderer teardown fix lands.
|
||||
test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(app.captureCharFrame()).toContain("Skill named new")
|
||||
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([])
|
||||
expect(app.captureCharFrame()).toContain("/new")
|
||||
|
||||
"task".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } },
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
|
||||
// Re-enable after the upstream renderer teardown fix lands.
|
||||
test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(app.captureCharFrame()).toContain("Apply formatter fixes")
|
||||
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([])
|
||||
expect(app.captureCharFrame()).not.toContain("/skills")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer counts queued and steering work while running", async () => {
|
||||
const app = await renderFooter({
|
||||
width: 160,
|
||||
|
||||
@@ -251,27 +251,6 @@ describe("run runtime queue", () => {
|
||||
expect(ui.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("skill commands retain their local input echo", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
onSend: (_prompt, emittedUser) => expect(emittedUser).toBe(true),
|
||||
run: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit({ text: "/review", parts: [], command: { name: "review", arguments: "", source: "skill" } })
|
||||
await task
|
||||
expect(ui.commits).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
text: "/review",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("demo commands own their local input echo", async () => {
|
||||
const { createRunDemo } = await import("../../src/mini/demo")
|
||||
const ui = createFooterApiFixture()
|
||||
|
||||
@@ -3413,70 +3413,6 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("routes skill prompts through v2.session.skill and settles without promotion", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
|
||||
const command = spyOn(client.session, "command")
|
||||
const prompt = spyOn(client.session, "prompt")
|
||||
spyOn(client.session, "skill").mockImplementation((input) => {
|
||||
request = input
|
||||
queueMicrotask(() => {
|
||||
events.push({
|
||||
id: "evt_skill",
|
||||
created: 0,
|
||||
type: "session.skill.activated",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
id: input.skill ?? "tigerstyle",
|
||||
name: input.skill ?? "tigerstyle",
|
||||
text: "skill instructions",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok(undefined) as never
|
||||
})
|
||||
|
||||
await transport.runPromptTurn({
|
||||
agent: "review",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: {
|
||||
messageID: "msg_skill",
|
||||
text: "/tigerstyle",
|
||||
parts: [],
|
||||
command: { name: "tigerstyle", arguments: "", source: "skill" },
|
||||
},
|
||||
files: [],
|
||||
includeFiles: true,
|
||||
})
|
||||
|
||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
||||
expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" })
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "system", text: '→ Skill "tigerstyle"', messageID: "msg_skill" }),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("sends inline skill attachments with a normal prompt", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
||||
@@ -16864,12 +16864,6 @@
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "repository"],
|
||||
@@ -16906,12 +16900,6 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
@@ -18850,20 +18838,17 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"slash": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoinvoke": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"location": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "location", "content"],
|
||||
"required": ["id", "name", "path", "content"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillNotFoundErrorEncoded": {
|
||||
|
||||
@@ -16864,12 +16864,6 @@
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "repository"],
|
||||
@@ -16906,12 +16900,6 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
@@ -18850,20 +18838,17 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"slash": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoinvoke": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"location": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "location", "content"],
|
||||
"required": ["id", "name", "path", "content"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillNotFoundErrorEncoded": {
|
||||
|
||||
Reference in New Issue
Block a user