diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index 80f9f59bff..d63bd40c2c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -1,7 +1,13 @@ -import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" -import { createResource, createMemo } from "solid-js" +import { createResource, createMemo, For } from "solid-js" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "@tui/context/sdk" +import { useTheme } from "@tui/context/theme" +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createStore } from "solid-js/store" +import { Locale } from "@/util/locale" +import { getScrollAcceleration } from "../util/scroll" +import { useTuiConfig } from "../context/tui-config" import path from "path" export type DialogSkillProps = { @@ -11,6 +17,10 @@ export type DialogSkillProps = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const [store, setStore] = createStore({ selected: 0 }) dialog.setSize("large") const [skills] = createResource(async () => { @@ -18,33 +28,215 @@ export function DialogSkill(props: DialogSkillProps) { return Array.isArray(result.data) ? { skills: result.data, invalid: [] } : (result.data ?? { skills: [], invalid: [] }) }) - const options = createMemo[]>(() => { + const rows = createMemo(() => { const list = skills() ?? { skills: [], invalid: [] } const maxWidth = Math.max( - 0, + 16, ...list.skills.map((s) => s.name.length), ...list.invalid.map((s) => path.basename(path.dirname(s.path)).length), ) + const groups = Map.groupBy( + list.skills.toSorted((a, b) => sourceSort(a.location).localeCompare(sourceSort(b.location)) || a.name.localeCompare(b.name)), + (skill) => sourceRoot(skill.location), + ) + return [ - ...list.skills.map((skill) => ({ - title: skill.name.padEnd(maxWidth), - description: skill.description?.replace(/\s+/g, " ").trim(), - value: skill.name, - category: "Skills", - onSelect: () => { - props.onSelect(skill.name) - dialog.clear() - }, - })), - ...list.invalid.map((skill) => ({ - title: path.basename(path.dirname(skill.path)).padEnd(maxWidth), - description: `${skill.reason}: ${skill.message}`, - value: skill.path, - category: "Invalid skills", - disabled: true, - })), + ...Array.from(groups).flatMap(([root, group]) => + [ + { type: "header" as const, id: root, root, count: group.length }, + ...group.map((skill) => ({ + type: "skill" as const, + id: skill.name, + name: skill.name.padEnd(maxWidth), + rawName: skill.name, + description: skill.description?.replace(/\s+/g, " ").trim() ?? "No description", + })), + ], + ), + ...(list.invalid.length > 0 + ? [ + { type: "error-header" as const, id: "errors", count: list.invalid.length }, + ...list.invalid.map((skill) => ({ + type: "error" as const, + id: skill.path, + name: path.basename(path.dirname(skill.path)).padEnd(maxWidth), + reason: skill.reason, + message: skill.message, + location: compactLocation(skill.path), + })), + ] + : []), ] }) - return + const selectable = createMemo(() => rows().filter((row) => row.type === "skill" || row.type === "error")) + const height = createMemo(() => + Math.min( + 18, + rows().reduce((total, row) => total + (row.type === "error" ? 2 : 1), 0), + ), + ) + + function move(offset: number) { + if (selectable().length === 0) return + const next = store.selected + offset + setStore("selected", next < 0 ? selectable().length - 1 : next >= selectable().length ? 0 : next) + } + + function select() { + const row = selectable()[store.selected] + if (!row || row.type !== "skill") return + props.onSelect(row.rawName) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "up") { + evt.preventDefault() + evt.stopPropagation() + move(-1) + return + } + if (evt.name === "down") { + evt.preventDefault() + evt.stopPropagation() + move(1) + return + } + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + select() + } + }) + + const title = createMemo(() => { + const list = skills() ?? { skills: [], invalid: [] } + return list.invalid.length > 0 ? `Skills (${list.skills.length}) ${list.invalid.length} skipped` : `Skills (${list.skills.length})` + }) + + return ( + + + {title()} + + ( + enter + ) invoke Escape to close + + + + + {(row) => { + if (row.type === "header") return + if (row.type === "error-header") return + const selected = createMemo(() => selectable()[store.selected]?.id === row.id) + if (row.type === "error") { + return ( + setStore("selected", selectable().findIndex((item) => item.id === row.id))} + > + + ! + + {row.name} + + {row.reason}: + {Locale.truncate(row.message, 46)} + + {Locale.truncateLeft(row.location, 66)} + + ) + } + return ( + { + setStore("selected", selectable().findIndex((item) => item.id === row.id)) + props.onSelect(row.rawName) + dialog.clear() + }} + > + + + {row.name} + + + {Locale.truncate(row.description, 46)} + + + + ) + }} + + + + ) +} + +function compactLocation(location: string) { + const home = process.env.HOME + if (!home) return location + return location.startsWith(home) ? `~${location.slice(home.length)}` : location +} + +function sourceSort(location: string) { + const label = sourceLabel(location) + const rank = label === "Global" ? 0 : label === "Project" ? 1 : label === "User" ? 2 : label === "Registry" ? 3 : 4 + return `${rank}:${sourceRoot(location)}` +} + +function sourceLabel(location: string) { + return sourceRootLabel(sourceRoot(location)) +} + +function sourceRootLabel(root: string) { + const compact = compactLocation(root) + if (compact.startsWith("~/.agents/") || compact.startsWith("~/.claude/")) return "Global" + if (compact.includes("/.opencode/cache/skills")) return "Registry" + if (root.startsWith(process.cwd())) return "Project" + if (compact.startsWith("~/")) return "User" + return "Project" +} + +function sourceRoot(location: string) { + return path.dirname(path.dirname(location)) +} + +function SourceRow(props: { root: string; count: number }) { + const { theme } = useTheme() + return ( + + + {sourceRootLabel(props.root)} + {compactLocation(props.root)}/ + ({props.count}) + + + ) +} + +function ErrorHeader(props: { count: number }) { + const { theme } = useTheme() + return ( + + + Skipped skills with errors + ({props.count}) + + + ) }