opencode-plugin-browser 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 +661 -0
- package/README.md +56 -0
- package/builtins.ts +47 -0
- package/index.ts +15 -0
- package/package.json +60 -0
- package/tui.tsx +959 -0
package/tui.tsx
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { createSignal, For, Show } from "solid-js"
|
|
3
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
4
|
+
import { basename, dirname, join } from "node:path"
|
|
5
|
+
import {
|
|
6
|
+
asArray,
|
|
7
|
+
createCachedResource,
|
|
8
|
+
createCachedStore,
|
|
9
|
+
createToggle,
|
|
10
|
+
showToast,
|
|
11
|
+
workspaceDirectory,
|
|
12
|
+
type Toggle,
|
|
13
|
+
} from "opencode-plugin-kit"
|
|
14
|
+
import { CollapsibleGroup, CollapsibleSection } from "opencode-plugin-kit/collapsible"
|
|
15
|
+
import { describeBuiltin } from "./builtins.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Data model
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
type Kind = "npm" | "local" | "builtin"
|
|
22
|
+
|
|
23
|
+
interface Entry {
|
|
24
|
+
name: string
|
|
25
|
+
kind: Kind
|
|
26
|
+
/** Project dir (local + uninstalled workspace candidates). */
|
|
27
|
+
dir?: string
|
|
28
|
+
/** npm version, when known. */
|
|
29
|
+
version?: string
|
|
30
|
+
/** npm registry reports an update available. */
|
|
31
|
+
outdated?: boolean
|
|
32
|
+
/** Activation state from the plugin registry ("active"/"failed"/…). */
|
|
33
|
+
status?: string
|
|
34
|
+
/** Description from the project's package.json (local plugins). */
|
|
35
|
+
description?: string
|
|
36
|
+
/** Known from config but not seen by the server registry (TUI-only). */
|
|
37
|
+
configOnly?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const GROUP_ORDER: Kind[] = ["npm", "local", "builtin"]
|
|
41
|
+
|
|
42
|
+
// Groups that start collapsed (built-ins are numerous). npm and local open.
|
|
43
|
+
export const DEFAULT_COLLAPSED: Partial<Record<Kind, boolean>> = {
|
|
44
|
+
builtin: true,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A directory is a plugin project when its package.json depends on the
|
|
48
|
+
// OpenCode plugin API and isn't a private monorepo root or the shared kit
|
|
49
|
+
// library (which has no plugin dependency).
|
|
50
|
+
export function isPluginProject(dir: string): { name: string; description?: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
|
|
53
|
+
if (pkg.private === true) return null
|
|
54
|
+
const deps = {
|
|
55
|
+
...pkg.dependencies,
|
|
56
|
+
...pkg.peerDependencies,
|
|
57
|
+
}
|
|
58
|
+
if ("@opencode-ai/plugin" in deps) {
|
|
59
|
+
return {
|
|
60
|
+
name: String(pkg.name ?? basename(dir)),
|
|
61
|
+
description: pkg.description ? String(pkg.description) : undefined,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Not a readable package.json — not a plugin project.
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Scan the workspace root (the location's directory) for sibling plugin
|
|
71
|
+
// projects. This is the "known plugins" universe beyond the registry:
|
|
72
|
+
// anything here that isn't installed shows up as uninstalled.
|
|
73
|
+
export function scanWorkspace(root: string): Entry[] {
|
|
74
|
+
try {
|
|
75
|
+
return readdirSync(root, { withFileTypes: true })
|
|
76
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
|
|
77
|
+
.map((d) => join(root, d.name))
|
|
78
|
+
.filter((dir) =>
|
|
79
|
+
statSync(join(dir, "package.json"), {
|
|
80
|
+
throwIfNoEntry: false,
|
|
81
|
+
})?.isFile(),
|
|
82
|
+
)
|
|
83
|
+
.map((dir) => ({ meta: isPluginProject(dir), dir }))
|
|
84
|
+
.filter((e) => e.meta)
|
|
85
|
+
.map(({ meta, dir }) => ({
|
|
86
|
+
name: meta!.name,
|
|
87
|
+
dir,
|
|
88
|
+
kind: "local" as const,
|
|
89
|
+
status: "uninstalled",
|
|
90
|
+
description: meta!.description,
|
|
91
|
+
}))
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Strip a trailing npm version from a specifier without touching scoped
|
|
98
|
+
// names: "pkg@1.2" → "pkg", "@scope/pkg@1.2" → "@scope/pkg", "@scope/pkg" →
|
|
99
|
+
// unchanged.
|
|
100
|
+
export function stripVersion(spec: string): string {
|
|
101
|
+
const m = spec.match(/^(.+[^@])@([^@]+)$/)
|
|
102
|
+
return m ? m[1] : spec
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A config `plugins` entry is either a local path ("./x", "/abs/x") or a
|
|
106
|
+
// package specifier ("pkg", "@scope/pkg", "pkg@1.2", a git URL).
|
|
107
|
+
export function configEntry(entry: string, docDir: string): Entry | null {
|
|
108
|
+
const e = String(entry ?? "").trim()
|
|
109
|
+
if (!e) return null
|
|
110
|
+
if (e.startsWith(".") || e.startsWith("/")) {
|
|
111
|
+
const abs = e.startsWith("/") ? e : join(docDir, e)
|
|
112
|
+
const meta = isPluginProject(abs)
|
|
113
|
+
return {
|
|
114
|
+
name: meta?.name ?? basename(abs),
|
|
115
|
+
dir: abs,
|
|
116
|
+
kind: "local",
|
|
117
|
+
configOnly: true,
|
|
118
|
+
description: meta?.description,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const name =
|
|
122
|
+
e.startsWith("http") || e.includes("://") ? stripVersion(basename(e).replace(/\.git$/, "")) : stripVersion(e)
|
|
123
|
+
// A specifier that survives the branches above always yields a non-empty
|
|
124
|
+
// name; the empty fallback is defensive only.
|
|
125
|
+
/* v8 ignore next */
|
|
126
|
+
return name ? { name, kind: "npm", configOnly: true } : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Load every plugin from two complementary sources:
|
|
130
|
+
// 1. the plugin registry (client.plugin.list()) — activation state and the
|
|
131
|
+
// built-ins; the server only activates plugins with server features, so
|
|
132
|
+
// TUI-only plugins are absent here
|
|
133
|
+
// 2. the config documents (client.config.get()) — every configured plugin,
|
|
134
|
+
// local or npm, loaded or not
|
|
135
|
+
// and merge with the workspace scan for uninstalled candidates.
|
|
136
|
+
export async function loadEntries(ctx: any, root: string): Promise<Entry[]> {
|
|
137
|
+
const [regOut, cfgOut] = await Promise.all([
|
|
138
|
+
ctx.client.plugin.list().catch(() => undefined),
|
|
139
|
+
ctx.client.config.get().catch(() => undefined),
|
|
140
|
+
])
|
|
141
|
+
const registry = asArray<any>(regOut)
|
|
142
|
+
|
|
143
|
+
const localDirs = new Set(
|
|
144
|
+
registry.map((p) => (p.source?.type === "local" && p.source.path ? dirname(p.source.path) : "")).filter(Boolean),
|
|
145
|
+
)
|
|
146
|
+
const ids = new Set(registry.map((p) => String(p.id ?? "")).filter(Boolean))
|
|
147
|
+
const targets = new Set(
|
|
148
|
+
registry
|
|
149
|
+
.map((p) => (p.source?.type === "package" && p.source.target ? stripVersion(String(p.source.target)) : ""))
|
|
150
|
+
.filter(Boolean),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const entries: Entry[] = registry.map((p): Entry => {
|
|
154
|
+
const src = p.source ?? {}
|
|
155
|
+
const kind: Kind = src.type === "package" ? "npm" : src.type === "local" ? "local" : "builtin"
|
|
156
|
+
const dir = kind === "local" ? dirname(src.path) : undefined
|
|
157
|
+
const name =
|
|
158
|
+
kind === "builtin"
|
|
159
|
+
? String(p.id ?? src.type)
|
|
160
|
+
: basename(dir ?? stripVersion(String(src.target ?? p.id ?? "plugin")))
|
|
161
|
+
return {
|
|
162
|
+
name,
|
|
163
|
+
kind,
|
|
164
|
+
dir,
|
|
165
|
+
version: kind === "npm" && src.version ? String(src.version) : undefined,
|
|
166
|
+
outdated: src.outdated === true,
|
|
167
|
+
status: String(p.state?.status ?? "") || undefined,
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Config-declared plugins the registry doesn't know: local paths resolve
|
|
172
|
+
// against each config document's directory; npm specifiers keep their
|
|
173
|
+
// package name. Skip anything the registry already covers.
|
|
174
|
+
//
|
|
175
|
+
// The service may run from a different location than this workspace, so
|
|
176
|
+
// config.get() can omit the project's opencode.json entirely — read the
|
|
177
|
+
// project config files directly as well, and collect every declared
|
|
178
|
+
// plugin (registry + config docs + project files) into coverage sets the
|
|
179
|
+
// uninstalled scan checks against.
|
|
180
|
+
const cfgData: any = (cfgOut as any)?.data ?? cfgOut
|
|
181
|
+
const docs: any[] = Array.isArray(cfgData) ? cfgData : asArray<any>(cfgData)
|
|
182
|
+
|
|
183
|
+
// Coverage: dirs/names of every plugin declared as installed anywhere.
|
|
184
|
+
const coveredDirs = new Set(localDirs)
|
|
185
|
+
const coveredNames = new Set<string>([...ids, ...targets])
|
|
186
|
+
|
|
187
|
+
const pushConfigEntries = (plugins: string[], docDir: string) => {
|
|
188
|
+
for (const raw of plugins) {
|
|
189
|
+
const e = configEntry(raw, docDir)
|
|
190
|
+
if (!e) continue
|
|
191
|
+
if (e.dir) coveredDirs.add(e.dir)
|
|
192
|
+
coveredNames.add(e.name)
|
|
193
|
+
const known =
|
|
194
|
+
(e.dir && localDirs.has(e.dir)) ||
|
|
195
|
+
ids.has(e.name) ||
|
|
196
|
+
targets.has(e.name) ||
|
|
197
|
+
entries.some((x) => (e.dir ? x.dir === e.dir : false) || (x.kind === e.kind && x.name === e.name))
|
|
198
|
+
if (known) continue
|
|
199
|
+
entries.push(e)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const doc of docs) {
|
|
204
|
+
const plugins = (doc?.info as any)?.plugins
|
|
205
|
+
if (!Array.isArray(plugins)) continue
|
|
206
|
+
pushConfigEntries(plugins, doc?.path ? dirname(String(doc.path)) : root)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The project's own config files, regardless of where the service runs.
|
|
210
|
+
// Tolerant parse: jsonc (comments, trailing commas) is allowed here too.
|
|
211
|
+
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
|
212
|
+
const path = join(root, file)
|
|
213
|
+
try {
|
|
214
|
+
const parsed = tolerantParse(readFileSync(path, "utf8")) as any
|
|
215
|
+
if (parsed && Array.isArray(parsed.plugins)) {
|
|
216
|
+
pushConfigEntries(parsed.plugins, root)
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// No readable project config here.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Backfill descriptions for local entries the registry knows but the
|
|
224
|
+
// config/scan didn't cover (one package.json read per project).
|
|
225
|
+
for (const e of entries) {
|
|
226
|
+
if (e.kind === "local" && e.dir && !e.description) {
|
|
227
|
+
try {
|
|
228
|
+
const pkg = JSON.parse(readFileSync(join(e.dir, "package.json"), "utf8"))
|
|
229
|
+
if (pkg.description) e.description = String(pkg.description)
|
|
230
|
+
} catch {
|
|
231
|
+
// No readable package.json — leave the description empty.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Uninstalled workspace candidates.
|
|
237
|
+
const seen = new Set(entries.map((e) => e.dir ?? e.name))
|
|
238
|
+
for (const cand of scanWorkspace(root)) {
|
|
239
|
+
const key = cand.dir!
|
|
240
|
+
if (coveredDirs.has(key) || coveredNames.has(cand.name)) {
|
|
241
|
+
const existing = entries.find((e) => e.dir === key || e.name === cand.name)
|
|
242
|
+
if (existing) {
|
|
243
|
+
existing.dir = existing.dir ?? key
|
|
244
|
+
existing.description = existing.description ?? cand.description
|
|
245
|
+
// Config-declared but unregistered by the server: not uninstalled.
|
|
246
|
+
if (existing.status === "uninstalled") {
|
|
247
|
+
existing.status = undefined // registered: now just installed
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
// Any prior entry with this dir would have matched coveredDirs above, so
|
|
253
|
+
// `seen.has(key)` is always false here; the guard is defensive only.
|
|
254
|
+
/* v8 ignore start */
|
|
255
|
+
if (!seen.has(key)) {
|
|
256
|
+
seen.add(key)
|
|
257
|
+
entries.push(cand)
|
|
258
|
+
}
|
|
259
|
+
/* v8 ignore stop */
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return entries.toSorted((a, b) => {
|
|
263
|
+
const ka = GROUP_ORDER.indexOf(a.kind)
|
|
264
|
+
const kb = GROUP_ORDER.indexOf(b.kind)
|
|
265
|
+
if (ka !== kb) return ka - kb
|
|
266
|
+
return a.name.localeCompare(b.name)
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Config toggle — install (register) or uninstall (remove) a plugin by
|
|
272
|
+
// editing the `plugins` array of the config file that declares it.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** The config document that owns a `plugins` array, preferring the project's
|
|
276
|
+
* own opencode.json; falls back to `<root>/opencode.json`. */
|
|
277
|
+
export function configDocPath(docs: any[], root: string): string {
|
|
278
|
+
const withPlugins = docs.filter((d) => Array.isArray((d?.info as any)?.plugins) && d.path)
|
|
279
|
+
const project = withPlugins.find((d) => dirname(String(d.path)) === root)
|
|
280
|
+
if (project) return String(project.path)
|
|
281
|
+
if (withPlugins.length > 0) return String(withPlugins[0].path)
|
|
282
|
+
// No doc with plugins: edit the project's own config — jsonc if that is
|
|
283
|
+
// what exists, else the default opencode.json.
|
|
284
|
+
const jsonc = join(root, "opencode.jsonc")
|
|
285
|
+
if (statSync(jsonc, { throwIfNoEntry: false })?.isFile()) return jsonc
|
|
286
|
+
return join(root, "opencode.json")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Resolve a config plugin specifier to the entry it would match, if any. */
|
|
290
|
+
export function specMatches(spec: string, docDir: string, e: Entry): boolean {
|
|
291
|
+
const s = String(spec ?? "").trim()
|
|
292
|
+
if (!s) return false
|
|
293
|
+
if (e.kind === "local" || e.dir) {
|
|
294
|
+
if (s.startsWith(".") || s.startsWith("/")) {
|
|
295
|
+
const abs = s.startsWith("/") ? s : join(docDir, s)
|
|
296
|
+
return e.dir ? abs === e.dir : basename(abs) === e.name
|
|
297
|
+
}
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
return stripVersion(s) === e.name
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function readConfig(path: string): {
|
|
304
|
+
ok: boolean
|
|
305
|
+
plugins: string[]
|
|
306
|
+
rest: any
|
|
307
|
+
} {
|
|
308
|
+
let raw: string
|
|
309
|
+
try {
|
|
310
|
+
raw = readFileSync(path, "utf8")
|
|
311
|
+
} catch {
|
|
312
|
+
return { ok: false, plugins: [], rest: {} }
|
|
313
|
+
}
|
|
314
|
+
const parsed = tolerantParse(raw)
|
|
315
|
+
if (parsed === undefined || typeof parsed !== "object" || parsed === null) {
|
|
316
|
+
return { ok: false, plugins: [], rest: {} }
|
|
317
|
+
}
|
|
318
|
+
const obj = parsed as Record<string, unknown>
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
plugins: Array.isArray(obj.plugins) ? obj.plugins.map(String) : [],
|
|
322
|
+
rest: obj,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Tolerant JSONC config handling
|
|
328
|
+
//
|
|
329
|
+
// OpenCode configs may be JSONC (comments, trailing commas), and users
|
|
330
|
+
// legitimately keep comments in them. Two pieces:
|
|
331
|
+
// 1. tolerantParse — reads JSONC the way the server does.
|
|
332
|
+
// 2. textual plugins-array editing — splices the `plugins` array in place
|
|
333
|
+
// so every comment and every byte of formatting outside it survives.
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Strip // and block comments plus trailing commas from JSONC text (string
|
|
337
|
+
* contents are preserved verbatim) so JSON.parse can read the result. */
|
|
338
|
+
export function cleanJsonc(text: string): string {
|
|
339
|
+
let out = ""
|
|
340
|
+
let i = 0
|
|
341
|
+
while (i < text.length) {
|
|
342
|
+
const c = text[i]
|
|
343
|
+
if (c === '"') {
|
|
344
|
+
// Copy the string (with escapes) verbatim.
|
|
345
|
+
let j = i + 1
|
|
346
|
+
while (j < text.length) {
|
|
347
|
+
if (text[j] === "\\") j += 2
|
|
348
|
+
else if (text[j] === '"') {
|
|
349
|
+
j++
|
|
350
|
+
break
|
|
351
|
+
} else j++
|
|
352
|
+
}
|
|
353
|
+
out += text.slice(i, j)
|
|
354
|
+
i = j
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
358
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
359
|
+
continue // the newline itself is copied on the next iteration
|
|
360
|
+
}
|
|
361
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
362
|
+
i += 2
|
|
363
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
364
|
+
i += 2
|
|
365
|
+
out += " "
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
if (c === ",") {
|
|
369
|
+
// Drop a trailing comma: skip whitespace/comments after it and check
|
|
370
|
+
// whether the next significant character closes the current scope.
|
|
371
|
+
let j = i + 1
|
|
372
|
+
for (;;) {
|
|
373
|
+
while (j < text.length && /\s/.test(text[j])) j++
|
|
374
|
+
if (text[j] === "/" && text[j + 1] === "/") {
|
|
375
|
+
while (j < text.length && text[j] !== "\n") j++
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
if (text[j] === "/" && text[j + 1] === "*") {
|
|
379
|
+
j += 2
|
|
380
|
+
while (j < text.length && !(text[j] === "*" && text[j + 1] === "/")) j++
|
|
381
|
+
j += 2
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
break
|
|
385
|
+
}
|
|
386
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
387
|
+
i++ // drop the comma
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
out += c
|
|
392
|
+
i++
|
|
393
|
+
}
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Parse JSONC (or plain JSON). Returns undefined for unparseable input. */
|
|
398
|
+
export function tolerantParse(text: string): unknown {
|
|
399
|
+
try {
|
|
400
|
+
return JSON.parse(cleanJsonc(text))
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface StrTok {
|
|
407
|
+
t: "str"
|
|
408
|
+
v: string
|
|
409
|
+
start: number
|
|
410
|
+
end: number
|
|
411
|
+
}
|
|
412
|
+
interface PTok {
|
|
413
|
+
t: "p"
|
|
414
|
+
ch: string
|
|
415
|
+
start: number
|
|
416
|
+
}
|
|
417
|
+
type Tok = StrTok | PTok
|
|
418
|
+
|
|
419
|
+
/** Tokenize JSONC: strings (with offsets) and punctuation; comments skipped. */
|
|
420
|
+
function scanTokens(text: string): Tok[] {
|
|
421
|
+
const toks: Tok[] = []
|
|
422
|
+
let i = 0
|
|
423
|
+
while (i < text.length) {
|
|
424
|
+
const c = text[i]
|
|
425
|
+
if (c === '"') {
|
|
426
|
+
let j = i + 1
|
|
427
|
+
while (j < text.length) {
|
|
428
|
+
if (text[j] === "\\") j += 2
|
|
429
|
+
else if (text[j] === '"') {
|
|
430
|
+
j++
|
|
431
|
+
break
|
|
432
|
+
} else j++
|
|
433
|
+
}
|
|
434
|
+
toks.push({ t: "str", v: text.slice(i + 1, j - 1), start: i, end: j })
|
|
435
|
+
i = j
|
|
436
|
+
continue
|
|
437
|
+
}
|
|
438
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
439
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
443
|
+
i += 2
|
|
444
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
445
|
+
i += 2
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
if ("{}[],:".includes(c)) toks.push({ t: "p", ch: c, start: i })
|
|
449
|
+
i++
|
|
450
|
+
}
|
|
451
|
+
return toks
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function indentOf(text: string, pos: number): string {
|
|
455
|
+
const lineStart = text.lastIndexOf("\n", pos - 1) + 1
|
|
456
|
+
// `[ \t]*` always matches (possibly empty), so exec never returns null.
|
|
457
|
+
return /^[ \t]*/.exec(text.slice(lineStart, pos))![0]
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Locate the top-level `plugins` key. Returns the token index of its "[",
|
|
461
|
+
* or null when the key is absent. `nonArray` flags `"plugins": <not-array>`.
|
|
462
|
+
* Only the root-level key matches — nested objects that happen to have a
|
|
463
|
+
* `plugins` key (e.g. under `mcp`) are ignored. */
|
|
464
|
+
function findPluginsArray(toks: Tok[]): { bracket: number; nonArray?: boolean } | null {
|
|
465
|
+
// The document is an object, so its keys live at brace depth 1; anything
|
|
466
|
+
// nested sits deeper.
|
|
467
|
+
let depth = 0
|
|
468
|
+
for (let i = 0; i < toks.length - 1; i++) {
|
|
469
|
+
const t = toks[i]
|
|
470
|
+
if (t.t === "p") {
|
|
471
|
+
if (t.ch === "{" || t.ch === "[") depth++
|
|
472
|
+
else if (t.ch === "}" || t.ch === "]") depth--
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
if (depth !== 1 || t.t !== "str" || t.v !== "plugins") continue
|
|
476
|
+
const colon = toks[i + 1]
|
|
477
|
+
if (colon.t !== "p" || colon.ch !== ":") continue
|
|
478
|
+
const value = toks[i + 2]
|
|
479
|
+
if (value.t === "p" && value.ch === "[") return { bracket: i + 2 }
|
|
480
|
+
return { bracket: -1, nonArray: true }
|
|
481
|
+
}
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Collect the string items of the plugins array. Returns null when the array
|
|
486
|
+
* holds anything besides strings and commas (not a plugin list) — callers
|
|
487
|
+
* fall back to a full rewrite. */
|
|
488
|
+
function arrayItems(toks: Tok[], open: number, close: number): StrTok[] | null {
|
|
489
|
+
const items: StrTok[] = []
|
|
490
|
+
for (let i = open + 1; i < close; i++) {
|
|
491
|
+
const t = toks[i]
|
|
492
|
+
if (t.t === "str") items.push(t)
|
|
493
|
+
else if (t.ch !== ",") return null
|
|
494
|
+
}
|
|
495
|
+
return items
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function matchingClose(toks: Tok[], open: number): number {
|
|
499
|
+
let depth = 0
|
|
500
|
+
for (let i = open; i < toks.length; i++) {
|
|
501
|
+
const t = toks[i]
|
|
502
|
+
if (t.t !== "p") continue
|
|
503
|
+
if (t.ch === "[" || t.ch === "{") depth++
|
|
504
|
+
else if (t.ch === "]" || t.ch === "}") {
|
|
505
|
+
depth--
|
|
506
|
+
if (depth === 0) return i
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return -1
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Insert a plugin specifier into the `plugins` array textually. Returns the
|
|
513
|
+
* updated document, the original text when the spec is already present, or
|
|
514
|
+
* null when there is no plugins array (the caller may insert one). */
|
|
515
|
+
export function addPluginSpec(raw: string, spec: string): string | null {
|
|
516
|
+
const toks = scanTokens(raw)
|
|
517
|
+
const found = findPluginsArray(toks)
|
|
518
|
+
if (!found || found.nonArray || found.bracket < 0) return null
|
|
519
|
+
const close = matchingClose(toks, found.bracket)
|
|
520
|
+
if (close < 0) return null
|
|
521
|
+
const items = arrayItems(toks, found.bracket, close)
|
|
522
|
+
if (items === null) return null
|
|
523
|
+
const q = JSON.stringify(spec)
|
|
524
|
+
|
|
525
|
+
if (items.length === 0) {
|
|
526
|
+
const openTok = toks[found.bracket] as PTok
|
|
527
|
+
const closeTok = toks[close] as PTok
|
|
528
|
+
if (!raw.slice(openTok.start, closeTok.start).includes("\n")) {
|
|
529
|
+
return raw.slice(0, openTok.start + 1) + q + raw.slice(closeTok.start)
|
|
530
|
+
}
|
|
531
|
+
const ind = indentOf(raw, closeTok.start)
|
|
532
|
+
return raw.slice(0, openTok.start + 1) + `\n${ind} ${q}\n${ind}` + raw.slice(closeTok.start)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (items.some((t) => t.v === spec)) return raw
|
|
536
|
+
const last = items[items.length - 1]
|
|
537
|
+
// Inline array: append without a newline.
|
|
538
|
+
if (!raw.slice(toks[found.bracket].start, last.start).includes("\n")) {
|
|
539
|
+
return raw.slice(0, last.end) + `,${q}` + raw.slice(last.end)
|
|
540
|
+
}
|
|
541
|
+
const ind = indentOf(raw, last.start)
|
|
542
|
+
// A trailing comma (legal JSONC) right after the last item → insert after it.
|
|
543
|
+
let after = last.end
|
|
544
|
+
while (after < raw.length && (raw[after] === " " || raw[after] === "\t")) after++
|
|
545
|
+
if (raw[after] === ",") {
|
|
546
|
+
return raw.slice(0, after + 1) + `\n${ind}${q}` + raw.slice(after + 1)
|
|
547
|
+
}
|
|
548
|
+
return raw.slice(0, last.end) + `,\n${ind}${q}` + raw.slice(last.end)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Insert a `plugins` array (with one specifier) into a document that has no
|
|
552
|
+
* plugins key, textually — preserving any comments. */
|
|
553
|
+
export function addPluginsKey(raw: string, spec: string): string | null {
|
|
554
|
+
const toks = scanTokens(raw)
|
|
555
|
+
let closeTok: PTok | undefined
|
|
556
|
+
for (const t of toks) if (t.t === "p" && t.ch === "}") closeTok = t
|
|
557
|
+
if (!closeTok) return null
|
|
558
|
+
const q = JSON.stringify(spec)
|
|
559
|
+
// Does the object already have members (a "," or ":" before the brace)?
|
|
560
|
+
let needsComma = false
|
|
561
|
+
for (const t of toks) {
|
|
562
|
+
if (t.t === "p" && t.ch === "}" && t.start === closeTok.start) break
|
|
563
|
+
if (t.t === "p" && (t.ch === "," || t.ch === ":")) needsComma = true
|
|
564
|
+
}
|
|
565
|
+
const ind = indentOf(raw, closeTok.start)
|
|
566
|
+
const prefix = needsComma ? "," : ""
|
|
567
|
+
return raw.slice(0, closeTok.start) + `${prefix}\n${ind} "plugins": [${q}]\n${ind}` + raw.slice(closeTok.start)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Remove the first plugin specifier matching `matches` from the `plugins`
|
|
571
|
+
* array, textually. Returns the text and whether anything was removed, or
|
|
572
|
+
* null when there is no plugins array. */
|
|
573
|
+
export function removePluginSpec(
|
|
574
|
+
raw: string,
|
|
575
|
+
matches: (spec: string) => boolean,
|
|
576
|
+
): { text: string; removed: boolean } | null {
|
|
577
|
+
const toks = scanTokens(raw)
|
|
578
|
+
const found = findPluginsArray(toks)
|
|
579
|
+
if (!found || found.nonArray || found.bracket < 0) return null
|
|
580
|
+
const close = matchingClose(toks, found.bracket)
|
|
581
|
+
if (close < 0) return null
|
|
582
|
+
const items = arrayItems(toks, found.bracket, close)
|
|
583
|
+
if (items === null) return null
|
|
584
|
+
|
|
585
|
+
// Find the first matching item's token index directly.
|
|
586
|
+
let at = -1
|
|
587
|
+
for (let i = found.bracket + 1; i < close; i++) {
|
|
588
|
+
const t = toks[i]
|
|
589
|
+
if (t.t === "str" && matches(t.v)) {
|
|
590
|
+
at = i
|
|
591
|
+
break
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (at < 0) return { text: raw, removed: false }
|
|
595
|
+
const item = toks[at] as StrTok
|
|
596
|
+
// Trim whitespace back to (and including) the item's newline, so a removed
|
|
597
|
+
// multiline item doesn't leave a blank indented line behind.
|
|
598
|
+
let cutStart = item.start
|
|
599
|
+
while (cutStart > 0 && (raw[cutStart - 1] === " " || raw[cutStart - 1] === "\t")) cutStart--
|
|
600
|
+
if (raw[cutStart - 1] === "\n") cutStart--
|
|
601
|
+
const next = toks[at + 1]
|
|
602
|
+
if (next && next.t === "p" && next.ch === ",") {
|
|
603
|
+
// Drop the item and its trailing comma, collapsing the whitespace after
|
|
604
|
+
// the comma to a single space so inline arrays keep a separator. When the
|
|
605
|
+
// removed item was the first, no separator is needed after the "[".
|
|
606
|
+
let end = (next as PTok).start + 1
|
|
607
|
+
let ws = ""
|
|
608
|
+
const isFirst = !toks.slice(found.bracket + 1, at).some((t) => t.t === "str")
|
|
609
|
+
if (!isFirst) {
|
|
610
|
+
while (end < raw.length && (raw[end] === " " || raw[end] === "\t")) {
|
|
611
|
+
ws ||= " "
|
|
612
|
+
end++
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
text: raw.slice(0, cutStart) + ws + raw.slice(end),
|
|
617
|
+
removed: true,
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// Last item: drop the preceding comma (if any) together with the item.
|
|
621
|
+
if (at > found.bracket + 1) {
|
|
622
|
+
const prev = toks[at - 1]
|
|
623
|
+
if (prev.t === "p" && prev.ch === ",") cutStart = prev.start
|
|
624
|
+
}
|
|
625
|
+
return { text: raw.slice(0, cutStart) + raw.slice(item.end), removed: true }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Apply an install/uninstall edit to the raw config text, preserving every
|
|
629
|
+
* comment and every byte of formatting outside the `plugins` array. Falls
|
|
630
|
+
* back to a full rewrite only when the document shape defeats the textual
|
|
631
|
+
* edit. */
|
|
632
|
+
function editPluginsText(
|
|
633
|
+
raw: string,
|
|
634
|
+
edit: { add: string; remove?: undefined } | { add?: undefined; remove: (spec: string) => boolean },
|
|
635
|
+
parsed: any,
|
|
636
|
+
next: string[],
|
|
637
|
+
): string {
|
|
638
|
+
if (edit.add !== undefined) {
|
|
639
|
+
const spliced = addPluginSpec(raw, edit.add)
|
|
640
|
+
if (spliced !== null) return spliced
|
|
641
|
+
const keyed = addPluginsKey(raw, edit.add)
|
|
642
|
+
if (keyed !== null) return keyed
|
|
643
|
+
} else {
|
|
644
|
+
const result = removePluginSpec(raw, edit.remove)
|
|
645
|
+
if (result !== null) return result.text
|
|
646
|
+
}
|
|
647
|
+
const base = typeof parsed === "object" && parsed !== null ? parsed : {}
|
|
648
|
+
return `${JSON.stringify({ ...base, plugins: next }, null, 2)}\n`
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Toggle a plugin's installed/uninstalled status by editing the config's
|
|
652
|
+
// `plugins` array. The edit is textual, so comments and formatting in JSONC
|
|
653
|
+
// configs survive. Returns a user-facing message; throws on failure.
|
|
654
|
+
export async function toggleInstalled(ctx: any, root: string, e: Entry): Promise<string> {
|
|
655
|
+
const cfgOut = await ctx.client.config.get().catch((_err: any) => {
|
|
656
|
+
return undefined
|
|
657
|
+
})
|
|
658
|
+
const cfgData: any = (cfgOut as any)?.data ?? cfgOut
|
|
659
|
+
const docs = Array.isArray(cfgData) ? cfgData : asArray<any>(cfgData)
|
|
660
|
+
const path = configDocPath(docs, root)
|
|
661
|
+
const docDir = dirname(path)
|
|
662
|
+
let raw = ""
|
|
663
|
+
try {
|
|
664
|
+
raw = readFileSync(path, "utf8")
|
|
665
|
+
} catch {
|
|
666
|
+
raw = ""
|
|
667
|
+
}
|
|
668
|
+
const parsed = tolerantParse(raw)
|
|
669
|
+
if (parsed === undefined) throw new Error(`Cannot parse ${path}`)
|
|
670
|
+
const plugins = Array.isArray((parsed as any)?.plugins) ? (parsed as any).plugins.map(String) : []
|
|
671
|
+
const installing = e.status === "uninstalled"
|
|
672
|
+
|
|
673
|
+
let next: string[]
|
|
674
|
+
let edit: { add: string; remove?: undefined } | { add?: undefined; remove: (spec: string) => boolean }
|
|
675
|
+
if (installing) {
|
|
676
|
+
const spec = e.dir ? `./${basename(e.dir)}` : e.name // npm specifier; OpenCode resolves it on next start
|
|
677
|
+
if (plugins.includes(spec)) {
|
|
678
|
+
return `${e.name} registered — restart the TUI to load it`
|
|
679
|
+
}
|
|
680
|
+
next = [...plugins, spec]
|
|
681
|
+
edit = { add: spec }
|
|
682
|
+
} else {
|
|
683
|
+
next = plugins.filter((spec: string) => !specMatches(spec, docDir, e))
|
|
684
|
+
if (next.length === plugins.length) throw new Error(`No config entry matches ${e.name}`)
|
|
685
|
+
edit = { remove: (spec) => specMatches(spec, docDir, e) }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const fs = await import("node:fs")
|
|
689
|
+
fs.writeFileSync(path, editPluginsText(raw, edit, parsed, next))
|
|
690
|
+
return installing
|
|
691
|
+
? `${e.name} registered — restart the TUI to load it`
|
|
692
|
+
: `${e.name} removed — restart the TUI to unload it`
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Widget — the top-level section and each group render through the kit's
|
|
697
|
+
// CollapsibleSection/CollapsibleGroup (same pattern as the built-in
|
|
698
|
+
// opencode.sidebar.mcp widget and the skill lister).
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
export const STATUS_GLYPH: Record<string, string> = {
|
|
702
|
+
active: "✓",
|
|
703
|
+
inactive: "○",
|
|
704
|
+
failed: "✗",
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export const GROUP_TITLE: Record<Kind, string> = {
|
|
708
|
+
npm: "NPM",
|
|
709
|
+
local: "LOCAL",
|
|
710
|
+
builtin: "BUILT-IN",
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function PluginList(props: { sessionID?: string }) {
|
|
714
|
+
const ctx = usePlugin()
|
|
715
|
+
const theme = ctx.theme
|
|
716
|
+
|
|
717
|
+
// Built-in categories: sub-groups inside the BUILT-IN section, derived
|
|
718
|
+
// from the plugin id (opencode.<category>.<name>); everything without a
|
|
719
|
+
// known category lands in "core".
|
|
720
|
+
const BUILTIN_CATEGORIES: Array<[string, string]> = [
|
|
721
|
+
["tool", "TOOLS"],
|
|
722
|
+
["provider", "PROVIDERS"],
|
|
723
|
+
["config", "CONFIG"],
|
|
724
|
+
["websearch", "WEB SEARCH"],
|
|
725
|
+
["prompt", "PROMPT ADAPTERS"],
|
|
726
|
+
]
|
|
727
|
+
const builtinCategory = (id: string): string => {
|
|
728
|
+
const parts = id.split(".")
|
|
729
|
+
return parts[0] === "opencode" && parts.length >= 2 ? parts[1] : ""
|
|
730
|
+
}
|
|
731
|
+
const builtinSubGroups = (items: Entry[]) => {
|
|
732
|
+
const groups: Array<{ key: string; title: string; items: Entry[] }> = []
|
|
733
|
+
const seen = new Map<string, Entry[]>()
|
|
734
|
+
for (const [cat, title] of BUILTIN_CATEGORIES) {
|
|
735
|
+
const items2 = items.filter((e) => builtinCategory(e.name) === cat)
|
|
736
|
+
if (items2.length) {
|
|
737
|
+
seen.set(cat, items2)
|
|
738
|
+
groups.push({ key: `builtin:${cat}`, title, items: items2 })
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const rest = items.filter((e) => !seen.has(builtinCategory(e.name)))
|
|
742
|
+
if (rest.length) groups.push({ key: "builtin:core", title: "CORE", items: rest })
|
|
743
|
+
return groups
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Built-ins are collapsed out of the summary count when hidden via
|
|
747
|
+
// /plugins-builtins (view toggle only — they still run).
|
|
748
|
+
const list = () => {
|
|
749
|
+
const all = entries.data() ?? []
|
|
750
|
+
return builtins.value() ? all : all.filter((e) => e.kind !== "builtin")
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Click-to-inspect (built-ins have no hover tooltip support in OpenTUI):
|
|
754
|
+
// selecting a row reveals its details inline beneath it.
|
|
755
|
+
const [selected, setSelected] = createSignal<string | null>(null)
|
|
756
|
+
const selectRow = (e: Entry) => setSelected((cur) => (cur === e.name ? null : e.name))
|
|
757
|
+
|
|
758
|
+
const entries = createCachedResource(
|
|
759
|
+
() => props.sessionID,
|
|
760
|
+
async () => loadEntries(ctx, workspaceDirectory(ctx)),
|
|
761
|
+
{ cache },
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
const groups = () =>
|
|
765
|
+
GROUP_ORDER.map((kind) => ({
|
|
766
|
+
kind,
|
|
767
|
+
items: list().filter((e) => e.kind === kind),
|
|
768
|
+
})).filter((g) => g.items.length > 0)
|
|
769
|
+
|
|
770
|
+
// Collapsed summary, omitting empty groups: "2 npm · 4 local · 86 builtin".
|
|
771
|
+
const summary = () =>
|
|
772
|
+
GROUP_ORDER.map((kind) => {
|
|
773
|
+
const n = list().filter((e) => e.kind === kind).length
|
|
774
|
+
return n > 0 ? `${n} ${kind}` : ""
|
|
775
|
+
})
|
|
776
|
+
.filter(Boolean)
|
|
777
|
+
.join(" · ")
|
|
778
|
+
|
|
779
|
+
// Click a plugin row to flip its status: uninstalled → registered in the
|
|
780
|
+
// project config; installed → removed from it. A restart applies the
|
|
781
|
+
// change (plugins load at startup).
|
|
782
|
+
const [note, setNote] = createSignal<{ ok: boolean; text: string } | null>(null)
|
|
783
|
+
const onToggle = async (e: Entry) => {
|
|
784
|
+
// Built-in rows route to selectRow() and render no control, so this
|
|
785
|
+
// guard is defensive only.
|
|
786
|
+
/* v8 ignore next */
|
|
787
|
+
if (e.kind === "builtin") return
|
|
788
|
+
const root = workspaceDirectory(ctx)
|
|
789
|
+
try {
|
|
790
|
+
const message = await toggleInstalled(ctx, root, e)
|
|
791
|
+
setNote({ ok: true, text: message })
|
|
792
|
+
showToast(ctx, message)
|
|
793
|
+
await entries.refetchNow()
|
|
794
|
+
} catch (err: any) {
|
|
795
|
+
// toggleInstalled only ever throws Errors; the ?? fallbacks are
|
|
796
|
+
// defensive only.
|
|
797
|
+
/* v8 ignore next */
|
|
798
|
+
const text = String(err?.message ?? err ?? "toggle failed")
|
|
799
|
+
setNote({ ok: false, text })
|
|
800
|
+
showToast(ctx, text, "error")
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const row = (e: Entry) => {
|
|
805
|
+
const uninstalled = e.status === "uninstalled"
|
|
806
|
+
const fg = uninstalled ? theme.text.subdued : theme.text.default
|
|
807
|
+
const glyph = uninstalled ? "○" : (STATUS_GLYPH[e.status ?? ""] ?? "•")
|
|
808
|
+
const version = e.version ? ` ${e.version}` : ""
|
|
809
|
+
const flag = e.outdated ? " ⚠ update" : ""
|
|
810
|
+
// MCPs show "connected"; plugins show their install state, right-aligned.
|
|
811
|
+
const state = uninstalled ? "uninstalled" : e.status === "failed" ? "failed" : "installed"
|
|
812
|
+
return {
|
|
813
|
+
glyph,
|
|
814
|
+
text: `${e.name}${version}${flag}`,
|
|
815
|
+
state,
|
|
816
|
+
fg,
|
|
817
|
+
stateFg: state === "failed" ? theme.text.default : theme.text.subdued,
|
|
818
|
+
// Explicit per-row control: [–] removes the config entry, [+] registers
|
|
819
|
+
// it. Built-ins aren't config-managed, so no control.
|
|
820
|
+
control: e.kind === "builtin" ? "" : uninstalled ? "[+]" : "[–]",
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// One plugin row + its optional inline description (built-ins only).
|
|
825
|
+
const RowBlock = (p: { e: Entry }) => {
|
|
826
|
+
const r = row(p.e)
|
|
827
|
+
const e = p.e
|
|
828
|
+
return (
|
|
829
|
+
<box flexDirection="column">
|
|
830
|
+
<box flexDirection="row" gap={1} minWidth={0} onMouseDown={() => selectRow(e)}>
|
|
831
|
+
<text fg={r.fg} flexShrink={0}>
|
|
832
|
+
{r.glyph}
|
|
833
|
+
</text>
|
|
834
|
+
<text fg={r.fg} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
|
835
|
+
{r.text}
|
|
836
|
+
</text>
|
|
837
|
+
<text fg={r.stateFg} flexShrink={0}>
|
|
838
|
+
{r.state}
|
|
839
|
+
</text>
|
|
840
|
+
<Show when={r.control}>
|
|
841
|
+
<text fg={theme.text.subdued} flexShrink={0} onMouseDown={() => onToggle(e)}>
|
|
842
|
+
{r.control}
|
|
843
|
+
</text>
|
|
844
|
+
</Show>
|
|
845
|
+
</box>
|
|
846
|
+
<Show when={selected() === e.name}>
|
|
847
|
+
<box flexDirection="column" marginLeft={3} onMouseDown={() => selectRow(e)}>
|
|
848
|
+
<text fg={theme.text.subdued} wrapMode="none">
|
|
849
|
+
{e.kind === "builtin"
|
|
850
|
+
? describeBuiltin(e.name)
|
|
851
|
+
: (e.description ?? "No description — check the project's package.json.")}
|
|
852
|
+
</text>
|
|
853
|
+
</box>
|
|
854
|
+
</Show>
|
|
855
|
+
</box>
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return (
|
|
860
|
+
<Show when={!entries.data.error} fallback={<text>⚠ plugins unavailable</text>}>
|
|
861
|
+
<Show when={list().length > 0}>
|
|
862
|
+
<CollapsibleSection
|
|
863
|
+
title="PLUGINS"
|
|
864
|
+
count={list().length}
|
|
865
|
+
summary={summary()}
|
|
866
|
+
pinned={
|
|
867
|
+
<Show when={note()}>
|
|
868
|
+
{(n) => (
|
|
869
|
+
<text fg={n().ok ? theme.text.subdued : theme.text.default} wrapMode="none" truncate>
|
|
870
|
+
{n().ok ? "" : "✗ "}
|
|
871
|
+
{n().text}
|
|
872
|
+
</text>
|
|
873
|
+
)}
|
|
874
|
+
</Show>
|
|
875
|
+
}
|
|
876
|
+
>
|
|
877
|
+
<For each={groups()}>
|
|
878
|
+
{(g) => (
|
|
879
|
+
<CollapsibleGroup
|
|
880
|
+
title={GROUP_TITLE[g.kind]}
|
|
881
|
+
count={g.items.length}
|
|
882
|
+
defaultCollapsed={DEFAULT_COLLAPSED[g.kind]}
|
|
883
|
+
>
|
|
884
|
+
{(collapsed) => (
|
|
885
|
+
<>
|
|
886
|
+
<Show when={g.kind === "builtin" && !collapsed()}>
|
|
887
|
+
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
|
888
|
+
built-ins can't be disabled individually;{" "}
|
|
889
|
+
{process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
|
890
|
+
? "OPENCODE_DISABLE_DEFAULT_PLUGINS is on (all off)"
|
|
891
|
+
: "OPENCODE_DISABLE_DEFAULT_PLUGINS turns all off"}
|
|
892
|
+
</text>
|
|
893
|
+
</Show>
|
|
894
|
+
<Show when={g.kind === "builtin"} fallback={<For each={g.items}>{(e) => <RowBlock e={e} />}</For>}>
|
|
895
|
+
<For each={builtinSubGroups(g.items)}>
|
|
896
|
+
{(sub) => (
|
|
897
|
+
<CollapsibleGroup title={sub.title} count={sub.items.length}>
|
|
898
|
+
<For each={sub.items}>{(e) => <RowBlock e={e} />}</For>
|
|
899
|
+
</CollapsibleGroup>
|
|
900
|
+
)}
|
|
901
|
+
</For>
|
|
902
|
+
</Show>
|
|
903
|
+
</>
|
|
904
|
+
)}
|
|
905
|
+
</CollapsibleGroup>
|
|
906
|
+
)}
|
|
907
|
+
</For>
|
|
908
|
+
</CollapsibleSection>
|
|
909
|
+
</Show>
|
|
910
|
+
</Show>
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// The cache must exist before the component renders, so create it at module
|
|
915
|
+
// scope and hydrate it from setup() (which owns the storage context).
|
|
916
|
+
type EntryCache = ReturnType<typeof createCachedStore<Entry[] | null>>
|
|
917
|
+
let cache: EntryCache
|
|
918
|
+
|
|
919
|
+
// Built-ins visibility: shown by default; /plugins-builtins toggles the
|
|
920
|
+
// view (kit createToggle). This is a view toggle only — built-ins always
|
|
921
|
+
// run. Created in setup() because it needs the storage context, then read
|
|
922
|
+
// from the component via module scope (same pattern as `cache` above).
|
|
923
|
+
let builtins: Toggle
|
|
924
|
+
|
|
925
|
+
export default Plugin.define({
|
|
926
|
+
id: "plugin-manager.cli",
|
|
927
|
+
setup(context: any) {
|
|
928
|
+
cache = createCachedStore<Entry[] | null>(context, "plugin-manager", {
|
|
929
|
+
initial: null,
|
|
930
|
+
staleAfterMs: 60_000,
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
// The kit's createToggle persists { value } under "builtins"; the legacy
|
|
934
|
+
// { show } shape it can't read falls back to the visible-by-default
|
|
935
|
+
// initial — the same outcome the previous one-time reset enforced.
|
|
936
|
+
builtins = createToggle(context, {
|
|
937
|
+
storageKey: "builtins",
|
|
938
|
+
initial: true,
|
|
939
|
+
command: {
|
|
940
|
+
id: "plugins.builtins",
|
|
941
|
+
group: "Plugins",
|
|
942
|
+
name: "plugins-builtins",
|
|
943
|
+
description: "Show or hide the built-in plugins group in the sidebar",
|
|
944
|
+
title: (value) => `Plugins: built-ins (${value ? "visible" : "hidden"})`,
|
|
945
|
+
},
|
|
946
|
+
toast: (value) => `Built-in plugins ${value ? "visible" : "hidden"} (they still run)`,
|
|
947
|
+
})
|
|
948
|
+
builtins.registerCommand()
|
|
949
|
+
|
|
950
|
+
// Placed with `after` so it renders below the built-in sidebar.content
|
|
951
|
+
// appends (Context, MCP). Ordering among plugins on the same slot follows
|
|
952
|
+
// the opencode.json `plugins` array — register this plugin after
|
|
953
|
+
// opencode-skill-lister so the section sits under MCP and Skills.
|
|
954
|
+
return context.ui.slot({
|
|
955
|
+
after: "sidebar.content",
|
|
956
|
+
render: ({ sessionID }: { sessionID?: string }) => <PluginList sessionID={sessionID} />,
|
|
957
|
+
})
|
|
958
|
+
},
|
|
959
|
+
})
|