From 7b9f01e8c49247c59ceed1d8565169c6cce80eef Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Wed, 19 Aug 2026 13:54:43 -0700 Subject: [PATCH 1/2] feat(tui): show skills in slash autocomplete and group /skills dialog by source Skills registered as server commands were skipped in the slash autocomplete, so / was not discoverable. Include them with a :skill label, matching the :mcp convention. The /skills dialog listed every skill under one heading. Group entries by source (Project, Global, Built-in) derived from the skill location. Closes #7846 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTuWSW35XoRbPYixZr8oKQ --- packages/tui/src/component/dialog-skill.tsx | 19 ++- .../tui/src/component/prompt/autocomplete.tsx | 3 +- .../tui/test/cli/tui/dialog-skill.test.tsx | 129 ++++++++++++++++++ .../tui/test/component/dialog-skill.test.ts | 27 ++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 packages/tui/test/cli/tui/dialog-skill.test.tsx create mode 100644 packages/tui/test/component/dialog-skill.test.ts diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index e962a6e7c3e0..32a5874083b7 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,7 +1,9 @@ +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 { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" @@ -10,12 +12,22 @@ 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) + if (relative === ".." || relative.startsWith(".." + path.sep)) return "Global" + return "Project" +} + export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() const { theme } = useTheme() + const formatter = usePathFormatter() dialog.setSize("large") + const source = (location: string) => skillSource(location, formatter.path()) + const [loadError, setLoadError] = createSignal() const [skills] = createResource(() => @@ -34,13 +46,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..cf88b660fdb6 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -448,8 +448,7 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...slashes()] 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" : "" 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..6767826509f4 --- /dev/null +++ b/packages/tui/test/component/dialog-skill.test.ts @@ -0,0 +1,27 @@ +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") + }) +}) From d87e288fa4ed75a59091ae7b0e4ade1e67c5f6db Mon Sep 17 00:00:00 2001 From: mccaffrey-jonathan Date: Thu, 20 Aug 2026 03:04:00 +0000 Subject: [PATCH 2/2] fix(tui): correct skill source classification and skill/builtin collisions - skillSource treated a location with no common root with the project as "Project": path.relative returns an absolute path across Windows drives and UNC shares, so neither ".." check fired. Guard with path.isAbsolute. - Classify against the worktree rather than the cwd. Project skills come from .opencode directories walked from the cwd up to the worktree root, so a session started in a subdirectory labelled its own project skills "Global". - Skip a skill whose name collides with a builtin slash command. Selecting an entry inserts "/" without the ":skill" label, so the collision rendered a second row that silently ran the builtin. --- packages/tui/src/component/dialog-skill.tsx | 11 +++++++++-- packages/tui/src/component/prompt/autocomplete.tsx | 4 ++++ packages/tui/test/component/dialog-skill.test.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 32a5874083b7..2b61e437cf36 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -4,6 +4,7 @@ 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" @@ -15,7 +16,9 @@ export type DialogSkillProps = { export function skillSource(location: string, directory: string) { if (location === "") return "Built-in" const relative = path.relative(directory, location) - if (relative === ".." || relative.startsWith(".." + path.sep)) return "Global" + // 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" } @@ -23,10 +26,14 @@ export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() const { theme } = useTheme() + const paths = useTuiPaths() const formatter = usePathFormatter() dialog.setSize("large") - const source = (location: string) => skillSource(location, formatter.path()) + // 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() diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index cf88b660fdb6..92832bc2299f 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -446,9 +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) { 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/component/dialog-skill.test.ts b/packages/tui/test/component/dialog-skill.test.ts index 6767826509f4..4ab2980e8d98 100644 --- a/packages/tui/test/component/dialog-skill.test.ts +++ b/packages/tui/test/component/dialog-skill.test.ts @@ -25,3 +25,17 @@ describe("skill source", () => { 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") + }) +})