Skip to content
Open
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
26 changes: 24 additions & 2 deletions packages/tui/src/component/dialog-skill.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -10,12 +13,28 @@ export type DialogSkillProps = {
onSelect: (skill: string) => void
}

export function skillSource(location: string, directory: string) {
if (location === "<built-in>") 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<unknown>()

const [skills] = createResource(() =>
Expand All @@ -34,13 +53,16 @@ export function DialogSkill(props: DialogSkillProps) {

const options = createMemo<DialogSelectOption<string>[]>(() => {
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()
Expand Down
7 changes: 5 additions & 2 deletions packages/tui/src/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/<name>", 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,
Expand Down
129 changes: 129 additions & 0 deletions packages/tui/test/cli/tui/dialog-skill.test.tsx
Original file line number Diff line number Diff line change
@@ -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: "<built-in>", 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(() => <DialogSkill onSelect={(skill) => selected.push(skill)} />)
return <box />
}

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const resolvedConfig = createTuiResolvedConfig({})
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
onCleanup(off)

return (
<TestTuiContexts directory={project} paths={{ home: tmp.path, state, worktree: project }}>
<SDKProvider url="http://test" fetch={fetch}>
<LocationProvider location={{ directory: project }}>
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={resolvedConfig}>
<KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
</LocationProvider>
</SDKProvider>
</TestTuiContexts>
)
}

const app = await testRender(() => <Harness />, { 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()
}
})
41 changes: 41 additions & 0 deletions packages/tui/test/component/dialog-skill.test.ts
Original file line number Diff line number Diff line change
@@ -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("<built-in>", 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")
})
})
Loading