|
| 1 | +import { TextAttributes } from "@opentui/core" |
| 2 | +import type { |
| 3 | + SkillStackAgent, |
| 4 | + SkillStackCard, |
| 5 | + SkillStackInstallMode, |
| 6 | + SkillStackPrepared, |
| 7 | + SkillStackScope, |
| 8 | +} from "@opencode-ai/sdk/v2" |
| 9 | +import { createMemo, createResource, createSignal } from "solid-js" |
| 10 | +import { useData } from "../context/data" |
| 11 | +import { useSDK } from "../context/sdk" |
| 12 | +import { useTheme } from "../context/theme" |
| 13 | +import { useDialog } from "../ui/dialog" |
| 14 | +import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" |
| 15 | +import { useToast } from "../ui/toast" |
| 16 | +import { errorMessage } from "../util/error" |
| 17 | +import { StatusBox } from "./dialog-custom-provider" |
| 18 | + |
| 19 | +// Fork-only: `/stacks` — browse the CoDev SkillHub's Skill Stacks and install |
| 20 | +// one into this project or globally. Every hub request goes through the |
| 21 | +// server's v2.skillhub routes, so the install lands on the host that runs the |
| 22 | +// agent (the same place `codev skill stack pull` would put it) and the TUI |
| 23 | +// never touches the hub itself. |
| 24 | +// |
| 25 | +// Architecture note (see dialog-configure.tsx): dialog.replace stores the |
| 26 | +// element FACTORY and solid may re-invoke it on unrelated re-renders, so all |
| 27 | +// flow state lives in the awaited controller below; the views it shows are |
| 28 | +// pure. And replace() fires every prior step's onClose — including when this |
| 29 | +// flow advances itself — so "did the user escape?" is answered by checking |
| 30 | +// whether the dialog still shows the step we put up, never by onClose. |
| 31 | + |
| 32 | +interface FlowContext { |
| 33 | + dialog: ReturnType<typeof useDialog> |
| 34 | + sdk: ReturnType<typeof useSDK> |
| 35 | + toast: ReturnType<typeof useToast> |
| 36 | + data: ReturnType<typeof useData> |
| 37 | +} |
| 38 | + |
| 39 | +// DialogSelect is single-choice (a per-row toggle would need its own keybind, |
| 40 | +// like dialog.mcp.toggle), so the agent set is picked from presets. CoDev Code |
| 41 | +// is in every one and is folded in server-side regardless. |
| 42 | +const AGENT_PRESETS: { title: string; description: string; value: SkillStackAgent[] }[] = [ |
| 43 | + { title: "CoDev Code only", description: "this agent", value: ["codev"] }, |
| 44 | + { title: "CoDev Code + Claude Code", description: ".claude/skills is shared", value: ["codev", "claude"] }, |
| 45 | + { |
| 46 | + title: "All agents", |
| 47 | + description: "CoDev Code, Claude Code, Codex, OpenCode", |
| 48 | + value: ["codev", "claude", "codex", "opencode"], |
| 49 | + }, |
| 50 | +] |
| 51 | + |
| 52 | +// The hub's listing is paged at 50; the local fuzzy filter covers that. A hub |
| 53 | +// with more public stacks than one page needs server-side search wired to |
| 54 | +// onFilter, which is not worth a debounce until it happens. |
| 55 | +const LIST_LIMIT = "50" |
| 56 | + |
| 57 | +export function DialogStack() { |
| 58 | + const ctx: FlowContext = { dialog: useDialog(), sdk: useSDK(), toast: useToast(), data: useData() } |
| 59 | + const { theme } = useTheme() |
| 60 | + ctx.dialog.setSize("large") |
| 61 | + |
| 62 | + const [loadError, setLoadError] = createSignal<unknown>() |
| 63 | + const [stacks] = createResource(() => |
| 64 | + ctx.sdk.client.v2.skillhub.stack |
| 65 | + .list({ limit: LIST_LIMIT }, { throwOnError: true }) |
| 66 | + .then((result) => result.data.data.items) |
| 67 | + // Catch so the rejected resource never reaches the memo below: reading |
| 68 | + // stacks() in an errored state re-throws and tears down the dialog. |
| 69 | + .catch((error: unknown) => { |
| 70 | + setLoadError(error) |
| 71 | + return undefined |
| 72 | + }), |
| 73 | + ) |
| 74 | + const showError = createMemo(() => Boolean(loadError())) |
| 75 | + |
| 76 | + const options = createMemo<DialogSelectOption<SkillStackCard>[]>(() => { |
| 77 | + if (showError()) return [] |
| 78 | + return (stacks() ?? []).map((card) => ({ |
| 79 | + title: card.name, |
| 80 | + description: describe(card), |
| 81 | + value: card, |
| 82 | + onSelect: () => void runInstallFlow(ctx, card), |
| 83 | + })) |
| 84 | + }) |
| 85 | + |
| 86 | + return ( |
| 87 | + <DialogSelect |
| 88 | + title="Skill Stacks" |
| 89 | + placeholder="Search stacks..." |
| 90 | + options={options()} |
| 91 | + renderFilter={!showError()} |
| 92 | + locked={showError()} |
| 93 | + emptyView={ |
| 94 | + showError() ? ( |
| 95 | + <box paddingLeft={4} paddingRight={4}> |
| 96 | + <text fg={theme.error} attributes={TextAttributes.BOLD}> |
| 97 | + Could not load Skill Stacks |
| 98 | + </text> |
| 99 | + <text fg={theme.textMuted}>{errorMessage(loadError())}</text> |
| 100 | + </box> |
| 101 | + ) : stacks.loading ? ( |
| 102 | + <box paddingLeft={4}> |
| 103 | + <text fg={theme.textMuted}>Loading...</text> |
| 104 | + </box> |
| 105 | + ) : undefined |
| 106 | + } |
| 107 | + /> |
| 108 | + ) |
| 109 | +} |
| 110 | + |
| 111 | +function describe(card: SkillStackCard): string { |
| 112 | + const counts = `${card.skillCount} skill${card.skillCount === 1 ? "" : "s"} · ${card.memberCount} member${card.memberCount === 1 ? "" : "s"}` |
| 113 | + const summary = card.description.replace(/\s+/g, " ").trim() |
| 114 | + return summary ? `${counts} — ${summary}` : counts |
| 115 | +} |
| 116 | + |
| 117 | +async function runInstallFlow(ctx: FlowContext, card: SkillStackCard) { |
| 118 | + const scope = await showSelect<SkillStackScope>(ctx.dialog, `Install ${card.name} to:`, [ |
| 119 | + { title: "This project", description: "recommended", value: "project" }, |
| 120 | + { title: "Global", description: "every project on this machine", value: "global" }, |
| 121 | + ]) |
| 122 | + if (scope === null) return |
| 123 | + |
| 124 | + const agents = await showSelect(ctx.dialog, "Install for which agents?", AGENT_PRESETS) |
| 125 | + if (agents === null) return |
| 126 | + |
| 127 | + const downloading = () => <StatusBox text={`Downloading ${card.name}...`} /> |
| 128 | + ctx.dialog.replace(downloading) |
| 129 | + const prepared = await ctx.sdk.client.v2.skillhub.stack.prepare({ |
| 130 | + slug: card.slug, |
| 131 | + skillStackPrepareInput: { scope, agents }, |
| 132 | + }) |
| 133 | + if (!prepared.data) return fail(ctx, `Could not prepare ${card.name}`, prepared.error) |
| 134 | + const plan = prepared.data.data |
| 135 | + if (!stillShowing(ctx.dialog, downloading)) return void discard(ctx, plan) |
| 136 | + |
| 137 | + const mode = |
| 138 | + plan.conflicts.length === 0 |
| 139 | + ? "skip-existing" |
| 140 | + : await showSelect<SkillStackInstallMode>( |
| 141 | + ctx.dialog, |
| 142 | + `${plan.conflicts.length} of these skills already installed`, |
| 143 | + [ |
| 144 | + { title: "Skip them, install the rest", description: plan.conflicts.join(", "), value: "skip-existing" }, |
| 145 | + { title: "Overwrite them", description: "discards any local edits", value: "overwrite" }, |
| 146 | + ], |
| 147 | + ) |
| 148 | + if (mode === null) return void discard(ctx, plan) |
| 149 | + |
| 150 | + const installing = () => <StatusBox text={`Installing ${plan.skills.length} skill(s)...`} /> |
| 151 | + ctx.dialog.replace(installing) |
| 152 | + const installed = await ctx.sdk.client.v2.skillhub.stack.install({ |
| 153 | + skillStackInstallInput: { stagingID: plan.stagingID, mode }, |
| 154 | + }) |
| 155 | + if (!installed.data) return fail(ctx, `Could not install ${card.name}`, installed.error) |
| 156 | + const outcome = installed.data.data |
| 157 | + |
| 158 | + // The server discovers skills once per instance and nothing invalidates |
| 159 | + // that, so the model would not see the new skills until a restart. Disposing |
| 160 | + // the instance is what a config update does too; the next request rebuilds |
| 161 | + // it. Then refresh the TUI's own list. |
| 162 | + await ctx.sdk.client.instance.dispose().catch(() => undefined) |
| 163 | + await ctx.data.location.skill.refresh().catch(() => undefined) |
| 164 | + |
| 165 | + const parts = [`Installed ${outcome.installed.length} of ${plan.skills.length} skill(s) from ${card.name}`] |
| 166 | + if (outcome.skipped.length > 0) parts.push(`${outcome.skipped.length} skipped`) |
| 167 | + if (outcome.failed.length > 0) parts.push(`${outcome.failed.length} failed`) |
| 168 | + ctx.toast.show({ |
| 169 | + variant: outcome.failed.length > 0 ? "warning" : "success", |
| 170 | + title: "Skill Stack installed", |
| 171 | + message: parts.join(", "), |
| 172 | + duration: 8000, |
| 173 | + }) |
| 174 | + // Only close the dialog if the install step is still what's up — escaping |
| 175 | + // mid-install already did, and something else may have opened since. |
| 176 | + if (stillShowing(ctx.dialog, installing)) ctx.dialog.clear() |
| 177 | +} |
| 178 | + |
| 179 | +// Renders a select and resolves with the chosen value, or null once the user |
| 180 | +// escapes. The onClose passed to replace() also fires when this flow advances |
| 181 | +// to its next step, but by then the promise is already resolved with a value. |
| 182 | +function showSelect<T>( |
| 183 | + dialog: FlowContext["dialog"], |
| 184 | + title: string, |
| 185 | + options: (Omit<DialogSelectOption<T>, "onSelect"> & { value: T })[], |
| 186 | +): Promise<T | null> { |
| 187 | + return new Promise((resolve) => { |
| 188 | + dialog.replace( |
| 189 | + () => ( |
| 190 | + <DialogSelect |
| 191 | + title={title} |
| 192 | + renderFilter={false} |
| 193 | + options={options.map((option) => ({ ...option, onSelect: () => resolve(option.value) }))} |
| 194 | + /> |
| 195 | + ), |
| 196 | + () => resolve(null), |
| 197 | + ) |
| 198 | + }) |
| 199 | +} |
| 200 | + |
| 201 | +// The dialog store types `element` as rendered JSX, but replace() stores the |
| 202 | +// factory it was handed — which is exactly what lets a step recognise itself. |
| 203 | +function stillShowing(dialog: FlowContext["dialog"], factory: () => unknown): boolean { |
| 204 | + return (dialog.stack[0]?.element as unknown) === factory |
| 205 | +} |
| 206 | + |
| 207 | +// The user walked away from a prepared stack: free its staging now rather |
| 208 | +// than waiting for the server's TTL. Best effort — the TTL is the backstop. |
| 209 | +function discard(ctx: FlowContext, plan: SkillStackPrepared) { |
| 210 | + void ctx.sdk.client.v2.skillhub.stack.discard({ stagingID: plan.stagingID }).catch(() => undefined) |
| 211 | +} |
| 212 | + |
| 213 | +function fail(ctx: FlowContext, title: string, error: unknown) { |
| 214 | + ctx.toast.show({ variant: "error", title, message: describeError(error), duration: 10000 }) |
| 215 | + ctx.dialog.clear() |
| 216 | +} |
| 217 | + |
| 218 | +// The server answers with a SkillHubError carrying a code and a message the |
| 219 | +// CLI already shows verbatim (`auth` names the command that signs in); other |
| 220 | +// wire errors carry a message too, and anything else falls back to the |
| 221 | +// generic formatter. |
| 222 | +function describeError(error: unknown): string { |
| 223 | + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { |
| 224 | + return error.message |
| 225 | + } |
| 226 | + return errorMessage(error) |
| 227 | +} |
0 commit comments