opencode-plugin-kit 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ranjith Raj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # opencode-plugin-kit
2
+
3
+ [![CI](https://github.com/ranjithrajv/opencode-plugin-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/ranjithrajv/opencode-plugin-kit/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/opencode-plugin-kit)](https://www.npmjs.com/package/opencode-plugin-kit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Shared building blocks for [OpenCode](https://opencode.ai) sidebar plugins —
8
+ the pieces that every sidebar widget ends up reimplementing. Used by five
9
+ production plugins with **100% test coverage**.
10
+
11
+ ## Status
12
+
13
+ **v1.0.0-alpha.1** — API stabilized, ready for integration testing.
14
+
15
+ ## Quick Start
16
+
17
+ ```sh
18
+ # npm
19
+ npm install opencode-plugin-kit
20
+
21
+ # bun
22
+ bun add opencode-plugin-kit
23
+ ```
24
+
25
+ ```ts
26
+ import { createViewPicker, createCachedStore } from "opencode-plugin-kit"
27
+
28
+ // Persisted view picker with slash command + dialog
29
+ const picker = createViewPicker(context, {
30
+ registry: [
31
+ { id: "go", title: "Go", description: "Go plan usage" },
32
+ { id: "zen", title: "Zen", description: "Zen usage" },
33
+ ],
34
+ storageKey: "view",
35
+ command: {
36
+ id: "usage.view",
37
+ group: "Usage",
38
+ name: "usage-view",
39
+ title: (v) => `Usage footer: view provider (${v.title})`,
40
+ description: "Pick which provider view the sidebar shows",
41
+ },
42
+ dialog: { title: "Usage view", message: "Choose the provider view" },
43
+ toastPrefix: "Usage footer",
44
+ })
45
+ picker.registerCommand()
46
+
47
+ // Storage-backed cache (instant restore after TUI restart)
48
+ const cache = createCachedStore<Usage | null>(context, "usage", {
49
+ initial: null,
50
+ staleAfterMs: 120_000,
51
+ })
52
+ ```
53
+
54
+ ## Why
55
+
56
+ Every OpenCode sidebar plugin reimplements the same patterns:
57
+
58
+ - Reading provider lists and auth.json
59
+ - Persisting UI state across TUI restarts
60
+ - Switching views with slash commands + dialogs
61
+ - Polling authenticated endpoints
62
+ - Traversing session messages defensively
63
+
64
+ Kit extracts these into tested, documented primitives so plugins focus on
65
+ their unique data and rendering.
66
+
67
+ ## Modules
68
+
69
+ | Module | Exports | Purpose |
70
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
71
+ | `providers.ts` | `ZEN_PROVIDER`, `GO_PROVIDER`, `providerLabel()`, `availableProviders()`, `unwrap()`, `isAssistant()`, `modelId()`, `providerId()`, `readAuth()`, `hasKey()`, `authKeys()` | Provider vocabulary + defensive shape readers |
72
+ | `rows.ts` | `short()`, `line()`, `bar()` | Pure text formatting |
73
+ | `format.ts` | `fmt()`, `fmtCost()`, `until()` | Number/date formatting |
74
+ | `cache.ts` | `createCachedStore<T>()` | Storage-backed cache with staleness tracking |
75
+ | `schemas.ts` | `parseUsage()`, `parseIntegrationList()`, `connectedProviderIds()` | Zod schemas for untrusted boundary shapes |
76
+ | `viewPicker.ts` | `createViewPicker()` | Registry + persistence + slash command + dialog + toast |
77
+ | `currentModel.ts` | `resolveCurrentModel()` | Resolve active model from session messages |
78
+ | `connectedProviders.ts` | `createConnectedProviders()` | Reactive integration-list polling |
79
+ | `cachedResource.ts` | `createCachedResource()` | Stale-while-revalidate resource |
80
+ | `messages.ts` | `walkMessages()`, `sumProviderTokens()` | Defensive message traversal |
81
+ | `pollingFetcher.ts` | `createPollingFetcher()` | Throttled polling with in-flight guard |
82
+ | `sidebarSlot.ts` | `createSessionResource()` | Session-reactive data resource |
83
+ | `commands.ts` | `createToggle()`, `registerKeymapCommand()` | Toggle command + keymap registration |
84
+ | `workspace.ts` | `resolveLocation()`, `workspaceDirectory()` | Workspace location resolution |
85
+ | `toast.ts` | `showToast()` | Toast helper |
86
+ | `host.ts` | `KitContext`, `KitMessageShape` | Structural host-contract types |
87
+
88
+ Everything is re-exported from the package root (`src/index.ts`).
89
+
90
+ ## API Reference
91
+
92
+ ### View Picker
93
+
94
+ ```ts
95
+ import { createViewPicker } from "opencode-plugin-kit"
96
+
97
+ const picker = createViewPicker(context, {
98
+ registry: readonly PickerOption[], // View options
99
+ storageKey: string, // Persistence key
100
+ command: {
101
+ id: string, // Command ID
102
+ group: string, // Command group
103
+ name: string, // Slash command name
104
+ aliases?: string[], // Slash aliases
105
+ title: (current) => string, // Dynamic title
106
+ description: string, // Command description
107
+ },
108
+ dialog: { title: string, message: string },
109
+ toastPrefix?: string, // Toast prefix on switch
110
+ selectable?: (entry) => boolean, // Gate entries (e.g. provider-key check)
111
+ unavailableMessage?: (entry) => string,
112
+ })
113
+ picker.registerCommand() // Call once from setup
114
+ picker.current() // Reactive current entry
115
+ picker.currentID() // Reactive current ID
116
+ picker.apply(entry) // Switch without UI
117
+ picker.pick(arg?) // Open picker or select by arg
118
+ ```
119
+
120
+ ### Cached Store
121
+
122
+ ```ts
123
+ import { createCachedStore } from "opencode-plugin-kit"
124
+
125
+ const cache = createCachedStore<T>(context, key, {
126
+ initial: T, // Initial value
127
+ staleAfterMs: number, // Staleness threshold
128
+ })
129
+ cache.value // Current value (restored from storage)
130
+ cache.lastSet // Epoch ms of last set (0 = never)
131
+ cache.stale // True when staleAfterMs elapsed
132
+ cache.set(value) // Update + persist
133
+ ```
134
+
135
+ ### Connected Providers
136
+
137
+ ```ts
138
+ import { createConnectedProviders } from "opencode-plugin-kit"
139
+
140
+ const connected = createConnectedProviders(context, {
141
+ extra: () => ["huggingface"], // Extra providers (e.g. env-only)
142
+ pollMs: 30_000, // Poll interval (0 = disable)
143
+ })
144
+ connected.ids() // Reactive Set of connected provider IDs
145
+ connected.has(id) // Check if provider is connected
146
+ connected.refresh() // Force immediate refresh
147
+ connected.stop() // Stop polling
148
+ ```
149
+
150
+ ### Polling Fetcher
151
+
152
+ ```ts
153
+ import { createPollingFetcher } from "opencode-plugin-kit"
154
+
155
+ const fetcher = createPollingFetcher({
156
+ fetch: () => Promise<T | null>,
157
+ intervalMs: 60_000,
158
+ throttleMs: 60_000, // Min gap between fetches
159
+ onResult: (value) => void,
160
+ onError: (err) => void,
161
+ })
162
+ fetcher.refresh() // Trigger immediate fetch
163
+ fetcher.stop() // Stop polling
164
+ fetcher.inFlight() // Whether a fetch is in progress
165
+ ```
166
+
167
+ ### Message Traversal
168
+
169
+ ```ts
170
+ import { walkMessages, sumProviderTokens } from "opencode-plugin-kit"
171
+
172
+ // Fold over a session's messages defensively
173
+ const totals = walkMessages(
174
+ context,
175
+ sessionID,
176
+ (message, acc) => {
177
+ acc.tokens += message?.tokens?.input ?? 0
178
+ return acc
179
+ },
180
+ { provider: "opencode", since: Date.now() - 3600_000 },
181
+ { tokens: 0 },
182
+ )
183
+
184
+ // Convenience: sum tokens for a provider
185
+ const { input, output, cost } = sumProviderTokens(context, sessionID, "opencode")
186
+ ```
187
+
188
+ ## Consuming Plugins
189
+
190
+ | Plugin | What it does |
191
+ | --------------------------------------------------------------- | -------------------------------------------------------------- |
192
+ | [opencode-usage-quota-tracker](../opencode-usage-quota-tracker) | Live provider quota + usage in sidebar footer |
193
+ | [opencode-model-recommender](../opencode-model-recommender) | Model recommendations by cache ratio, token cost, session cost |
194
+ | [opencode-skill-lister](../opencode-skill-lister) | Skills list in sidebar |
195
+ | [opencode-plugin-manager](../opencode-plugin-manager) | Plugin manager in sidebar |
196
+
197
+ Link locally with `"opencode-plugin-kit": "file:../opencode-plugin-kit"` in the
198
+ consumer's `package.json`, then `npm install` (or `bun install`).
199
+
200
+ ## Compatibility
201
+
202
+ The host plugin API is beta; its types are the spec. Kit consumes the context
203
+ structurally — anything the host ships with the expected members satisfies it.
204
+
205
+ - **No runtime dependency** on `@opencode-ai/plugin` — types only
206
+ - **Optional peer** — declared as optional so consumers share one copy
207
+ - **CI tripwire** — `host.test-d.ts` pins published host types against kit's
208
+ contract; drift fails CI with a readable diff
209
+
210
+ ## Development
211
+
212
+ ```sh
213
+ # Install
214
+ vp install
215
+
216
+ # Check (format + lint + typecheck)
217
+ vp check
218
+
219
+ # Fix issues
220
+ vp check --fix
221
+
222
+ # Format
223
+ vp fmt
224
+
225
+ # Lint
226
+ vp lint
227
+
228
+ # Typecheck
229
+ vp check --typecheck
230
+
231
+ # Test
232
+ vp test
233
+
234
+ # Test with coverage
235
+ vp test --coverage
236
+ ```
237
+
238
+ ## Contributing
239
+
240
+ See [CONTRIBUTING.md](CONTRIBUTING.md) — and keep
241
+ [docs/opencode2-api.md](docs/opencode2-api.md) in sync if your change adopts
242
+ a new OpenCode API surface.
243
+
244
+ Platform patterns proposed for upstreaming into the official plugin API are
245
+ tracked in [docs/UPSTREAM.md](docs/UPSTREAM.md) — absorbing a pattern
246
+ upstream and deleting it here is the project's exit goal, not a failure.
247
+
248
+ ## License
249
+
250
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "opencode-plugin-kit",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Shared building blocks for OpenCode sidebar plugins: provider vocabulary, row formatting, defensive message traversal, connected-provider tracking, polling fetcher, cached resources, and a persisted view/filter picker (registry + slash command + dialog + toast).",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "sidebar",
9
+ "tui"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Ranjith Raj",
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "scripts": {
21
+ "check": "vp check",
22
+ "check:fix": "vp check --fix",
23
+ "fmt": "vp fmt",
24
+ "lint": "vp lint",
25
+ "typecheck": "vp check --typecheck",
26
+ "test:types": "vitest run --typecheck",
27
+ "test:coverage": "vitest run --coverage.enabled"
28
+ },
29
+ "devDependencies": {
30
+ "@opencode-ai/plugin": "0.0.0-beta-19242",
31
+ "@opencode-ai/sdk": "0.0.0-beta-19234",
32
+ "@opentui/core": "^0.5.11",
33
+ "@opentui/solid": "^0.5.11",
34
+ "@types/node": "^26.5.0",
35
+ "happy-dom": "^20.14.0",
36
+ "typescript": "^7.0.2",
37
+ "vite-plugin-solid": "^2.11.14",
38
+ "vite-plus": "^0.1.16",
39
+ "vitest": "^5.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@opencode-ai/plugin": ">=1.18.25 <2 || 0.0.0-beta-19242",
43
+ "solid-js": "1.9.15",
44
+ "zod": "^4.5.4"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@opencode-ai/plugin": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "packageManager": "bun@1.4.2",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/ranjithraj/opencode-plugin-kit.git"
55
+ }
56
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Storage-backed cache for sidebar widgets: instant restore of the last
2
+ import type { KitContext } from "./host.ts"
3
+ // known value across TUI restarts, with staleness tracking.
4
+
5
+ export interface CachedStore<T> {
6
+ /** Last cached value (restored from plugin storage on restart), or the
7
+ * `initial` when nothing has been cached yet. */
8
+ readonly value: T
9
+ /** Epoch ms of the last successful `set`. 0 when never set. */
10
+ readonly lastSet: number
11
+ /** True when `staleAfterMs` has elapsed since the last set (or nothing
12
+ * was ever set). */
13
+ readonly stale: boolean
14
+ /** Cache a value and persist it. */
15
+ set(value: T): void
16
+ }
17
+
18
+ interface CacheEntry<T> {
19
+ value: T
20
+ at: number
21
+ }
22
+
23
+ /**
24
+ * Module-scope, storage-backed cache. Sidebar slots render synchronously, so
25
+ * the latest successful fetch is what gets painted and a background refresher
26
+ * keeps it fresh — this encapsulates that pattern:
27
+ *
28
+ * const cache = createCachedStore(context, "usage", { initial: null, staleAfterMs: 120_000 })
29
+ * cache.set(fresh) // after a successful fetch
30
+ * render(cache.value) // instant, even after restart
31
+ * if (cache.stale) void refetch() // background refresh
32
+ */
33
+ export function createCachedStore<T>(
34
+ context: KitContext,
35
+ key: string,
36
+ { initial, staleAfterMs }: { initial: T; staleAfterMs: number },
37
+ ): CachedStore<T> {
38
+ let value = initial
39
+ let at = 0
40
+ let persist: ((entry: CacheEntry<T>) => void) | null = null
41
+
42
+ // Restore the last known value so a TUI restart shows data immediately
43
+ // instead of a loading/empty state. Storage unavailable → memory-only.
44
+ try {
45
+ const [store] = context.storage.store(key, { initial: { entry: null as CacheEntry<T> | null } })
46
+ if (store.entry) {
47
+ value = store.entry.value
48
+ at = store.entry.at
49
+ }
50
+ persist = (entry) => {
51
+ store.entry = entry
52
+ }
53
+ } catch {
54
+ // No storage; cache stays in-memory only.
55
+ }
56
+
57
+ return {
58
+ get value() {
59
+ return value
60
+ },
61
+ get lastSet() {
62
+ return at
63
+ },
64
+ get stale() {
65
+ return at === 0 || Date.now() - at > staleAfterMs
66
+ },
67
+ set(next: T) {
68
+ value = next
69
+ at = Date.now()
70
+ persist?.({ value: next, at })
71
+ },
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Durable storage cell shared by the picker and the toggle: read the
77
+ * persisted object (or undefined when storage is unavailable) and persist
78
+ * by mutating it — the host hands back a live cell, so mutation = write.
79
+ */
80
+ export function persistedCell<T extends object>(context: KitContext, storageKey: string, initial: T) {
81
+ const read = (): T | undefined => {
82
+ try {
83
+ return (context.storage.store(storageKey, { initial }) as [T, T])?.[0]
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+ return {
89
+ read,
90
+ persist: (mutate: (value: T) => void): void => {
91
+ const value = read()
92
+ if (value) mutate(value)
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,58 @@
1
+ // Stale-while-revalidate resource: combines createResource + createCachedStore
2
+ // into one primitive so sidebar widgets restore instantly from cache, then
3
+ // refresh in the background.
4
+ //
5
+ // Both the model recommender and skill-lister hand-wire "serve cache, refresh
6
+ // behind it" — this encapsulates that dance.
7
+ import { createResource } from "solid-js"
8
+ import type { CachedStore } from "./cache.ts"
9
+
10
+ export interface CachedResourceOptions<T> {
11
+ /** Durable cache (from createCachedStore). */
12
+ readonly cache: CachedStore<T | null>
13
+ }
14
+
15
+ /**
16
+ * Create a cached resource that serves the cache instantly, then refetches
17
+ * in the background. The cache persists across TUI restarts so the sidebar
18
+ * never shows a loading state.
19
+ *
20
+ * @example
21
+ * const cache = createCachedStore(context, "picks", { initial: null, staleAfterMs: 300_000 })
22
+ * const cached = createCachedResource(() => sessionID, loadPicks, { cache })
23
+ * // cached.data() — the resource signal
24
+ * // cached.refetch() — force a refetch
25
+ * // cached.set(value) — mutate both resource + cache
26
+ */
27
+ export function createCachedResource<T>(
28
+ source: () => string | undefined,
29
+ loader: (sid: string | undefined) => Promise<T>,
30
+ options: CachedResourceOptions<T>,
31
+ ) {
32
+ const { cache } = options
33
+
34
+ const [data, { refetch, mutate }] = createResource(
35
+ source,
36
+ async (sid) => {
37
+ const result = await loader(sid)
38
+ cache.set(result)
39
+ return result
40
+ },
41
+ { initialValue: (cache.value ?? undefined) as T | undefined },
42
+ )
43
+
44
+ return {
45
+ data,
46
+ refetch: () => {
47
+ if (cache.stale) void refetch()
48
+ },
49
+ // Unconditional refetch — for after external changes (e.g. a config file
50
+ // edited on disk) where the staleness gate would wrongly skip the reload.
51
+ refetchNow: () => refetch(),
52
+ set: (value: T) => {
53
+ cache.set(value)
54
+ // Setter<T> overloads don't resolve for bare generics; cast through unknown.
55
+ ;(mutate as (v: T) => void)(value)
56
+ },
57
+ }
58
+ }
@@ -0,0 +1,86 @@
1
+ // Collapsible sidebar sections — the header pattern shared by the skill
2
+ // lister and the plugin manager (both mirroring the built-in
3
+ // opencode.sidebar.mcp widget). One implementation keeps every sidebar
4
+ // section in lockstep with the built-in instead of drifting per plugin.
5
+ //
6
+ // Two shapes, matching the two patterns the plugins actually render:
7
+ // - CollapsibleSection: the top-level block. Bold title, ▶/▼ arrow only
8
+ // above `threshold` items, collapsed summary, content hidden while
9
+ // collapsed (unless under the threshold).
10
+ // - CollapsibleGroup: a nested group. Subdued ▸/▾ header with a trailing
11
+ // count, always toggleable.
12
+ import { createSignal, Show } from "solid-js"
13
+ import type { JSX } from "solid-js"
14
+ import { usePlugin } from "@opencode-ai/plugin/tui"
15
+
16
+ /** The top-level collapsible sidebar section. */
17
+ export function CollapsibleSection(props: {
18
+ title: string
19
+ /** Item count; drives the threshold and the default collapsed summary. */
20
+ count?: number
21
+ /** Collapsed summary text; defaults to the count in parentheses. */
22
+ summary?: string
23
+ /** The section only expands above this count. Defaults to 2. */
24
+ threshold?: number
25
+ /** Always-visible content between the header and the collapsible body
26
+ * (e.g. the plugin manager's status note). */
27
+ pinned?: JSX.Element
28
+ children: JSX.Element
29
+ }) {
30
+ const theme = usePlugin().theme
31
+ const [expanded, setExpanded] = createSignal(false)
32
+ const count = () => props.count ?? 0
33
+ const threshold = () => props.threshold ?? 2
34
+ const expandable = () => count() > threshold()
35
+ return (
36
+ <box flexDirection="column">
37
+ <box flexDirection="row" gap={1} onMouseDown={() => expandable() && setExpanded((e) => !e)}>
38
+ <Show when={expandable()}>
39
+ <text fg={theme.text.default}>{expanded() ? "▼" : "▶"}</text>
40
+ </Show>
41
+ <text fg={theme.text.default}>
42
+ <b>{props.title}</b>
43
+ </text>
44
+ <Show when={!expanded()}>
45
+ <text fg={theme.text.subdued}> ({props.summary ?? count()})</text>
46
+ </Show>
47
+ </box>
48
+ {props.pinned}
49
+ <Show when={!expandable() || expanded()}>{props.children}</Show>
50
+ </box>
51
+ )
52
+ }
53
+
54
+ /**
55
+ * A collapsible group nested inside a section. Children may be a render
56
+ * function receiving the reactive collapsed state — for content that must
57
+ * react to the toggle without being hidden by it (the plugin manager's
58
+ * built-ins hint).
59
+ */
60
+ export function CollapsibleGroup(props: {
61
+ title: string
62
+ count: number
63
+ defaultCollapsed?: boolean
64
+ children: JSX.Element | ((collapsed: () => boolean) => JSX.Element)
65
+ }) {
66
+ const theme = usePlugin().theme
67
+ const [collapsed, setCollapsed] = createSignal(props.defaultCollapsed ?? false)
68
+ return (
69
+ <box flexDirection="column">
70
+ <box flexDirection="row" gap={1} onMouseDown={() => setCollapsed((c) => !c)}>
71
+ <text fg={theme.text.subdued}>{collapsed() ? "▸" : "▾"}</text>
72
+ <text fg={theme.text.subdued}>
73
+ {props.title} ({props.count})
74
+ </text>
75
+ </box>
76
+ <Show when={!collapsed()}>
77
+ {(() => {
78
+ const children = props.children
79
+ return typeof children === "function" && children.length > 0
80
+ ? (children as (c: () => boolean) => JSX.Element)(collapsed)
81
+ : (children as JSX.Element)
82
+ })()}
83
+ </Show>
84
+ </box>
85
+ )
86
+ }
@@ -0,0 +1,108 @@
1
+ // Slash/palette command registration: the shared plumbing under
2
+ // createViewPicker and createToggle.
3
+ //
4
+ // Keymap layers are owned by the calling component, so a command must be
5
+ // registered from a rendered `app` slot — a layer registered directly in
6
+ // setup() never becomes active.
7
+ import { createSignal } from "solid-js"
8
+ import type { KitCommandEntry, KitContext } from "./host.ts"
9
+ import { persistedCell } from "./cache.ts"
10
+ import { showToast } from "./toast.ts"
11
+
12
+ /**
13
+ * Register one slash/palette command through a keymap layer. `command` may be
14
+ * a factory — it re-evaluates on every palette render, keeping titles fresh
15
+ * after the underlying state changes.
16
+ */
17
+ export function registerKeymapCommand(context: KitContext, command: KitCommandEntry | (() => KitCommandEntry)): void {
18
+ try {
19
+ context.ui.slot({
20
+ append: "app",
21
+ render: () => {
22
+ try {
23
+ const entry = typeof command === "function" ? command() : command
24
+ context.keymap.layer(() => ({ mode: "global", priority: 10, commands: [entry] }))
25
+ } catch (err) {
26
+ console.warn("opencode-plugin-kit: keymap.layer unavailable", err)
27
+ }
28
+ return null
29
+ },
30
+ })
31
+ } catch (err) {
32
+ console.warn("opencode-plugin-kit: ui.slot unavailable", err)
33
+ }
34
+ }
35
+
36
+ export interface ToggleCommandConfig {
37
+ readonly id: string
38
+ readonly group: string
39
+ /** Slash name, e.g. "plugins-builtins". */
40
+ readonly name: string
41
+ readonly aliases?: string[]
42
+ readonly description: string
43
+ /** Palette title; re-evaluated on every palette render. */
44
+ readonly title: (value: boolean) => string
45
+ }
46
+
47
+ export interface ToggleConfig {
48
+ /** Durable storage key (plugin-scoped) for the boolean. */
49
+ readonly storageKey: string
50
+ readonly initial: boolean
51
+ readonly command: ToggleCommandConfig
52
+ /** Toast message on toggle; omit for a silent toggle. */
53
+ readonly toast?: (value: boolean) => string
54
+ }
55
+
56
+ export interface Toggle {
57
+ readonly value: () => boolean
58
+ readonly toggle: () => void
59
+ readonly registerCommand: () => void
60
+ }
61
+
62
+ /**
63
+ * A persisted boolean with a slash/palette command to flip it — the
64
+ * single-toggle counterpart to createViewPicker. The persisted shape is
65
+ * `{ value: boolean }`; anything unreadable (including a foreign legacy
66
+ * shape under the same key) falls back to `initial`.
67
+ */
68
+ export function createToggle(context: KitContext, config: ToggleConfig): Toggle {
69
+ const [value, setValue] = createSignal(config.initial)
70
+
71
+ type StoredToggle = { value?: boolean }
72
+ const cell = persistedCell<StoredToggle>(context, config.storageKey, { value: config.initial })
73
+
74
+ // Restore the persisted pick, if readable.
75
+ try {
76
+ const persisted = cell.read()
77
+ if (persisted && typeof persisted.value === "boolean") setValue(persisted.value)
78
+ } catch {
79
+ // In-memory only.
80
+ }
81
+
82
+ const toggle = () => {
83
+ const next = !value()
84
+ setValue(next)
85
+ cell.persist((s) => {
86
+ s.value = next
87
+ })
88
+ const message = config.toast?.(next)
89
+ if (message) showToast(context, message)
90
+ }
91
+
92
+ const registerCommand = () =>
93
+ registerKeymapCommand(context, () => ({
94
+ id: config.command.id,
95
+ title: config.command.title(value()),
96
+ description: config.command.description,
97
+ group: config.command.group,
98
+ palette: true,
99
+ slash: {
100
+ name: config.command.name,
101
+ aliases: config.command.aliases,
102
+ },
103
+ suggested: true,
104
+ run: () => toggle(),
105
+ }))
106
+
107
+ return { value, toggle, registerCommand }
108
+ }