opencode-webui 2.3.0 → 3.0.0

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.
@@ -6,8 +6,8 @@ that matches the job (framing rule below), never two for the same job.
6
6
 
7
7
  ```
8
8
  my-extension/
9
- manifest.json id, name, version, description, disabled (optional bool)
10
- index.tsx browser stratum: register() against the registry
9
+ manifest.json id, name, version, description; optional disabled, settings, requires, capabilities
10
+ index.tsx browser stratum: register() and/or activate(ctx)
11
11
  dom.ts DOM stratum: post-render DOM changes (the free layer)
12
12
  server.ts proxy stratum: routes / middleware / event tap / pollers
13
13
  engine/ optional opencode plugin payload (tools, system-prompt hints)
@@ -16,7 +16,16 @@ my-extension/
16
16
  ```jsonc
17
17
  // manifest.json
18
18
  { "id": "my-extension", "name": "My extension", "version": "1.0.0",
19
- "description": "What it does" /* "disabled": true — paused */ }
19
+ "description": "What it does" /* "disabled": true — paused */,
20
+ // Declared settings (roadmap 5): core renders these in Settings › Extensions.
21
+ "settings": [
22
+ { "key": "enabled", "type": "boolean", "title": "Enabled", "default": true },
23
+ { "key": "threshold", "type": "number", "title": "Threshold", "default": 5, "min": 1, "max": 20 },
24
+ { "key": "mode", "type": "enum", "title": "Mode", "options": ["fast", "thorough"], "default": "fast" }
25
+ ],
26
+ // Checkable references (roadmap 8): an unmet one is a visible warning.
27
+ "requires": { "api": 1, "targets": ["message.timestamp"], "slots": ["composer.above"] },
28
+ "capabilities": ["notify"] }
20
29
  ```
21
30
 
22
31
  **Gating — one state, owned by the folder itself:** presence = installed;
@@ -28,6 +37,16 @@ pausing: the former keeps the entry loaded but quiet, the latter
28
37
  (`disabled: true`) is never bundled or imported and its id unregisters —
29
38
  use a settings toggle for "off for now", the manifest for "unplug".
30
39
 
40
+ **Settings › Extensions** renders one card per installed extension (its
41
+ `name`/`description` from `manifest.json`) with an on/off switch, plus any
42
+ `settings` collection the extension contributes, inline in that card. The
43
+ switch is just a UI for the folder flag above: it calls
44
+ `POST /api/webui/extensions/<id>/state { disabled }`, which edits the winning
45
+ folder's `manifest.json`. A **shipped** id is never edited in place (an app
46
+ update would clobber the flag) — disabling it writes a user-level shadow
47
+ folder with the same id, removed again on re-enable (shipped browser bundles
48
+ are glob-owned, so re-enabling reloads the page to re-register them).
49
+
31
50
  **Precedence (same id = same swap point, higher wins):**
32
51
 
33
52
  1. `~/.config/opencode/webui-extensions/<name>/` — user
@@ -65,7 +84,7 @@ semantics are the whole point: `wrap` = default, `replace` = ownership.
65
84
 
66
85
  | Kind | Job | Staleness |
67
86
  | --- | --- | --- |
68
- | `wrap` | Flow-through tweak of any registered target: `render(props, next)` — transform props/output, delegate to live core by default | **Stale-proof by construction.** Core updates always render *through* it. The default path for edits. |
87
+ | `wrap` | Flow-through tweak of any registered target: `render(props, next)` — transform output, and/or call `next(overrides)` to merge changed/extra props into the rest of the chain, delegating to live core by default | **Stale-proof by construction.** Core updates always render *through* it. The default path for edits. |
69
88
  | `replace` | Take ownership of one registered target: `render(props, core)` wins outright at its priority; return `null` to fall through to the next candidate / core | **Frozen snapshot.** You opt out of core updates for that target — the marked escape hatch. Still receives `core` so you *can* compose. |
70
89
  | `contribute` | Add an item to a named collection (`collection` + `item`, `order` sorts, lower first) | Data, not code — core owns the list, you own your row. |
71
90
  | `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
@@ -90,7 +109,7 @@ with rich props, so wraps and value-overrides stay surgical.
90
109
  | `conversation.header` | full `HeaderProps` |
91
110
  | `conversation.empty` | — |
92
111
  | `composer` | full `ComposerProps` |
93
- | `composer.contextReadout` | `parts: string[]` |
112
+ | `composer.contextReadout` | `parts: string[]`, `sessionID` |
94
113
  | `composer.sendActions` | `sessionID`, `appendDraft(text)` — space-joins onto the draft + refocuses; prefer over writing drafts directly |
95
114
  | `message.timestamp` | `time: number` (consults the `format.timestamp` service) |
96
115
  | `message.tokens` | `tokens` |
@@ -98,7 +117,7 @@ with rich props, so wraps and value-overrides stay surgical.
98
117
  | `message.copyButton` | `variant: "user" \| "assistant"`, `text` |
99
118
  | `message:<type>` / `message:*` | replace-with-fall-through per message type |
100
119
  | `tool.card` | `part: ToolPart`, `stateKey?` |
101
- | `tool.edit` / `write` / `shell` / `subagent` / `execute` / `generic` | per-tool view props |
120
+ | `tool.edit` / `write` / `shell` / `subagent` / `execute` / `read` / `generic` | per-tool view props |
102
121
  | `tool:<name>` | replace-with-fall-through per tool name |
103
122
 
104
123
  Persisted-vs-live guarantee (streaming authors depend on this): persisted
@@ -115,14 +134,176 @@ must never touch streaming output can rely on the distinction structurally.
115
134
  - **Runtime code uses the bridge only.** External (user/project-dir) bundles
116
135
  are built standalone: `import type` from `src/` is erased at build and
117
136
  safe, but any *runtime* `src/` import breaks the copy outside the repo.
118
- Use `window.__opencodeUI` (`register`, `react`, `api`, `store`, `prefs`,
119
- `notify`, `services`, `dom`, `kv`) — shipped code consumes the identical
137
+ Use `window.__opencodeUI` (`register`, `react`, `api`, `store` [the curated
138
+ facade], `events`, `settings`, `collections`, `bus`, `prefs`, `notify`,
139
+ `services`, `dom`, `kv`; raw modules as `advanced.*`) — shipped code consumes the identical
120
140
  surface via `getExtensionApi()`.
121
141
  - **The `@/` alias works in shipped extensions only.** Same repo, same
122
142
  tsconfig (`@/*` → `./src/*`, e.g. a shipped extension imports
123
143
  `@/components/ui/dialog`) — external copies must still use the bridge,
124
144
  never `@/` or relative `src/` paths.
125
145
 
146
+ ### Activation (lifecycle) — the shape to write
147
+
148
+ `index.tsx` may export an activation entry instead of registering at module
149
+ scope:
150
+
151
+ ```tsx
152
+ export const id = "my-extension";
153
+
154
+ export function activate(ctx) {
155
+ ctx.register({ kind: "wrap", id: "my-wrap", target: "message.timestamp", render: … });
156
+ const stop = someObserver(); // timer, listener, subscription…
157
+ ctx.onDispose(stop); // or: return stop
158
+ }
159
+ ```
160
+
161
+ - `ctx.register(entry)` — the same five kinds; the id is remembered so
162
+ teardown prunes exactly this extension's entries.
163
+ - `ctx.poll({ name, minInterval, intervals?, whenHidden?, run })` — recurring
164
+ work on the shared scheduler (tier-aware, jittered; the app's only timer
165
+ owner). Returns an idempotent stop; stopped automatically on dispose.
166
+ - `ctx.after(ms, fn)` — one-shot delay; returns an idempotent cancel; cleared
167
+ automatically on dispose.
168
+ - `ctx.on(name, handler)` — subscribe to the event bus (raw engine type or
169
+ derived lifecycle name; `"*"` = all). Returns an unsubscribe; removed
170
+ automatically on dispose. See **Events (observe)** below.
171
+ - `ctx.subscribe(selector, listener)` — derived store read: fires immediately,
172
+ then only when the selected value changes (shallow-equal). Auto-removed on
173
+ dispose. See **Store (read + act)** below.
174
+ - `ctx.store` — the curated store facade (selectors + actions); the same
175
+ object as the bridge's `store`.
176
+ - `ctx.settings` — resolved declared settings (`get`/`set`/`reset`/`subscribe`);
177
+ subscriptions disposed with the extension. See **Declared settings +
178
+ requirements**. No manifest schema → empty handle (use `kv` for ad-hoc data).
179
+ - `ctx.collections` — read other extensions' contributions (`get(collection)`)
180
+ and every live collection id (`list()`). See **Peer composition**.
181
+ - `ctx.bus` — extension-to-extension events: `publish(channel, payload)`
182
+ (tagged with this id) and `subscribe(channel, fn)` (disposed with the
183
+ extension). See **Peer composition**.
184
+ - `ctx.onDispose(fn)` / returning a teardown fn — runs on hot-swap,
185
+ `disabled: true`, and delete (LIFO, crash-isolated). This is the one place
186
+ non-React cleanup belongs — no `window.__*Installed` guards.
187
+ - `ctx.log(...)` — prefixed with the extension id.
188
+ - `ctx.services.getService` / `getServiceProviders` — named-logic lookups.
189
+
190
+ Module-scope `register({…})` still works (the loaders fall back to the
191
+ registry id-delta), but it is the shape being deprecated: nothing outside the
192
+ module can dispose what the module did, so `disabled`/delete/hot-swap can't
193
+ tear down listeners or timers it started. New extensions write `activate`.
194
+ The DOM stratum already has this contract (`mount` returns a cleanup fn).
195
+
196
+ ### Events (observe)
197
+
198
+ `ctx.on(name, handler)` (or the bridge's `events.subscribe`) observes what
199
+ happened without diffing store snapshots. Two families share one channel:
200
+
201
+ - **Raw engine events** — every event the store reduces, under its engine
202
+ `type` (`session.tool.success`, `session.text.delta`, `permission.asked`,
203
+ `session.idle`, …). Payload is the event's `data`.
204
+ - **Derived lifecycle events** — core computes these so you don't infer them:
205
+ `run.started` `{sessionID}` · `run.ended` `{sessionID,reason}` ·
206
+ `tool.called` `{sessionID,assistantMessageID,id,name,input?}` ·
207
+ `tool.completed` `{sessionID,assistantMessageID,id,name?,ok}` ·
208
+ `message.appended` `{sessionID,messageID,type}`.
209
+
210
+ ```tsx
211
+ ctx.on("tool.completed", (e) => {
212
+ const { name, ok } = e.payload;
213
+ usage[name] = (usage[name] ?? 0) + (ok ? 1 : 0);
214
+ });
215
+ ctx.on("run.ended", () => notify({ title: "Run finished" }));
216
+ ```
217
+
218
+ Delivery is frame-batched (16ms) so a token burst is one dispatch per frame.
219
+ `"*"` receives everything (diagnostics/analytics — it is per-event, so filter).
220
+ Listeners are crash-isolated; the subscription is disposed with the
221
+ extension. The bus is notification-only: it never mutates state and
222
+ extensions cannot publish.
223
+
224
+ ### Store (read + act)
225
+
226
+ `ctx.store` (bridge: `store`) is the curated, **supported** store surface —
227
+ selectors and actions, never the raw module (which stays reachable, and
228
+ explicitly unsupported, as `advanced.store`):
229
+
230
+ - **observe:** `subscribe(listener)`, `select(selector, listener)`,
231
+ `useStore(selector)` (React), `getState()` (full snapshot)
232
+ - **read:** `currentSessionID()`, `sessions()`, `sessionDetail(id)`,
233
+ `messages(id)`, `liveAssistants(id)`, `isRunning(id)`, `isQueued(id)`,
234
+ `pendingRequests()`, `isDraftSession(id)`, `sessionHref(id)`
235
+ - **act:** `sendPrompt`, `sendPromptTo(id, text, {delivery?})`,
236
+ `selectSession`, `navigateFocused`, `newSession`, `materializeDraft`,
237
+ `replyPermission`, `replyForm`, `replyQuestion`, `rejectQuestion`,
238
+ `interrupt`, `switchAgent`, `switchModel`, `renameSession`,
239
+ `compactSession`, `undoSession`, `redoSession`, `activateSkill`
240
+
241
+ ```tsx
242
+ // ctx.subscribe auto-disposes; ctx.store.select is the manual form.
243
+ ctx.subscribe((s) => s.currentSessionID, (id) => badge.textContent = id ?? "");
244
+ ```
245
+
246
+ Core keeps adding internal state/actions — those do **not** become API. A new
247
+ extension need means a deliberate addition to the facade (version bump), not
248
+ reaching into `advanced.store`.
249
+
250
+ ### Declared settings + requirements (manifest)
251
+
252
+ Two optional `manifest.json` blocks turn a fragile extension into a checkable
253
+ one.
254
+
255
+ **`settings`** — declare options once; core renders them in the extension's
256
+ Settings card and persists per id (defaults applied, invalid/legacy values
257
+ dropped). The extension just reads resolved values via `ctx.settings` (or the
258
+ bridge's `settings.forExt(id)`):
259
+
260
+ ```tsx
261
+ export function activate(ctx) {
262
+ const apply = () => (opts = ctx.settings.get());
263
+ apply();
264
+ ctx.settings.subscribe(apply); // auto-disposed
265
+ }
266
+ ```
267
+
268
+ Schema subset per field: `{ key, type, title, description?, default? }` where
269
+ `type` is `boolean | number (min/max/step) | string (placeholder) | enum
270
+ (options: string[])`. That covers the common toggle/threshold/format option
271
+ with no bespoke settings component or storage.
272
+
273
+ **`requires`** — declare the references you depend on
274
+ (`api` version, `targets`, `slots`, `services`). Core checks them every
275
+ manifest sync; an unmet one becomes a visible warning in your Settings card
276
+ (`⚠ unmet target "…"`) plus a console warning, instead of a silently blank
277
+ spot. `capabilities` is free-form declared metadata.
278
+
279
+ Static shape problems (bad `settings`/`requires`) are reported the same way —
280
+ never swallowed.
281
+
282
+ ### Peer composition
283
+
284
+ Extensions cooperate through two read-only surfaces — neither imports the
285
+ other:
286
+
287
+ - **Collections** — read what any extension contributed:
288
+ `ctx.collections.get("palette")` (or the bridge's `collections.get`), plus
289
+ `ctx.collections.list()` for every live collection id. Contributing is the
290
+ existing `contribute` kind; consuming another's items is just reading.
291
+ - **The peer bus** — extension-to-extension events:
292
+ `ctx.bus.subscribe(channel, fn)` and `ctx.bus.publish(channel, payload)`
293
+ (the event carries `from: <publisher id>`). Channels are free-form strings;
294
+ delivery is synchronous and low-frequency (coordination, not streams).
295
+ Subscriptions are disposed with the extension.
296
+
297
+ ```tsx
298
+ // provider
299
+ ctx.register({ kind: "contribute", id: "my-metric", collection: "metrics", item: { label: "TPS" } });
300
+ // consumer — same-page, no import
301
+ ctx.bus.subscribe("metrics.changed", ({ from }) => refresh(ctx.collections.get("metrics")));
302
+ ctx.bus.publish("metrics.changed", {});
303
+ ```
304
+
305
+ `service` still covers one-to-one provide/consume; this covers many-to-many.
306
+
126
307
  ```tsx
127
308
  // index.tsx — wrap the timestamp, own nothing else
128
309
  import { register } from "../../src/extensions/registry";
@@ -145,6 +326,20 @@ register({
145
326
  });
146
327
  ```
147
328
 
329
+ ```tsx
330
+ // index.tsx — a wrap that FEEDS the target changed props, not just output.
331
+ // `next(overrides)` shallow-merges overrides into every remaining wrap and
332
+ // the leaf (core default / winning replace). With no argument, behavior is
333
+ // exactly as before. Overrides never change the wrap's own `props`.
334
+ register({
335
+ kind: "wrap",
336
+ id: "my-append-action",
337
+ target: "composer.sendActions",
338
+ render: (props, next) =>
339
+ next({ extraActions: [...(props.extraActions as unknown[]), <MyButton />] }),
340
+ });
341
+ ```
342
+
148
343
  ```tsx
149
344
  // index.tsx — replace with fall-through: own one case, defer the rest
150
345
  register({
@@ -167,7 +362,8 @@ UI-only, local `run(args, { sessionID })`; engine commands come from
167
362
  (item `{ title, description?, render }`, routed at `/ext/{id}`),
168
363
  `settings` (item `{ title, description?, render }`, section in
169
364
  Settings › Extensions), `contextMenu.message`, `contextMenu.session`,
170
- `contextMenu.file` (item `{ label, run, order? }`).
365
+ `contextMenu.file` (item `{ label, run, order? }`), and the `slot:<id>`
366
+ placement collections (see Slots below).
171
367
 
172
368
  ```tsx
173
369
  register({
@@ -178,6 +374,30 @@ register({
178
374
  });
179
375
  ```
180
376
 
377
+ ### Slots (placement)
378
+
379
+ A **slot** is a named insertion point in core chrome — placement, not
380
+ identity. Targets render a unit's component chain (something with an id you
381
+ tweak); slots render whatever anyone contributed to a place. Same `contribute`
382
+ kind, collection `slot:<slotID>`:
383
+
384
+ ```tsx
385
+ register({
386
+ kind: "contribute",
387
+ id: "my-compose-badge",
388
+ collection: "slot:composer.above",
389
+ item: { render: ({ sessionID }) => <span>draft for {sessionID}</span> },
390
+ });
391
+ ```
392
+
393
+ Known slot ids (the versioned registry, `src/extensions/slots.tsx`):
394
+ `conversation.header.actions`, `conversation.empty`, `composer.above`,
395
+ `composer.actions`, `sidebar.header.actions`. Contributing to an unknown
396
+ `slot:<id>` renders nowhere — declare it in `requires.slots` to get a warning
397
+ instead. Items sort by `order` (lower first); each is crash-isolated; the
398
+ stamp site carries `data-oc-slot="<id>"` for the DOM stratum. Renaming/moving
399
+ a slot id is a contract bump + migration note.
400
+
181
401
  ### Hook catalog
182
402
 
183
403
  Open event strings — fired from the api client wrapper (every endpoint),
@@ -246,6 +466,7 @@ extensions break silently on every redesign.
246
466
  | `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
247
467
  | `data-oc-subagent-strip` | SubagentStrip |
248
468
  | `data-oc-runs-panel` | RunsPanel |
469
+ | `data-oc-slot` | Slot wrapper (`slot:<id>` — one per known slot id) |
249
470
 
250
471
  ```ts
251
472
  // dom.ts — badge next to the send button, cleaned up on hot-swap
@@ -364,9 +585,10 @@ One folder becomes pixels through four files — follow them in order:
364
585
  react external, build logs printed loudly, never silent).
365
586
  4. **Import + register (page).** `src/lib/runtimeExtensions.ts` fetches
366
587
  the manifest, dynamic-imports each new `?v=` bundle (re-import on
367
- mtime move → registry same-id-swap → live repaint), mounts `domUrl`
368
- via the DOM kit, and unregisters ids that vanish or flip
369
- `disabled: true`. Shipped browser bundles are skipped here (the glob
588
+ mtime move → dispose the old instance → registry same-id-swap → live
589
+ repaint), runs the module's `activate(ctx)` entry when present, mounts
590
+ `domUrl` via the DOM kit, and disposes + unregisters ids that vanish or
591
+ flip `disabled: true`. Shipped browser bundles are skipped here (the glob
370
592
  owns them — importing twice would run side effects twice) but shipped
371
593
  `domUrl` still mounts and `disabled` still pauses them.
372
594
 
@@ -374,8 +596,15 @@ One folder becomes pixels through four files — follow them in order:
374
596
 
375
597
  Everything the app can — shipped extensions are the same build:
376
598
 
377
- - `useStore` / store actions from `src/store.ts`
599
+ - the curated store facade (`store` / `ctx.store`), or `useStore` directly in
600
+ shipped components
601
+ - declared settings (`ctx.settings` / `settings.forExt(id)`) — core renders and
602
+ persists the manifest schema
603
+ - other extensions' contributions and the peer bus
604
+ (`ctx.collections` / `ctx.bus`)
378
605
  - `api` from `src/api/client.ts` (every endpoint fires `api.pre/post/error`)
606
+ - the event bus (`ctx.on` / `events.subscribe`) — raw engine events + derived
607
+ lifecycle events, frame-batched
379
608
  - `getService` / services from `src/extensions/registry.tsx`
380
609
  - UI primitives from `src/components/ui/` (shadcn) — always build on these
381
610
  so extensions look native
@@ -383,8 +612,9 @@ Everything the app can — shipped extensions are the same build:
383
612
  - Toaster via the extension API surface (`notify`)
384
613
 
385
614
  External (user/project-dir) extensions use the one extension API surface
386
- (`register`, `react`, `api`, `store`, `prefs`, `notify`, `services`, `dom`
387
- kit, `kv`) used identically by our shipped ones.
615
+ (`register`, `react`, `api`, `store` facade, `events`, `settings`,
616
+ `collections`, `bus`, `prefs`, `notify`, `services`, `dom` kit, `kv`,
617
+ `advanced.*` raw modules) — used identically by our shipped ones.
388
618
 
389
619
  ## Hot reload guarantees
390
620