Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/floppy-ads-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Display skills in CLI slash command autocomplete options
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
64 changes: 53 additions & 11 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ export interface Interface {
readonly list: () => Effect.Effect<Info[]>
}

// 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<Service, Interface>()("@opencode/Command") {}

export const layer = Layer.effect(
Expand Down Expand Up @@ -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 {
Expand All @@ -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 })
}),
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/kilocode/cli/cmd/command-display.ts
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 57 additions & 0 deletions packages/opencode/test/kilocode/skill-command-autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
},
},
},
),
)
})
Loading