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 +21 -0
- package/README.md +250 -0
- package/package.json +56 -0
- package/src/cache.ts +95 -0
- package/src/cachedResource.ts +58 -0
- package/src/collapsible.tsx +86 -0
- package/src/commands.ts +108 -0
- package/src/currentModel.ts +32 -0
- package/src/format.ts +25 -0
- package/src/host.ts +148 -0
- package/src/index.ts +15 -0
- package/src/messages.ts +118 -0
- package/src/pollingFetcher.ts +81 -0
- package/src/providers.ts +200 -0
- package/src/rows.ts +22 -0
- package/src/schemas.ts +70 -0
- package/src/toast.ts +21 -0
- package/src/viewPicker.ts +172 -0
- package/src/workspace.ts +21 -0
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
|
+
[](https://github.com/ranjithrajv/opencode-plugin-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/opencode-plugin-kit)
|
|
5
|
+
[](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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|