/** * `/providers` slash command — interactive TUI to turn the three providers * registered by this package (and their individual models) on/off in pi's * `/model` selector. * * Primary UI: a `SettingsList` (rich, multi-toggle) rendered via `ctx.ui.custom` * in TUI mode. Falls back to `ctx.ui.select` dialogs when pi-tui cannot be * loaded or pi is not in TUI mode (e.g. RPC), so the command always works. * * Effects: * - Model on/off → rewrites the `hide` list → takes effect the next time * `/model` is opened (the refresh hook re-reads the file). * - Provider on/off → rewrites `enabled` → takes effect after a pi * restart (registerProvider is load-time only). * * pi-tui is imported DYNAMICALLY and guarded so a resolution failure never * breaks extension load (worst case: the dialog fallback runs). * * NOTE on types: the dev-time typecheck resolves pi-coding-agent v0.77.x while * the runtime is v0.84+. v0.77 lacks `ctx.mode` and types `select` as * `string[]`, so a local `CmdCtx` interface (cast at the boundary) is used — * the same version-skew pattern the rest of this package already uses. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { getSettingsListTheme } from "@earendil-works/pi-coding-agent" import type { SelectItem, SettingItem } from "@earendil-works/pi-tui" import { GO_MODELS, ZEN_MODELS } from "./backends/opencode/catalog.ts" import { loadedCommandCodeModelIds, loadedFactoryDroidModelIds, } from "./provider-state.ts" import { loadVisibilityConfig, saveVisibilityConfig, type ProviderVisibility, type VisibilityConfig, } from "./visibility.ts" import { existsSync, readFileSync } from "node:fs" import { getAgentDir } from "@earendil-works/pi-coding-agent" import { join } from "node:path" // --------------------------------------------------------------------------- // Local ctx types (version-skew tolerant; see header note) // --------------------------------------------------------------------------- interface ThemeLike { fg(color: string, text: string): string bold(text: string): string } interface CmdUi { select(title: string, options: string[], opts?: unknown): Promise custom( factory: ( tui: unknown, theme: ThemeLike, kb: unknown, done: (result: T) => void, ) => unknown, ): Promise notify(message: string, type?: "info" | "warning" | "error"): void theme: ThemeLike } interface CmdCtx { hasUI: boolean mode?: string ui: CmdUi } // --------------------------------------------------------------------------- // Provider metadata + model sources // --------------------------------------------------------------------------- interface ProviderMeta { id: string name: string /** "zen"/"go" pull from the static OpenCode catalog; "factory-droid" pulls the static Factory catalog; null = commandcode. */ tier: "zen" | "go" | "factory-droid" | null } const PROVIDERS: readonly ProviderMeta[] = [ { id: "oc-zen", name: "OpenCode Zen", tier: "zen" }, { id: "oc-go", name: "OpenCode Go", tier: "go" }, { id: "commandcode", name: "Command Code", tier: null }, { id: "factory-droid", name: "Factory Droid", tier: "factory-droid" }, ] interface ModelEntry { id: string name: string } /** Read ids pi has already persisted for an OpenCode provider (live models). */ function readModelsStoreIds(providerId: string): string[] { try { const path = join(getAgentDir(), "models-store.json") if (!existsSync(path)) return [] const data: unknown = JSON.parse(readFileSync(path, "utf8")) const entry = (data as Record)?.[providerId]?.models if (!Array.isArray(entry)) return [] return entry .map((model) => (model as { id?: string })?.id) .filter((id): id is string => typeof id === "string" && id.length > 0) } catch { return [] } } /** Full model list for a provider: static catalog ∪ live ids (name = id when unknown). */ function getProviderModels(meta: ProviderMeta): ModelEntry[] { const map = new Map() if (meta.tier === "zen") for (const model of ZEN_MODELS) map.set(model.id, model.name) else if (meta.tier === "go") for (const model of GO_MODELS) map.set(model.id, model.name) else if (meta.tier === "factory-droid") for (const id of loadedFactoryDroidModelIds) map.set(id, id) else for (const id of loadedCommandCodeModelIds) map.set(id, id) // Merge live ids discovered by the refresh hook (OpenCode tiers only). if (meta.tier === "zen" || meta.tier === "go") for (const id of readModelsStoreIds(meta.id)) if (!map.has(id)) map.set(id, id) return [...map.entries()].map(([id, name]) => ({ id, name })) } function providerMeta(id: string): ProviderMeta | undefined { return PROVIDERS.find((provider) => provider.id === id) } // --------------------------------------------------------------------------- // pi-tui dynamic loader (guarded — never throws) // --------------------------------------------------------------------------- interface TuiModule { Container: new () => { addChild(child: unknown): void render(w: number): unknown invalidate(): void } Text: new (text: string, x?: number, y?: number) => unknown SelectList: new ( items: SelectItem[], maxVisible: number, theme: unknown, ) => { onSelect: (item: SelectItem) => void onCancel: () => void handleInput(data: unknown): void } SettingsList: new ( items: SettingItem[], maxVisible: number, theme: unknown, onChange: (id: string, value: string) => void, onClose: () => void, opts?: { enableSearch?: boolean }, ) => { handleInput?: (data: unknown) => void } } let tuiModulePromise: Promise | null = null async function loadTui(): Promise { if (!tuiModulePromise) { tuiModulePromise = import("@earendil-works/pi-tui") .then((module) => module as unknown as TuiModule) .catch(() => null) } return tuiModulePromise } /** Whether the rich SettingsList/SelectList path is usable (TUI + pi-tui loaded). */ function canUseTui(ctx: CmdCtx, tui: TuiModule | null): boolean { return tui !== null && ctx.hasUI && ctx.mode === "tui" } // --------------------------------------------------------------------------- // TUI panels // --------------------------------------------------------------------------- /** Picklist theme for SelectList, built inline (self-contained, no extra API). */ function selectListTheme(theme: ThemeLike) { return { selectedPrefix: (text: string) => theme.fg("accent", text), selectedText: (text: string) => theme.fg("accent", text), description: (text: string) => theme.fg("muted", text), scrollInfo: (text: string) => theme.fg("dim", text), noMatch: (text: string) => theme.fg("warning", text), } } /** Main menu: choose a provider's model panel, provider on/off, or done. */ async function pickMainAction( ctx: CmdCtx, tui: TuiModule | null, config: VisibilityConfig, ): Promise { const items: SelectItem[] = PROVIDERS.map((provider) => { const models = getProviderModels(provider) const hidden = (config[provider.id]?.hide ?? []).length const enabled = config[provider.id]?.enabled !== false return { value: provider.id, label: `${enabled ? "●" : "○"} ${provider.name} — toggle models`, description: `${models.length} models · ${hidden} hidden${enabled ? "" : " · DISABLED"}`, } }) items.push({ value: "__providers__", label: "Enable / disable providers", description: "restart required to take effect", }) items.push({ value: "__done__", label: "Done", description: "close" }) if (canUseTui(ctx, tui) && tui) { return ctx.ui.custom((tuiRuntime, theme, _kb, done) => { const container = new tui.Container() container.addChild( new tui.Text( theme.fg("accent", theme.bold("pi-other-provider — visibility")), 1, 0, ), ) const list = new tui.SelectList( items, Math.min(items.length, 12), selectListTheme(theme), ) list.onSelect = (item) => done(item.value) list.onCancel = () => done(null) container.addChild(list) container.addChild( new tui.Text( theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0, ), ) const render = (w: number) => container.render(w) return { render, invalidate: () => container.invalidate(), handleInput: (data: unknown) => { list.handleInput(data) ;(tuiRuntime as { requestRender: () => void }).requestRender() }, } }) } // Dialog fallback (RPC / no pi-tui): select over string labels, map back by index. const labels = items.map((item) => `${item.label} — ${item.description ?? ""}`) const values = items.map((item) => item.value as string) const pick = await ctx.ui.select("pi-other-provider — visibility", labels) const index = pick ? labels.indexOf(pick) : -1 return index >= 0 ? (values[index] ?? null) : null } /** Generic settings panel (SettingsList in TUI, select-loop fallback). */ async function settingsPanel( ctx: CmdCtx, tui: TuiModule | null, title: string, items: SettingItem[], onChange: (id: string, newValue: string) => void, ): Promise { if (canUseTui(ctx, tui) && tui) { await ctx.ui.custom((tuiRuntime, theme, _kb, done) => { const container = new tui.Container() container.addChild(new tui.Text(theme.fg("accent", theme.bold(title)), 1, 0)) const list = new tui.SettingsList( items, Math.min(items.length + 2, 20), getSettingsListTheme(), onChange, () => done(undefined), { enableSearch: true }, ) container.addChild(list) const render = (w: number) => container.render(w) return { render, invalidate: () => container.invalidate(), handleInput: (data: unknown) => { list.handleInput?.(data) ;(tuiRuntime as { requestRender: () => void }).requestRender() }, } }) return } // Dialog fallback: pick a row to cycle its value, until Done/cancel. let active = true while (active) { const labels = items.map( (item) => `[${item.currentValue.toUpperCase()}] ${item.label}`, ) labels.push("Done") const pick = await ctx.ui.select(title, labels) if (!pick || pick === "Done") { active = false break } const index = labels.indexOf(pick ?? "") const item = index >= 0 ? items[index] : undefined if (!item || !item.values?.length) continue const valueIndex = item.values.indexOf(item.currentValue) const next = item.values[(valueIndex + 1) % item.values.length] ?? item.currentValue item.currentValue = next onChange(item.id, next) } } /** Per-model on/off panel for one provider (manages the `hide` list). */ async function editProviderModels( ctx: CmdCtx, tui: TuiModule | null, providerId: string, ): Promise { const meta = providerMeta(providerId) if (!meta) return const config = loadVisibilityConfig() const visibility: ProviderVisibility = config[providerId] ?? {} const hide = new Set(visibility.hide ?? []) const showOnlyNote = visibility.showOnly && visibility.showOnly.length > 0 ? ` · showOnly active: ${visibility.showOnly.join(", ")}` : "" const models = getProviderModels(meta) const items: SettingItem[] = models.map((model) => ({ id: model.id, label: model.name !== model.id ? `${model.name} (${model.id})` : model.id, currentValue: hide.has(model.id) ? "off" : "on", values: ["on", "off"], })) await settingsPanel( ctx, tui, `${meta.name} — model visibility (${models.length}${showOnlyNote})`, items, (id, newValue) => { if (newValue === "off") hide.add(id) else hide.delete(id) config[providerId] = { ...visibility, hide: [...hide] } saveVisibilityConfig(config) }, ) } /** Provider-level on/off panel (manages the `enabled` flag). */ async function editProvidersOnOff(ctx: CmdCtx, tui: TuiModule | null): Promise { const config = loadVisibilityConfig() const items: SettingItem[] = PROVIDERS.map((provider) => ({ id: provider.id, label: provider.name, currentValue: config[provider.id]?.enabled === false ? "off" : "on", values: ["on", "off"], })) await settingsPanel(ctx, tui, "Enable / disable providers", items, (id, newValue) => { const existing: ProviderVisibility = config[id] ?? {} config[id] = { ...existing, enabled: newValue === "on" } saveVisibilityConfig(config) }) } // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- export function registerProvidersCommand(pi: ExtensionAPI): void { pi.registerCommand("providers", { description: "Toggle pi-other-provider models/providers on/off in /model", handler: async (args, ctx) => { const cmdCtx = ctx as unknown as CmdCtx if (!cmdCtx.hasUI) { cmdCtx.ui.notify("/providers needs interactive (TUI/RPC) mode", "warning") return } const tui = await loadTui() const directArg = typeof args === "string" ? args.trim().toLowerCase() : "" const directProvider = directArg ? providerMeta(directArg) : undefined // `/providers oc-zen` jumps straight into that provider's panel. if (directProvider) { await editProviderModels(cmdCtx, tui, directProvider.id) cmdCtx.ui.notify( `Done. Reopen /model to apply ${directProvider.name} changes.`, "info", ) return } if (directArg === "onoff" || directArg === "providers") { await editProvidersOnOff(cmdCtx, tui) cmdCtx.ui.notify( "Done. Restart pi to apply provider enable/disable.", "info", ) return } let running = true while (running) { const config = loadVisibilityConfig() const action = await pickMainAction(cmdCtx, tui, config) if (!action || action === "__done__") { running = false break } if (action === "__providers__") { await editProvidersOnOff(cmdCtx, tui) } else { await editProviderModels(cmdCtx, tui, action) } } cmdCtx.ui.notify( "Visibility saved. Reopen /model (model filters) / restart pi (provider toggle).", "info", ) }, }) }