diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index e962a6e7c3e0..2b61e437cf36 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,7 +1,10 @@ +import path from "path" import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { createResource, createMemo, createSignal } from "solid-js" import { useDialog } from "../ui/dialog" +import { usePathFormatter } from "../context/path-format" +import { useTuiPaths } from "../context/runtime" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" @@ -10,12 +13,28 @@ export type DialogSkillProps = { onSelect: (skill: string) => void } +export function skillSource(location: string, directory: string) { + if (location === "") return "Built-in" + const relative = path.relative(directory, location) + // path.relative returns an absolute path when the two share no root - different + // Windows drives, a UNC share - and an absolute result is outside the project too. + if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return "Global" + return "Project" +} + export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() const { theme } = useTheme() + const paths = useTuiPaths() + const formatter = usePathFormatter() dialog.setSize("large") + // Classify against the worktree, not the cwd: project skills are discovered from + // .opencode directories between the cwd and the worktree root, so a session started + // in a subdirectory would otherwise label its own project skills "Global". + const source = (location: string) => skillSource(location, paths.worktree || formatter.path()) + const [loadError, setLoadError] = createSignal() const [skills] = createResource(() => @@ -34,13 +53,16 @@ export function DialogSkill(props: DialogSkillProps) { const options = createMemo[]>(() => { if (showError()) return [] - const list = skills() ?? [] + const rank = { Project: 0, Global: 1, "Built-in": 2 } + const list = (skills() ?? []).toSorted( + (a, b) => rank[source(a.location)] - rank[source(b.location)] || a.name.localeCompare(b.name), + ) const maxWidth = Math.max(0, ...list.map((s) => s.name.length)) return list.map((skill) => ({ title: skill.name.padEnd(maxWidth), description: skill.description?.replace(/\s+/g, " ").trim(), value: skill.name, - category: "Skills", + category: source(skill.location), onSelect: () => { props.onSelect(skill.name) dialog.clear() diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83eb7..92832bc2299f 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -446,10 +446,13 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] + const builtins = new Set(results.map((item) => item.display)) for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - const label = serverCommand.source === "mcp" ? ":mcp" : "" + const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" + // Selecting an entry inserts "/", not the label, so a skill sharing a + // name with a builtin would render a second row that runs the builtin instead. + if (serverCommand.source === "skill" && builtins.has("/" + serverCommand.name)) continue results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, diff --git a/packages/tui/test/cli/tui/dialog-skill.test.tsx b/packages/tui/test/cli/tui/dialog-skill.test.tsx new file mode 100644 index 000000000000..6929ac058335 --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-skill.test.tsx @@ -0,0 +1,129 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import { tmpdir } from "../../fixture/fixture" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { json } from "../../fixture/tui-sdk" +import { TestTuiContexts } from "../../fixture/tui-environment" + +async function wait(fn: () => boolean, timeout = 5000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +test("dialog skill groups skills by source", async () => { + await using tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + const project = path.join(tmp.path, "project") + await mkdir(project, { recursive: true }) + + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/skill") + return json([ + { + name: "deploy", + description: "Deploy the app", + location: path.join(project, ".opencode", "skill", "deploy", "SKILL.md"), + content: "", + }, + { + name: "review-code", + description: "Review pending changes", + location: path.join(tmp.path, "global", "skill", "review-code", "SKILL.md"), + content: "", + }, + { name: "customize-opencode", description: "Configure opencode", location: "", content: "" }, + ]) + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + const [ + { DialogProvider, useDialog }, + { DialogSkill }, + { KVProvider }, + { LocationProvider }, + { SDKProvider }, + { ThemeProvider }, + { TuiConfigProvider }, + { ToastProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + ] = await Promise.all([ + import("../../../src/ui/dialog"), + import("../../../src/component/dialog-skill"), + import("../../../src/context/kv"), + import("../../../src/context/location"), + import("../../../src/context/sdk"), + import("../../../src/context/theme"), + import("../../../src/config"), + import("../../../src/ui/toast"), + import("../../../src/keymap"), + ]) + + const selected: string[] = [] + + function Content() { + const dialog = useDialog() + dialog.replace(() => selected.push(skill)} />) + return + } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({}) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + + return ( + + + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true, width: 100, height: 30 }) + try { + let frame = "" + await wait(() => { + app.renderer.requestRender() + frame = app.captureCharFrame() + return frame.includes("customize-opencode") + }) + + expect(frame).toContain("Project") + expect(frame).toContain("Global") + expect(frame).toContain("Built-in") + expect(frame).toContain("deploy") + expect(frame).toContain("review-code") + expect(frame.indexOf("Project")).toBeLessThan(frame.indexOf("Global")) + expect(frame.indexOf("Global")).toBeLessThan(frame.indexOf("Built-in")) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/component/dialog-skill.test.ts b/packages/tui/test/component/dialog-skill.test.ts new file mode 100644 index 000000000000..4ab2980e8d98 --- /dev/null +++ b/packages/tui/test/component/dialog-skill.test.ts @@ -0,0 +1,41 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { skillSource } from "../../src/component/dialog-skill" + +const directory = path.resolve("/home/user/project") + +describe("skill source", () => { + test("classifies the built-in skill", () => { + expect(skillSource("", directory)).toBe("Built-in") + }) + + test("classifies skills inside the project as Project", () => { + expect(skillSource(path.join(directory, ".opencode", "skill", "deploy", "SKILL.md"), directory)).toBe("Project") + }) + + test("classifies the project directory itself as Project", () => { + expect(skillSource(directory, directory)).toBe("Project") + }) + + test("classifies skills outside the project as Global", () => { + expect(skillSource(path.resolve("/home/user/.config/opencode/skill/review/SKILL.md"), directory)).toBe("Global") + }) + + test("does not treat sibling directories with a shared prefix as Project", () => { + expect(skillSource(path.resolve("/home/user/project-other/skill/SKILL.md"), directory)).toBe("Global") + }) +}) + +describe("skill source on windows paths", () => { + // path.relative returns an absolute path when the two share no root, so these + // would fall through to "Project" without an isAbsolute guard. + test.skipIf(process.platform !== "win32")("classifies a skill on another drive as Global", () => { + expect(skillSource("C:\\Users\\me\\.config\\opencode\\skill\\review\\SKILL.md", "D:\\repos\\project")).toBe( + "Global", + ) + }) + + test.skipIf(process.platform !== "win32")("classifies a skill on a UNC share as Global", () => { + expect(skillSource("\\\\server\\share\\skill\\SKILL.md", "C:\\repos\\project")).toBe("Global") + }) +})