diff --git a/.changeset/floppy-ads-rule.md b/.changeset/floppy-ads-rule.md new file mode 100644 index 00000000000..924f8032c92 --- /dev/null +++ b/.changeset/floppy-ads-rule.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Display skills in CLI slash command autocomplete options diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 8cd4fbfcf58..ad0e1508e6e 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -11,6 +11,7 @@ import { useKeyboard } from "@opentui/solid" import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { slashDisplay, slashMatches } from "@/kilocode/cli/cmd/command-display" // kilocode_change import * as Locale from "@/util/locale" import { createPromptHistory, @@ -169,7 +170,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { return { type: "pending" as const } } - if (!commands.some((item) => item.name === head.name)) { + if (!commands.some((item) => slashMatches(item, head.name))) { // kilocode_change return { type: "none" as const } } @@ -375,13 +376,13 @@ export function createPromptState(input: PromptInput): PromptState { const hidden = new Set(builtins.map((item) => item.name)) return [ ...(input.commands() ?? []) - .filter((item) => item.source !== "skill" && !hidden.has(item.name)) + .filter((item) => !hidden.has(item.name)) // kilocode_change - suggest skills as slash commands .map( (item) => ({ kind: "slash", name: item.name, - display: `/${item.name}${item.source === "mcp" ? ":mcp" : ""}`, + display: slashDisplay(item), // kilocode_change description: item.description, }) satisfies SlashOption, ), @@ -763,7 +764,7 @@ export function createPromptState(input: PromptInput): PromptState { } if (next.kind === "slash") { - const text = `/${next.name} ` + const text = `${next.display} ` // kilocode_change const cursor = area.cursorOffset area.cursorOffset = 0 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 12e628e19de..5908789bba3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -14,6 +14,7 @@ import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandPalette } from "../../context/command-palette" import { useTerminalDimensions } from "@opentui/solid" +import { slashDisplay } from "@/kilocode/cli/cmd/command-display" // kilocode_change import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" @@ -405,19 +406,20 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...command.slashes()] for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - const label = serverCommand.source === "mcp" ? ":mcp" : "" + // kilocode_change start - preserve suffixes like :skill when inserting selected slash commands + const display = slashDisplay(serverCommand) results.push({ - display: "/" + serverCommand.name + label, + display, description: serverCommand.description, onSelect: () => { - const newText = "/" + serverCommand.name + " " + const newText = display + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) props.input().cursorOffset = Bun.stringWidth(newText) }, }) + // kilocode_change end } results.sort((a, b) => a.display.localeCompare(b.display)) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 354f67f71db..0a3ea1ea13d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -58,6 +58,7 @@ import { import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" // kilocode_change +import { slashMatches } from "@/kilocode/cli/cmd/command-display" // kilocode_change import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" @@ -1180,7 +1181,7 @@ export function Prompt(props: PromptProps) { iife(() => { const firstLine = inputText.split("\n")[0] const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) + return sync.data.command.some((x) => slashMatches(x, command)) // kilocode_change }) ) { // Parse command from first line, preserve multi-line content in arguments diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3a19f1901f3..a2c69f4f000 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -71,6 +71,28 @@ export interface Interface { readonly list: () => Effect.Effect } +// kilocode_change start - skills can share names with slash commands +function fromSkill(item: Skill.Info): Info { + return { + name: item.name, + description: item.description, + source: "skill", + get template() { + return item.content + }, + hints: [], + } +} + +function skillName(name: string) { + return name.endsWith(":skill") ? name.slice(0, -6) : undefined +} + +function mcpName(name: string) { + return name.endsWith(":mcp") ? name.slice(0, -4) : undefined +} +// kilocode_change end + export class Service extends Context.Service()("@opencode/Command") {} export const layer = Layer.effect( @@ -156,15 +178,7 @@ export const layer = Layer.effect( for (const item of yield* skill.all()) { if (commands[item.name]) continue - commands[item.name] = { - name: item.name, - description: item.description, - source: "skill", - get template() { - return item.content - }, - hints: [], - } + commands[item.name] = fromSkill(item) // kilocode_change } return { @@ -176,13 +190,41 @@ export const layer = Layer.effect( const get = Effect.fn("Command.get")(function* (name: string) { const s = yield* InstanceState.get(state) - return s.commands[name] + // kilocode_change start + const exact = s.commands[name] + if (exact) return exact + // kilocode_change end + + // kilocode_change start + const target = skillName(name) + if (target) { + const item = yield* skill.get(target) + if (item) return fromSkill(item) + return undefined + } + // kilocode_change end + // kilocode_change start + const prompt = mcpName(name) + if (prompt) { + const cmd = s.commands[prompt] + return cmd?.source === "mcp" ? cmd : undefined + } + // kilocode_change end + return undefined // kilocode_change }) + // kilocode_change start const list = Effect.fn("Command.list")(function* () { const s = yield* InstanceState.get(state) - return Object.values(s.commands) + const result = Object.values(s.commands) + const names = new Set(result.map((item) => item.name)) + for (const item of yield* skill.all()) { + if (s.commands[item.name]?.source === "skill") continue + if (names.has(item.name)) result.push(fromSkill(item)) + } + return result }) + // kilocode_change end return Service.of({ get, list }) }), diff --git a/packages/opencode/src/kilocode/cli/cmd/command-display.ts b/packages/opencode/src/kilocode/cli/cmd/command-display.ts new file mode 100644 index 00000000000..1194fd7aeb0 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/command-display.ts @@ -0,0 +1,14 @@ +type Command = { + name: string + source?: "command" | "mcp" | "skill" +} + +export function slashDisplay(cmd: Command) { + if (cmd.source === "skill") return `/${cmd.name}:skill` + if (cmd.source === "mcp") return `/${cmd.name}:mcp` + return `/${cmd.name}` +} + +export function slashMatches(cmd: Command, name: string) { + return cmd.name === name || slashDisplay(cmd).slice(1) === name +} diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts new file mode 100644 index 00000000000..f7e81a786f4 --- /dev/null +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Command } from "../../src/command" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Command.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +describe("skill slash commands", () => { + it.live("lists and resolves skills that conflict with commands", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".kilo", "skill", "review", "SKILL.md"), + `--- +name: review +description: Skill with command conflict. +--- + +# Review Skill + +Skill content. +`, + ), + ) + + const command = yield* Command.Service + const list = yield* command.list() + const matches = list.filter((item) => item.name === "review") + + expect(matches.some((item) => item.source === "command")).toBe(true) + expect(matches.some((item) => item.source === "skill")).toBe(true) + + const cmd = yield* command.get("review") + const skill = yield* command.get("review:skill") + + expect(cmd?.source).toBe("command") + expect(skill?.source).toBe("skill") + expect(yield* Effect.promise(async () => skill?.template)).toContain("Skill content.") + }), + { + git: true, + config: { + command: { + review: { + template: "Command content.", + }, + }, + }, + }, + ), + ) +})