diff --git a/.changeset/skill-slash-command.md b/.changeset/skill-slash-command.md new file mode 100644 index 00000000000..7544c6ac7c0 --- /dev/null +++ b/.changeset/skill-slash-command.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +--- + +Invoke skills as slash commands in the CLI TUI and VS Code sidebar. Each skill is available as `/` when the name is free and always as `/skill:`, so you can run a skill directly without going through the skill picker dialog. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 6d99d009be2..fc7a98f73b7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -837,6 +837,13 @@ export const PromptInput: Component = (props) => { onMouseEnter={() => slash.setIndex(idx() + offset)} > /{cmd.name} + + + {cmd.source === "skill" + ? language.t("prompt.slash.badge.skill") + : language.t("prompt.slash.badge.mcp")} + + {cmd.description} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 8221cc1bb52..44999936bf1 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -113,7 +113,23 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const commands = (): SlashCommandEntry[] => { const names = new Set(client.map((c) => c.name)) - const filtered = server().filter((c) => !names.has(c.name)) + // Hide the `/skill:` alias when the bare `/` is actually shown as a skill. + // Skills register both forms server-side; showing both would duplicate entries. But if + // a client action shadows the bare name, we must keep the alias so the skill stays + // reachable. + const bareSkillsShown = new Set( + server() + .filter((c) => c.source === "skill" && !c.name.startsWith("skill:") && !names.has(c.name)) + .map((c) => c.name), + ) + const filtered = server().filter((c) => { + if (names.has(c.name)) return false + if (c.source === "skill" && c.name.startsWith("skill:")) { + const bare = c.name.slice("skill:".length) + if (bareSkillsShown.has(bare)) return false + } + return true + }) return [...client, ...filtered] } diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index 64bfde404a9..247eb650749 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -113,6 +113,29 @@ font-size: 11px; } +.slash-command-badge { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + opacity: 0.8; +} + +.slash-command-badge[data-source="skill"] { + background: var(--vscode-charts-purple, var(--vscode-badge-background)); + color: var(--vscode-badge-foreground); +} + +.slash-command-badge[data-source="mcp"] { + background: var(--vscode-charts-blue, var(--vscode-badge-background)); + color: var(--vscode-badge-foreground); +} + .slash-command-empty { padding: 8px 10px; font-size: 12px; diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index e949d3f28fa..05e06e135a8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -363,9 +363,22 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...command.slashes()] + // kilocode_change start - track skill names already shown under a short alias + const skillBareNames = new Set() + for (const c of sync.data.command) { + if (c.source === "skill" && !c.name.startsWith("skill:")) skillBareNames.add(c.name) + } + // kilocode_change end for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - const label = serverCommand.source === "mcp" ? ":mcp" : "" + // kilocode_change start - expose skills; hide the /skill: alias when the bare / is already shown + if ( + serverCommand.source === "skill" && + serverCommand.name.startsWith("skill:") && + skillBareNames.has(serverCommand.name.slice("skill:".length)) + ) + continue + const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" + // kilocode_change end results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 23038966413..73debdc6a1a 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -156,18 +156,31 @@ export const layer = Layer.effect( } } + // kilocode_change start - expose skills as slash commands with /skill: alias for (const item of yield* skill.all()) { - if (commands[item.name]) continue - commands[item.name] = { - name: item.name, + const aliased = `skill:${item.name}` + commands[aliased] = { + name: aliased, description: item.description, source: "skill", get template() { return item.content }, - hints: [], + hints: [item.name], + } + if (!commands[item.name]) { + commands[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + return item.content + }, + hints: [aliased], + } } } + // kilocode_change end return { commands, diff --git a/packages/opencode/test/kilocode/skill-slash-command.test.ts b/packages/opencode/test/kilocode/skill-slash-command.test.ts new file mode 100644 index 00000000000..5b4f1721f93 --- /dev/null +++ b/packages/opencode/test/kilocode/skill-slash-command.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Command } from "../../src/command" +import { Instance } from "../../src/project/instance" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Command.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("skills as slash commands", () => { + it.live("registers the built-in kilo-config skill under both /kilo-config and /skill:kilo-config", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const svc = yield* Command.Service + const commands = yield* svc.list() + + const bare = commands.find((c) => c.name === "kilo-config") + const aliased = commands.find((c) => c.name === "skill:kilo-config") + + expect(bare).toBeDefined() + expect(bare!.source).toBe("skill") + expect(aliased).toBeDefined() + expect(aliased!.source).toBe("skill") + expect(aliased!.description).toBe(bare!.description) + + // Both should resolve to the same skill content. + const bareTemplate = yield* Effect.promise(async () => await bare!.template) + const aliasedTemplate = yield* Effect.promise(async () => await aliased!.template) + expect(bareTemplate).toBe(aliasedTemplate) + expect(bareTemplate.length).toBeGreaterThan(0) + }), + { git: true }, + ), + ) + + it.live("keeps /skill: reachable even when the bare name is shadowed by a user command", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + // Write a user command that collides with the built-in kilo-config skill name. + yield* Effect.promise(() => + Bun.write( + `${dir}/.kilo/command/kilo-config.md`, + `--- +description: user override command +--- + +user template body +`, + ), + ) + + const svc = yield* Command.Service + const commands = yield* svc.list() + + const bare = commands.find((c) => c.name === "kilo-config") + const aliased = commands.find((c) => c.name === "skill:kilo-config") + + expect(bare).toBeDefined() + expect(bare!.source).toBe("command") + expect(aliased).toBeDefined() + expect(aliased!.source).toBe("skill") + + const bareTemplate = yield* Effect.promise(async () => await bare!.template) + const aliasedTemplate = yield* Effect.promise(async () => await aliased!.template) + expect(bareTemplate).toContain("user template body") + expect(aliasedTemplate).not.toBe(bareTemplate) + }), + { git: true }, + ), + ) +})