Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

## [Unreleased] — feat/on-demand-skill-picker

### Added

- `type: core | non-core` frontmatter field for SKILL.md files
- `skills.autoLoad` config option: `"all"` | `"core"` | `"none"` (default: `"all"`)
- Skill scanner: `scanAvailableSkills()` returning typed metadata
- Skill loader: `loadSkillContent()` for session-scoped preview
- Backend skill API group: `GET /skills`, `POST /skills/load`
- `/skills` slash command with SolidJS Dialog picker
- Core/Non-Core grouped sections in picker with typeahead filtering
- Session-loaded skill indicators (✓ marker) in picker list
- i18n strings for all new UI text

### Changed

- `session/system.ts`: skill injection now filtered by `autoLoad` + `type`

### Backward Compatible

- `autoLoad` defaults to `"all"` — zero behaviour change for existing users
- SKILL.md files with no `type` field default to `"non-core"` — no errors
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,48 @@ Learn more about [agents](https://opencode.ai/docs/agents).

For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).

### Skills

OpenCode uses skills — structured markdown files containing reusable instructions
that are injected into the system prompt. Skills are auto-loaded at session start,
but you can control this behaviour.

#### autoLoad config

Control which skills are injected into the system prompt at session start:

```json
{
"skills": {
"autoLoad": "core"
}
}
```

| Value | Behaviour |
|-------|-----------|
| `"all"` | All skills injected at startup (default — existing behaviour) |
| `"core"` | Only skills tagged `type: core` in their SKILL.md frontmatter |
| `"none"` | No skills injected; use /skills picker to load on demand |

#### /skills picker

Type `/skills` in the chat input to open the interactive skill picker.
Browse core and non-core skills, filter by typing, press Enter to load
a skill into the current session.

#### Tagging a skill as core

Add a `type` field to your skill's SKILL.md frontmatter:

```yaml
---
name: my-skill
type: core
description: Does X
---
```

### Contributing

If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
Expand Down
52 changes: 52 additions & 0 deletions docs/adr/002-on-demand-skill-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR 002: On-Demand Skill Loading via /skills Command

## Status

Accepted

## Context

All skills were injected into the system prompt at every session start,
consuming context window tokens regardless of relevance. There was no way
to browse or selectively load a single skill mid-session. The only workaround
(an fzf picker) required an external terminal window — broken UX.

## Decision

- Added optional `type: core | non-core` field to SKILL.md frontmatter
(default: non-core — conservative, core is opt-in)
- Added `skills.autoLoad: "all" | "core" | "none"` config option
(default: "all" — full backward compatibility)
- Built /skills SolidJS Dialog picker using @opencode-ai/ui primitives,
matching the DialogSelectModel pattern
- Skills loaded via picker are session-scoped only — not persisted,
not injected into system prompt
- Backend skill API group: GET /skills, POST /skills/load

## Alternatives Considered

- **fzf in bash tool** — Rejected: no TTY available in OpenCode's bash tool,
keyboard input never reaches subprocess
- **Binary true/false autoLoad** — Rejected: too coarse, no middle ground
for users who want engineering skills but not domain-specific ones
- **External terminal picker** — Rejected: breaks single-window workflow

## Consequences

### Positive

- Context window used only for skills relevant to the current task
- Users can discover skills they didn't know existed via the picker
- Backward compatible — existing users see zero behaviour change
- Core skills (engineering workflow) auto-load; domain skills on demand

### Negative

- Users must tag their SKILL.md files with `type: core` to use autoLoad: "core"
- The preview panel (width ≥ 120) is not yet implemented — deferred

## References

- skill-picker.tsx
- packages/core/src/v1/config/skills.ts
- packages/opencode/src/session/system.ts
159 changes: 159 additions & 0 deletions packages/app/src/components/skill-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Component, createMemo, createResource, createSignal, Match, Show, Switch } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { makeEventListener } from "@solid-primitives/event-listener"

interface SkillItem {
name: string
description?: string
type: "core" | "non-core"
loaded: boolean
}

export const SkillPicker: Component = () => {
const sdk = useSDK()
const dialog = useDialog()
const language = useLanguage()
const [width, setWidth] = createSignal(window.innerWidth)
const [highlighted, setHighlighted] = createSignal<string | undefined>()

makeEventListener(window, "resize", () => setWidth(window.innerWidth))

const [skills, { refetch }] = createResource(async () => {
const res = await fetch(`${sdk().url}/skills?directory=${encodeURIComponent(sdk().directory)}`, {
headers: { "x-opencode-directory": encodeURIComponent(sdk().directory) },
})
if (!res.ok) return []
return (await res.json()) as SkillItem[]
})

const coreSkills = createMemo(() => skills()?.filter((s) => s.type === "core") ?? [])
const nonCoreSkills = createMemo(() => skills()?.filter((s) => s.type === "non-core") ?? [])

const handleLoad = async (name: string) => {
try {
const res = await fetch(`${sdk().url}/skills/load?directory=${encodeURIComponent(sdk().directory)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": encodeURIComponent(sdk().directory),
},
body: JSON.stringify({ name }),
})
if (!res.ok) return
refetch()
} catch {
// ignore
}
}

const [previewContent, setPreviewContent] = createSignal<string | undefined>()

let pendingPreview: string | undefined
const fetchPreview = (name: string | undefined) => {
pendingPreview = name
if (!name || !previewEnabled()) {
setPreviewContent(undefined)
return
}
void (async () => {
if (pendingPreview !== name) return
try {
const url = `${sdk().url}/skills/content?name=${encodeURIComponent(name)}&directory=${encodeURIComponent(sdk().directory)}`
const res = await fetch(url, {
headers: { "x-opencode-directory": encodeURIComponent(sdk().directory) },
})
if (pendingPreview !== name) return
if (!res.ok) return
const data = await res.json()
if (pendingPreview !== name) return
setPreviewContent(data.content.substring(0, 2000))
} catch {
// ignore
}
})()
}

const previewEnabled = createMemo(() => width() >= 120)

const wideLayout = (content: any) => (
<div class="flex gap-3" style="max-height: min(70vh, 600px);">
<div class="flex-1 min-w-0">{content}</div>
<Show when={previewEnabled()}>
<div class="w-80 shrink-0 border-l border-border-default pl-3 overflow-y-auto">
<Show when={highlighted()} fallback={<div class="text-12-regular text-text-weaker p-2">{language.t("command.skills.preview.hint")}</div>}>
<div class="flex items-center gap-2 mb-2">
<span class="text-12-medium text-text-default truncate">{highlighted()}</span>
</div>
<Switch>
<Match when={previewContent() === undefined}>
<div class="text-12-regular text-text-weaker p-2">{language.t("common.loading")}</div>
</Match>
<Match when={previewContent() !== undefined}>
<pre class="text-12-regular text-text-default whitespace-pre-wrap break-words font-mono leading-relaxed p-2 rounded-sm bg-bg-subtle">{previewContent()}</pre>
</Match>
</Switch>
</Show>
</div>
</Show>
</div>
)

const listContent = (
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("command.skills.empty")}
loadingMessage={language.t("common.loading")}
key={(x: SkillItem) => x.name}
items={skills() ?? []}
filterKeys={["name", "description"]}
groupBy={(x) => (x.type === "core" ? language.t("command.skills.group.core") : language.t("command.skills.group.nonCore"))}
sortGroupsBy={(a, b) => {
const order = [language.t("command.skills.group.core"), language.t("command.skills.group.nonCore")]
return order.indexOf(a.category) - order.indexOf(b.category)
}}
groupHeader={(group) => (
<div class="flex items-center gap-2 px-2 py-1 text-12-medium text-text-weaker uppercase tracking-wider">
<span>{group.category}</span>
<span class="text-text-subtle">({group.items.length})</span>
</div>
)}
onSelect={(item: SkillItem | undefined) => {
if (!item) return
if (!item.loaded) handleLoad(item.name)
}}
onMove={(item: SkillItem | undefined) => {
const name = item?.name
setHighlighted(name)
fetchPreview(name)
}}
>
{(item: SkillItem) => (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{item.name}</span>
</div>
<Show when={item.description}>
<span class="text-12-regular text-text-weaker truncate">{item.description}</span>
</Show>
</div>
<Show when={item.loaded}>
<Icon name="check" size="small" class="text-icon-success shrink-0" />
</Show>
</div>
)}
</List>
)

return (
<Dialog title={language.t("command.skills.title")} size="large" transition>
{wideLayout(listContent)}
</Dialog>
)
}
8 changes: 8 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const dict = {
"command.category.terminal": "Terminal",
"command.category.model": "Model",
"command.category.mcp": "MCP",
"command.category.skills": "Skills",
"command.category.agent": "Agent",
"command.category.permissions": "Permissions",
"command.category.workspace": "Workspace",
Expand Down Expand Up @@ -66,6 +67,13 @@ export const dict = {
"command.model.choose.description": "Select a different model",
"command.mcp.toggle": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"command.skills.picker": "Skills Picker",
"command.skills.picker.description": "Browse and load available skills",
"command.skills.title": "Skills",
"command.skills.empty": "No skills found",
"command.skills.group.core": "Core Skills",
"command.skills.group.nonCore": "Non-Core Skills",
"command.skills.preview.hint": "Select a skill to preview",
"command.agent.cycle": "Cycle agent",
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const skillsCommand = withCategory(language.t("command.category.skills"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))

const isAutoAcceptActive = () => {
Expand Down Expand Up @@ -539,6 +540,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
]

const skillsCmds = () => [
skillsCommand({
id: "skills.picker",
title: language.t("command.skills.picker"),
description: language.t("command.skills.picker.description"),
slash: "skills",
onSelect: () => {
void import("@/components/skill-picker").then((x) => {
dialog.show(() => <x.SkillPicker />)
})
},
}),
]

const agentCmds = () => [
agentCommand({
id: "agent.cycle",
Expand Down Expand Up @@ -581,6 +596,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...skillsCmds(),
...agentCmds(),
...permissionsCmds(),
])
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
Expand Down
Loading
Loading