opencode-webui 1.0.9 → 2.1.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.
@@ -1,25 +1,47 @@
1
1
  /**
2
- * Phase CUSER extension dirs.
2
+ * Extension folder discovery ONE loader, THREE sources (spec §4, §6, §11.3).
3
3
  *
4
- * Plugins ship browser halves discovered through the engine's /api/plugin
5
- * list (see index.ts). This module adds the user's own drop-in dirs, scanned
6
- * straight from disk with NO engine involvement:
4
+ * Sources, highest precedence first (same id = same swap point, higher wins):
5
+ * 1. user: ~/.config/opencode/webui-extensions/<name>/ (global)
6
+ * 2. project: <cwd>/.opencode/webui-extensions/<name>/ (per project)
7
+ * 3. shipped: <app>/webui-extensions/<name>/ (ours, updates with the app)
7
8
  *
8
- * ~/.config/opencode/webui-extensions/<name>/main.tsx (global)
9
- * <cwd>/.opencode/webui-extensions/<name>/main.tsx (per project)
9
+ * One extension = one folder. Gating is owned by the folder itself:
10
+ * presence = installed; manifest.json `disabled: true` = paused; delete/move
11
+ * the folder to uninstall. No config.ts list, no per-browser localStorage.
10
12
  *
11
- * They ride the exact same pipeline as plugin UIs — mtime cache-busting and
12
- * Bun.build bundling via bundleUIEntry() in index.ts — so a user extension
13
- * is just `{ id, url, source: "user:<path>" }` appended to the existing
14
- * GET /api/webui/extensions manifest. Discovery is cached 5s, mirroring the
15
- * plugin discovery pattern.
13
+ * Folder anatomy (new format; legacy `main.tsx`-only folders still load):
14
+ * manifest.json { id?, name?, version?, description?, disabled? }
15
+ * index.tsx browser stratum entry (preferred)
16
+ * main.tsx legacy browser entry (fallback)
17
+ * dom.ts DOM stratum entry (spec §7 — post-render DOM changes)
18
+ *
19
+ * They ride the same pipeline as plugin UIs — mtime cache-busting and
20
+ * Bun.build bundling via bundleUIEntry() in index.ts — so an extension is
21
+ * just `{ id, url?, domUrl?, source }` (or `{ id, disabled: true, source }`
22
+ * when paused) in the GET /api/webui/extensions manifest. A dom-only folder
23
+ * (no index/main entry) still lists with only `domUrl`.
16
24
  */
17
25
 
18
- import { existsSync, readdirSync, statSync } from "node:fs";
26
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
27
  import { homedir } from "node:os";
20
- import { join } from "node:path";
28
+ import { dirname, join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
21
30
 
22
- export type UIEntry = { id: string; entry: string; mtimeMs: number; source?: string };
31
+ export type UIEntry = {
32
+ id: string;
33
+ /** Browser-stratum bundle entry file; "" when none exists (or disabled). */
34
+ entry: string;
35
+ mtimeMs: number;
36
+ /** DOM-stratum bundle entry (`dom.ts`); absent when the folder has none. */
37
+ domEntry?: string;
38
+ domMtimeMs?: number;
39
+ source?: string;
40
+ /** manifest.json `disabled: true` — paused, never bundled or imported. */
41
+ disabled?: boolean;
42
+ /** Which of the three sources won for this id. */
43
+ origin?: "user" | "project" | "shipped";
44
+ };
23
45
 
24
46
  export const USER_EXT_LIST_TTL_MS = 5_000;
25
47
 
@@ -38,9 +60,26 @@ export function projectUserExtensionsDir(): string | null {
38
60
  return join(process.cwd(), ".opencode", "webui-extensions");
39
61
  }
40
62
 
63
+ /** Shipped extensions live next to the app and update with it. Resolved from
64
+ * this module's location (not cwd — the proxy may be launched from any
65
+ * project directory; only the PROJECT source is cwd-relative). */
66
+ export function shippedExtensionsDir(): string {
67
+ try {
68
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "webui-extensions");
69
+ } catch {
70
+ return join(process.cwd(), "webui-extensions");
71
+ }
72
+ }
73
+
74
+ /** Every source root the proxy watches (existing or not — watchers attach lazily). */
75
+ export function extensionSourceRoots(): string[] {
76
+ const roots = [globalUserExtensionsDir(), projectUserExtensionsDir(), shippedExtensionsDir()];
77
+ return roots.filter((r): r is string => !!r);
78
+ }
79
+
41
80
  const warned = new Set<string>();
42
81
 
43
- /** Console.warn at most once per key per process — discovery re-runs every 5s. */
82
+ /** Console.warn at most once per key per process — discovery re-runs often. */
44
83
  export function warnOnce(key: string, message: string): void {
45
84
  if (warned.has(key)) return;
46
85
  warned.add(key);
@@ -49,10 +88,124 @@ export function warnOnce(key: string, message: string): void {
49
88
 
50
89
  let cache: { at: number; entries: UIEntry[] } | null = null;
51
90
 
91
+ /** Drop the discovery cache so the next read re-scans disk (watcher path). */
92
+ export function invalidateExtensionCache(): void {
93
+ cache = null;
94
+ }
95
+
96
+ function readManifest(dir: string): { id?: unknown; disabled?: unknown } | null {
97
+ try {
98
+ const raw = readFileSync(join(dir, "manifest.json"), "utf8");
99
+ const parsed: unknown = JSON.parse(raw);
100
+ if (parsed && typeof parsed === "object") return parsed as { id?: unknown; disabled?: unknown };
101
+ return null;
102
+ } catch {
103
+ return null; // absent or unreadable — legacy folder, id falls back to dir name
104
+ }
105
+ }
106
+
107
+ /** DOM-stratum entry: exactly `dom.ts` — one method per concern. */
108
+ function folderDomEntry(dir: string): string | null {
109
+ const candidate = join(dir, "dom.ts");
110
+ try {
111
+ if (existsSync(candidate)) return candidate;
112
+ } catch {
113
+ /* unreadable — no DOM stratum */
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function mtimeOf(path: string): number | null {
119
+ try {
120
+ return statSync(path).mtimeMs;
121
+ } catch {
122
+ return null; // vanished mid-scan
123
+ }
124
+ }
125
+ /** Preferred-first browser entry candidates for one extension folder. */
126
+ function folderEntry(dir: string): string | null {
127
+ for (const name of ["index.tsx", "index.ts", "main.tsx", "main.ts"]) {
128
+ const candidate = join(dir, name);
129
+ try {
130
+ if (existsSync(candidate)) return candidate;
131
+ } catch {
132
+ /* unreadable — try the next */
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function scanRoot(root: string, origin: UIEntry["origin"], entries: UIEntry[], seen: Set<string>): void {
139
+ let names: string[];
140
+ try {
141
+ names = readdirSync(root, { withFileTypes: true })
142
+ .filter((d) => d.isDirectory())
143
+ .map((d) => d.name);
144
+ } catch {
145
+ return; // absent root is the normal case
146
+ }
147
+ for (const name of names.sort()) {
148
+ if (seen.has(name)) {
149
+ warnOnce(
150
+ `ext-dup:${name}`,
151
+ `extension "${name}" shadowed — keeping the higher-precedence copy`,
152
+ );
153
+ continue;
154
+ }
155
+ const dir = join(root, name);
156
+ let manifest: { id?: unknown; disabled?: unknown } | null = null;
157
+ try {
158
+ manifest = readManifest(dir);
159
+ } catch {
160
+ /* unreadable entry — skip */
161
+ continue;
162
+ }
163
+ const id = typeof manifest?.id === "string" && manifest.id.length > 0 ? manifest.id : name;
164
+ if (seen.has(id)) {
165
+ warnOnce(`ext-dup:${id}`, `extension "${id}" shadowed — keeping the higher-precedence copy`);
166
+ continue;
167
+ }
168
+ const disabled = manifest?.disabled === true;
169
+ const entry = disabled ? null : folderEntry(dir);
170
+ const dom = disabled ? null : folderDomEntry(dir);
171
+ // A paused extension needs no entry file; an enabled one without any
172
+ // entry file is skipped silently (mid-write folder, or server-only —
173
+ // note a dom-only folder DOES list, with only `domEntry` set).
174
+ if (!disabled && !entry && !dom) continue;
175
+ let mtimeMs = 0;
176
+ if (entry) {
177
+ const mtime = mtimeOf(entry);
178
+ if (mtime === null) continue; // vanished mid-scan
179
+ mtimeMs = mtime;
180
+ }
181
+ let domEntry: string | undefined;
182
+ let domMtimeMs: number | undefined;
183
+ if (dom) {
184
+ const mtime = mtimeOf(dom);
185
+ if (mtime !== null) {
186
+ domEntry = dom;
187
+ domMtimeMs = mtime;
188
+ }
189
+ }
190
+ entries.push({
191
+ id,
192
+ entry: entry ?? "",
193
+ mtimeMs,
194
+ domEntry,
195
+ domMtimeMs,
196
+ source: `webui-extensions:${dir}`,
197
+ disabled: disabled || undefined,
198
+ origin,
199
+ });
200
+ seen.add(name);
201
+ seen.add(id);
202
+ }
203
+ }
204
+
52
205
  /**
53
- * User UI entries, global root first. Absent roots and unreadable entries are
54
- * skipped silently (that is the normal case); duplicate ids across roots warn
55
- * once and keep the first.
206
+ * All three sources merged, user root first. Absent roots and unreadable
207
+ * entries are skipped silently; duplicate ids across roots warn once and
208
+ * keep the higher-precedence copy (user > project > shipped).
56
209
  */
57
210
  export function discoverUserUIEntries(): UIEntry[] {
58
211
  const now = Date.now();
@@ -60,36 +213,10 @@ export function discoverUserUIEntries(): UIEntry[] {
60
213
 
61
214
  const entries: UIEntry[] = [];
62
215
  const seen = new Set<string>();
63
- for (const root of [globalUserExtensionsDir(), projectUserExtensionsDir()]) {
64
- if (!root) continue;
65
- let names: string[];
66
- try {
67
- names = readdirSync(root, { withFileTypes: true })
68
- .filter((d) => d.isDirectory())
69
- .map((d) => d.name);
70
- } catch {
71
- continue; // absent root is the normal case
72
- }
73
- for (const name of names) {
74
- if (seen.has(name)) {
75
- warnOnce(`user-dup:${name}`, `user extension "${name}" exists in two roots — keeping the first`);
76
- continue;
77
- }
78
- const entry = join(root, name, "main.tsx");
79
- try {
80
- if (!existsSync(entry)) continue;
81
- entries.push({
82
- id: name,
83
- entry,
84
- mtimeMs: statSync(entry).mtimeMs,
85
- source: `user:${entry}`,
86
- });
87
- seen.add(name);
88
- } catch {
89
- /* unreadable entry — skip */
90
- }
91
- }
92
- }
216
+ scanRoot(globalUserExtensionsDir(), "user", entries, seen);
217
+ const projectRoot = projectUserExtensionsDir();
218
+ if (projectRoot) scanRoot(projectRoot, "project", entries, seen);
219
+ scanRoot(shippedExtensionsDir(), "shipped", entries, seen);
93
220
  cache = { at: now, entries };
94
221
  return entries;
95
222
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: webui
3
- description: opencode-webui — the browser frontend for the OpenCode engine. Load when the user mentions the webui/web frontend, asks about webui extensions, or wants to report a webui bug (/report does it).
3
+ description: opencode-webui — the browser frontend for the OpenCode engine. Load when the user mentions the webui/web frontend, asks about webui extensions, wants to BUILD, ADD, CHANGE, DEBUG, or TEST a webui extension, or wants to report a webui bug (/report does it).
4
4
  ---
5
5
 
6
6
  # OpenCode webui (opencode-webui)
@@ -11,7 +11,7 @@ service credentials. One port for UI + `/api/*`: http://localhost:4097
11
11
  (`WEBUI_PROXY_PORT`).
12
12
 
13
13
  - **Repo**: https://github.com/AbdelftahZowail/opencode-webui
14
- - **This skill's version**: 1.0.9 (matches the `v1.0.9` git tag —
14
+ - **This skill's version**: 2.1.0 (matches the `v2.1.0` git tag —
15
15
  the file links below are pinned to it, so they always describe the code
16
16
  this skill was generated with)
17
17
  - **A running instance exposes its version** at `GET /api/webui/config` →
@@ -24,12 +24,13 @@ fetch the exact file at the pinned tag instead of reading a local clone:
24
24
 
25
25
  | File | Purpose |
26
26
  | --- | --- |
27
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/ui-extensions/README.md | Full authoring guide — the source of truth for kinds/hooks/regions |
28
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/src/extensions/registry.tsx | The slot registry — exact register() shapes per kind |
29
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/src/components/Composer.tsx | Where slash entries / prompt hooks / composer regions live |
30
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/src/components/MessageItem.tsx | Where message/message.decoration/message.part render |
31
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/src/lib/composerHandoff.ts | Type-anywhere composer behavior (if your extension competes for keys) |
32
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.9/src/store.ts | The storeactions useStore exposes to extensions |
27
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/webui-extensions/README.md | Full authoring guide — the source of truth for strata/kinds/hooks/anchors |
28
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/extensions/hooks.ts | Shared fireHooks runner how open hook events fire |
30
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
31
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/server/ext/types.ts | Proxy-stratum types server.ts routes/middleware/onEvent/pollers shapes |
32
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/docs/extension-system-spec.md | The v2 decision record strata, precedence, deletions, acceptance checks |
33
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/store.ts | The store — actions useStore exposes to extensions |
33
34
 
34
35
  ## Environment
35
36
 
@@ -38,9 +39,26 @@ fetch the exact file at the pinned tag instead of reading a local clone:
38
39
  | `WEBUI_PASSWORD` | generated on first boot, printed once | Shared login passphrase. |
39
40
  | `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
40
41
  | `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
42
+ | `WEBUI_EXTENSION_DIR` | the global + project dirs | Replace both with ONE directory (the sandbox does this to keep WIP isolated). |
43
+ | `WEBUI_ENGINE_URL` | `service.json` discovery | Aim the proxy at a chosen engine — skips `Service.ensure()`, so a stale pid can never spawn a rogue serve. Explicit env wins. |
44
+ | `WEBUI_ENGINE_PASSWORD` | `service.json` password | Engine password for the override above (no file fallback when the URL is overridden — a chosen engine has its own credential). |
45
+ | `WEBUI_SANDBOX_NOVITE` | unset | `1` — sandbox boots the proxy only, no Vite (`--no-vite` flag does the same). |
46
+ | `WEBUI_CRASH_LOG` | `$XDG_STATE_HOME/opencode-webui/proxy-crash.log` | File fatal proxy errors (`uncaughtException`/`unhandledRejection`) are appended to; the last entry prints on next boot. |
41
47
  | `WEBUI_DEBUG` | unset | `1` — server/proxy debug logs to stdout. |
42
48
  | `WEBUI_DEBUG_LOG` | `/tmp/webui-debug.log` | File the frontend log sink (`POST /api/debug`) appends to. |
43
49
 
50
+ ### Parallel sandboxes (one per extension under test)
51
+
52
+ Yes — run as many sandboxes at once as you need. `bun run sandbox` stacks
53
+ with no flags: the first instance takes the fixed defaults (`:4099`/`:5175`
54
+ + shared scratch dir); every further instance detects the busy ports and
55
+ auto-isolates onto free ports + a fresh mkdtemp extension dir, printing what
56
+ it picked. Explicit env (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` /
57
+ `WEBUI_EXTENSION_DIR`) always wins per knob. The engine stays shared
58
+ (same sessions everywhere, by design) — only ports + extension dirs are
59
+ isolated. Rules: one sandbox per extension, never two writers to one ext
60
+ dir, never reuse a port.
61
+
44
62
  ## What you can do for the user
45
63
 
46
64
  - **File a webui bug** — the composer ships a built-in `/report` command that
@@ -48,117 +66,120 @@ fetch the exact file at the pinned tag instead of reading a local clone:
48
66
  ring) into a prefilled GitHub issue for AbdelftahZowail/opencode-webui (see
49
67
  Reporting bugs below).
50
68
  - **Explain the extension system** from the tables below — they are extracted
51
- verbatim from the authoring guide (`ui-extensions/README.md`), which is the
69
+ verbatim from the authoring guide (`webui-extensions/README.md`), which is the
52
70
  source of truth.
53
- - **Author a user-dir extension** for the user — a folder, no build step, no
54
- restart.
71
+ - **Author an extension folder** for the user — one folder, no build step, no
72
+ restart. Pick the stratum that matches the job (React tree → browser,
73
+ portals/canvas/post-render → dom.ts, headless/always-on → server.ts,
74
+ model tools → engine/).
55
75
 
56
- ### Dev extensions vs user extensions (two homes, one contract)
76
+ ## The model in one minute
57
77
 
58
- | | Dev (app repo) | User (any machine) |
59
- | --- | --- | --- |
60
- | Where | `ui-extensions/<name>/` in the webui checkout | `~/.config/opencode/webui-extensions/<name>/main.tsx` (per-user) or `<project>/.opencode/webui-extensions/<name>/main.tsx` (per-project) |
61
- | How it loads | Bundled into the app build, listed in `ui-extensions/index.ts` | The proxy bundles it on the fly; page picks it up within ~8s |
62
- | On/off | `enabled` list in `ui-extensions/config.ts` | Settings › Extensions toggle |
63
- | API surface | Full app internals (imports, `useStore`, `api`) | ONLY the `window.__opencodeUI` bridge (`register`, `react`, `useStore`, `api`, `notify`, `getHooks`, `version`) |
78
+ One extension = **one folder**: `manifest.json` (id, version, description,
79
+ `disabled`?) + `index.tsx` (browser stratum) + `dom.ts` (DOM stratum) +
80
+ `server.ts` (proxy stratum) + `engine/` (opencode plugin payload).
81
+ Presence = installed; `disabled: true` = paused; delete the folder =
82
+ uninstalled. Precedence, highest wins: `~/.config/opencode/webui-extensions/`
83
+ (user) `<project>/.opencode/webui-extensions/` (project) shipped
84
+ `webui-extensions/` (ours). Dropping a folder in the extension dir is an
85
+ act of trust — extension code is not sandboxed.
64
86
 
65
- Authoring rules (kinds, hook events) are identical in both homes — the tables
66
- below apply to each.
87
+ ### Add / pause / remove
88
+
89
+ | Action | How |
90
+ | --- | --- |
91
+ | Add | Create `webui-extensions/<name>/` with `manifest.json` + `index.tsx` calling `register({ kind, ... })`. Loads without rebuild/refresh/restart (manifest SSE push, same-id swap). |
92
+ | Pause | Set `"disabled": true` in its `manifest.json` — gone on the next manifest push. |
93
+ | Remove | Delete/move the folder — the id vanishes from the manifest. |
94
+ | Shadow | Same id at higher precedence wins; core updates still flow everywhere else. |
67
95
 
68
- ### Minimal user-dir extension
96
+ Authoring rules (kinds, hook events) are identical in every source — the
97
+ tables below apply to each. Full hot-reload guarantees and the timestamp
98
+ worked example are in the authoring guide.
69
99
 
70
- Drop a folder`~/.config/opencode/webui-extensions/<name>/main.tsx`
71
- (per-user) or `<project>/.opencode/webui-extensions/<name>/main.tsx`
72
- (per-project). The proxy bundles it and the page loads it within a poll cycle
73
- `~8s` — see the dev-vs-user split above). Runtime extensions reach
74
- the app ONLY through the `window.__opencodeUI` bridge (`register`,
75
- `react`, `useStore`, `api`, `notify`, `getHooks`, `version`):
100
+ ### Minimal extension (wrap the default path for edits)
76
101
 
77
102
  ```tsx
78
- // ~/.config/opencode/webui-extensions/hello/main.tsx
79
- const { register, react } = window.__opencodeUI;
103
+ // ~/.config/opencode/webui-extensions/my-time/{manifest.json,index.tsx}
104
+ import { register } from "opencode-webui/extensions"; // shipped dirs import from src; external dirs use the extension API surface
80
105
 
81
106
  register({
82
- kind: "region",
83
- id: "hello",
84
- region: "footer",
85
- render: () => react.createElement("span", null, "hello from a user extension"),
107
+ kind: "wrap",
108
+ id: "my-time",
109
+ target: "Timestamp",
110
+ render: (props, next) => <span className="tabular-nums">{next()}</span>,
86
111
  });
87
112
  ```
88
113
 
89
- ## Sandbox (iterate without touching the user's webui)
114
+ ## Five kinds, one job each (the contract)
90
115
 
91
- A second, private instance for authoring/testing user extensions — no repo
92
- needed. Start it the same way the package runs, plus `sandbox`:
93
- `bunx opencode-webui sandbox` (or `./opencode-webui-<target> sandbox`).
94
- It binds `127.0.0.1:4099` — loopback-only, NO password (a non-loopback
95
- sandbox is refused) — and loads extensions from an ISOLATED scratch dir
96
- (`~/.local/state/opencode-webui/sandbox-extensions/<name>/main.tsx`), so WIP
97
- is invisible to the user's main instance. Same engine, same sessions as the
98
- main instance. Workflow: write the extension in the scratch dir → watch it
99
- load in the sandbox within its poll cycle (~8s) → fix until right → then copy
100
- the folder into `~/.config/opencode/webui-extensions/<name>/` to ship it to
101
- the user (or `<project>/.opencode/webui-extensions/<name>/` for one project).
102
- Do not write user extensions directly into the real dirs while iterating —
103
- that exposes WIP to the user's browser immediately.
116
+ | Kind | Job | Staleness |
117
+ | --- | --- | --- |
118
+ | `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. |
119
+ | `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. |
120
+ | `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. |
121
+ | `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
122
+ | `service` | Provide named logic: `{ service, value, precedence }` — consume via `getService(id)`; highest precedence wins | **Value overrides.** Core consults services for pluggable values (e.g. the timestamp formatter), so tiny logic tweaks never touch markup. |
104
123
 
105
- ## Extension kinds (the contract)
124
+ Contribute collections (registry-owned lists — data, not new kinds):
125
+ `palette`, `slash` (UI-only; engine commands win name clashes),
126
+ `pages` (routed at `/ext/{id}`), `settings`,
127
+ `contextMenu.message` / `contextMenu.session` / `contextMenu.file`.
106
128
 
107
- | Kind | What it does | Where it surfaces |
108
- | --- | --- | --- |
109
- | `region` | render into any `<Slot region="…">` marker placed by core (see generated table below) | wherever core placed a `<Slot>` |
110
- | `command` | entry in the palette's "Extension commands" group (`run({ sessionID })`; `keybind` like `ctrl+shift+k` for global hotkey) | command palette (⌘/ctrl-K) + keybind |
111
- | `slash` | **UI-only** slash entry for Composer `/name` (local `run(args,{sessionID})`; not engine) | Composer autocomplete (`/` menu) |
112
- | `message` | full replacement for any message type (`type:"system"\|"synthetic"\|"shell"\|"compaction"\|"user"\|"assistant"\|…\|"*"`, `render({message, sessionID}) => node\|null`); first non-null wins, else core `renderMessageBody` | `MessageItem` per message |
113
- | `message.decoration` | small extras under message rows; `render({ messageID, message }) => node\|null` | under every message row |
114
- | `message.part` | inject after *each* part (text/tool/reasoning) inside a message; `render({messageID, message, part, partIndex})` | inside `MessageItem` per part |
115
- | `tool.renderer` | custom card for a specific tool name (`toolName:"bash"\|"edit"\|…`, `render(part)`) | `ToolCard` per tool call |
116
- | `contextMenu` | right-click menu item (`target:"message"\|"session"\|"file"`, `label`, `run`, `order`) | context menu |
117
- | `hook` | intercept/behavior (`event:string`, `handler(ctx,next)`) — see Hook events below | store / Composer / MessageItem |
118
- | `page` | full surface at `/ext/{id}` (route derived from the id) | sidebar links + direct URL |
119
- | `settings` | titled section inside Settings › Extensions (`render: () => ReactNode`) | Settings dialog |
120
-
121
- ### Hook events
129
+ ### Hook catalog
122
130
 
123
131
  | Event | `ctx` shape | When |
124
132
  | --- | --- | --- |
125
- | `session.prompt` | `{ text: string, sessionID: string }` — mutate `ctx.text` to transform; call `next()` to continue | Composer `submit` before `POST /api/session/{id}/prompt` |
126
- | `message.render` | `{ message: MessageInfo, sessionID?: string }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before `message` renderer |
127
- | `store.dispatch` | `{ action, ... }` | store middleware observer |
128
-
129
- ## Regions (render points)
130
-
131
- | Region | Render point |
133
+ | `api.pre` | `{ name, args }` — MUTATE `ctx.args` (unknown[] spread into the endpoint) | Before every api client call; `await`ed so mutations apply |
134
+ | `api.post` | `{ name, args, result }` — observe | After every successful api call |
135
+ | `api.error` | `{ name, args, error }` — observe; the original error is rethrown | After every failed api call |
136
+ | `store.dispatch` | `{ patch, state }` — observe | Store middleware, every action (sync site — `void fireHooks`) |
137
+ | `session.prompt` | `{ text, sessionID }` — mutate `ctx.text` to transform; `await`ed | Composer submit before `POST /api/session/{id}/prompt` |
138
+ | `session.adopted` | `{ sessionID }` | Session becomes the focused session |
139
+ | `pane.focused` | `{ paneID, sessionID }` | Split-view focus moves to a pane |
140
+ | `extension.loaded` | `{ id, url }` | A browser extension bundle finished loading |
141
+ | `message.render` | `{ message, sessionID? }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before the message renderer |
142
+
143
+ Browser hooks affect only their own browser — transforms affecting *all*
144
+ clients go in proxy `server.ts` middleware. Proxy mounts (`server/ext/`):
145
+ `routes` at `/api/webui/ext/<id>/…`, `middleware` over `/api/*`,
146
+ `onEvent` tap (works with all tabs closed), `pollers`, per-extension KV.
147
+
148
+ ## DOM anchors (the free layer)
149
+
150
+ `dom.ts` + the kit (`foreign`/`watch`/`styles`, mount/dispose) covers
151
+ what the React tree cannot: mid-component DOM, portals, canvas/xterm,
152
+ iframes. Stable anchors below are contract — renaming one is a version bump
153
+ + migration note. DOM is the marked last resort ("outside the contract; you
154
+ own the fragility").
155
+
156
+ | Anchor | Site |
132
157
  | --- | --- |
133
- | `app.header` | `src/App.tsx:238` |
134
- | `composer.above` | `src/components/Composer.tsx:932` |
135
- | `composer.below` | `src/components/Composer.tsx:1252` |
136
- | `composer.toolbar` | `src/components/Composer.tsx:1240` |
137
- | `footer` | `src/App.tsx:303` |
138
- | `header.session.actions` | `src/components/Conversation.tsx:572` |
139
- | `header.session.before` | `src/components/Conversation.tsx:520` |
140
- | `message.after` | `src/components/MessageItem.tsx:258` |
141
- | `message.before` | `src/components/MessageItem.tsx:239` |
142
- | `sidebar` | `src/components/Sidebar.tsx:682` |
143
- | `sidebar.session.after` | `src/components/Sidebar.tsx:982` |
144
- | `sidebar.session.before` | `src/components/Sidebar.tsx:914` |
145
- | `tool.after` | `src/components/ToolCard.tsx:58` |
146
- | `tool.before` | `src/components/ToolCard.tsx:57` |
147
- | `transcript.above` | `src/components/Conversation.tsx:128` |
148
- | `transcript.below` | `src/components/Conversation.tsx:155` |
149
- | `transcript.empty` | `src/components/Conversation.tsx:434` |
150
-
151
- ## Add / remove / disable (app repo)
158
+ | `data-oc-transcript` | MessageScroller content |
159
+ | `data-oc-message` + `data-oc-message-id` + `data-oc-message-type` | MessageItem root per type branch |
160
+ | `data-oc-composer` + `data-oc-composer-input` + `data-oc-composer-send` | Composer card, textarea, send button |
161
+ | `data-oc-tool-card` + `data-oc-tool-name` | ToolCard root + tool call name |
162
+ | `data-oc-session-header` | Conversation header bar |
163
+ | `data-oc-sidebar` | Sidebar root |
164
+ | `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
165
+ | `data-oc-subagent-strip` | SubagentStrip |
166
+ | `data-oc-runs-panel` | RunsPanel |
152
167
 
153
- | Action | How |
154
- | --- | --- |
155
- | Add | Create `ui-extensions/<name>/`, add one import to `ui-extensions/index.ts`. Appears instantly via HMR. |
156
- | Remove | Delete the import line and the folder. |
157
- | Disable | Remove its id from the `enabled` list in `ui-extensions/config.ts` — one line, applies instantly via HMR, no reload. |
168
+ ## Sandbox (iterate without touching the user's webui)
158
169
 
159
- The `enabled` list in `ui-extensions/config.ts` is the runtime switch: only
160
- ids listed there are rendered, even if the code is bundled. (A settings-panel
161
- UI could drive the same list later — the mechanism is already in place.)
170
+ A second, private instance for authoring/testing extensions no repo
171
+ needed. Start it the same way the package runs, plus `sandbox`:
172
+ `bunx opencode-webui sandbox` (or `./opencode-webui-<target> sandbox`).
173
+ It binds `127.0.0.1:4099` — loopback-only, NO password (a non-loopback
174
+ sandbox is refused) — and loads extensions from an ISOLATED scratch dir
175
+ (`~/.local/state/opencode-webui/sandbox-extensions/<name>/`), so WIP
176
+ is invisible to the user's main instance. Same engine, same sessions as the
177
+ main instance. Workflow: write the extension folder in the scratch dir →
178
+ watch it load in the sandbox via the manifest push (sub-second) → fix until
179
+ right → then copy the folder into `~/.config/opencode/webui-extensions/<name>/`
180
+ to ship it to the user (or `<project>/.opencode/webui-extensions/<name>/`
181
+ for one project). Do not write user extensions directly into the real dirs
182
+ while iterating — that exposes WIP to the user's browser immediately.
162
183
 
163
184
  ## Reporting bugs
164
185