opencode-webui 1.0.8 → 2.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.
@@ -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
  }
@@ -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.8 (matches the `v1.0.8` git tag —
14
+ - **This skill's version**: 2.0.0 (matches the `v2.0.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.8/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.8/src/extensions/registry.tsx | The slot registry — exact register() shapes per kind |
29
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.8/src/components/Composer.tsx | Where slash entries / prompt hooks / composer regions live |
30
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.8/src/components/MessageItem.tsx | Where message/message.decoration/message.part render |
31
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.8/src/lib/composerHandoff.ts | Type-anywhere composer behavior (if your extension competes for keys) |
32
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v1.0.8/src/store.ts | The storeactions useStore exposes to extensions |
27
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.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.0.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/src/extensions/hooks.ts | Shared fireHooks runner how open hook events fire |
30
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.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.0.0/server/ext/types.ts | Proxy-stratum types server.ts routes/middleware/onEvent/pollers shapes |
32
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/docs/extension-system-spec.md | The v2 decision record strata, precedence, deletions, acceptance checks |
33
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/src/store.ts | The store — actions useStore exposes to extensions |
33
34
 
34
35
  ## Environment
35
36
 
@@ -38,9 +39,22 @@ 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). |
41
43
  | `WEBUI_DEBUG` | unset | `1` — server/proxy debug logs to stdout. |
42
44
  | `WEBUI_DEBUG_LOG` | `/tmp/webui-debug.log` | File the frontend log sink (`POST /api/debug`) appends to. |
43
45
 
46
+ ### Parallel sandboxes (one per extension under test)
47
+
48
+ One command, no flags: `bun run sandbox` detects a running sandbox and
49
+ auto-isolates. Alone it uses the fixed defaults (`:4099`/`:5175` + shared
50
+ scratch dir); when another sandbox already holds those ports, the new
51
+ instance takes free ports + a fresh mkdtemp extension dir and prints what
52
+ it picked. Explicit env (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` /
53
+ `WEBUI_EXTENSION_DIR`) always wins per knob. The engine stays shared
54
+ (same sessions everywhere, by design) — only ports + extension dirs are
55
+ isolated. Rules: one sandbox per extension, never two writers to one ext
56
+ dir, never reuse a port.
57
+
44
58
  ## What you can do for the user
45
59
 
46
60
  - **File a webui bug** — the composer ships a built-in `/report` command that
@@ -48,117 +62,120 @@ fetch the exact file at the pinned tag instead of reading a local clone:
48
62
  ring) into a prefilled GitHub issue for AbdelftahZowail/opencode-webui (see
49
63
  Reporting bugs below).
50
64
  - **Explain the extension system** from the tables below — they are extracted
51
- verbatim from the authoring guide (`ui-extensions/README.md`), which is the
65
+ verbatim from the authoring guide (`webui-extensions/README.md`), which is the
52
66
  source of truth.
53
- - **Author a user-dir extension** for the user — a folder, no build step, no
54
- restart.
67
+ - **Author an extension folder** for the user — one folder, no build step, no
68
+ restart. Pick the stratum that matches the job (React tree → browser,
69
+ portals/canvas/post-render → dom.ts, headless/always-on → server.ts,
70
+ model tools → engine/).
55
71
 
56
- ### Dev extensions vs user extensions (two homes, one contract)
72
+ ## The model in one minute
57
73
 
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`) |
74
+ One extension = **one folder**: `manifest.json` (id, version, description,
75
+ `disabled`?) + `index.tsx` (browser stratum) + `dom.ts` (DOM stratum) +
76
+ `server.ts` (proxy stratum) + `engine/` (opencode plugin payload).
77
+ Presence = installed; `disabled: true` = paused; delete the folder =
78
+ uninstalled. Precedence, highest wins: `~/.config/opencode/webui-extensions/`
79
+ (user) `<project>/.opencode/webui-extensions/` (project) shipped
80
+ `webui-extensions/` (ours). Dropping a folder in the extension dir is an
81
+ act of trust — extension code is not sandboxed.
64
82
 
65
- Authoring rules (kinds, hook events) are identical in both homes — the tables
66
- below apply to each.
83
+ ### Add / pause / remove
84
+
85
+ | Action | How |
86
+ | --- | --- |
87
+ | Add | Create `webui-extensions/<name>/` with `manifest.json` + `index.tsx` calling `register({ kind, ... })`. Loads without rebuild/refresh/restart (manifest SSE push, same-id swap). |
88
+ | Pause | Set `"disabled": true` in its `manifest.json` — gone on the next manifest push. |
89
+ | Remove | Delete/move the folder — the id vanishes from the manifest. |
90
+ | Shadow | Same id at higher precedence wins; core updates still flow everywhere else. |
67
91
 
68
- ### Minimal user-dir extension
92
+ Authoring rules (kinds, hook events) are identical in every source — the
93
+ tables below apply to each. Full hot-reload guarantees and the timestamp
94
+ worked example are in the authoring guide.
69
95
 
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`):
96
+ ### Minimal extension (wrap the default path for edits)
76
97
 
77
98
  ```tsx
78
- // ~/.config/opencode/webui-extensions/hello/main.tsx
79
- const { register, react } = window.__opencodeUI;
99
+ // ~/.config/opencode/webui-extensions/my-time/{manifest.json,index.tsx}
100
+ import { register } from "opencode-webui/extensions"; // shipped dirs import from src; external dirs use the extension API surface
80
101
 
81
102
  register({
82
- kind: "region",
83
- id: "hello",
84
- region: "footer",
85
- render: () => react.createElement("span", null, "hello from a user extension"),
103
+ kind: "wrap",
104
+ id: "my-time",
105
+ target: "Timestamp",
106
+ render: (props, next) => <span className="tabular-nums">{next()}</span>,
86
107
  });
87
108
  ```
88
109
 
89
- ## Sandbox (iterate without touching the user's webui)
110
+ ## Five kinds, one job each (the contract)
90
111
 
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.
112
+ | Kind | Job | Staleness |
113
+ | --- | --- | --- |
114
+ | `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. |
115
+ | `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. |
116
+ | `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. |
117
+ | `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
118
+ | `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
119
 
105
- ## Extension kinds (the contract)
120
+ Contribute collections (registry-owned lists — data, not new kinds):
121
+ `palette`, `slash` (UI-only; engine commands win name clashes),
122
+ `pages` (routed at `/ext/{id}`), `settings`,
123
+ `contextMenu.message` / `contextMenu.session` / `contextMenu.file`.
106
124
 
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
125
+ ### Hook catalog
122
126
 
123
127
  | Event | `ctx` shape | When |
124
128
  | --- | --- | --- |
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 |
129
+ | `api.pre` | `{ name, args }` — MUTATE `ctx.args` (unknown[] spread into the endpoint) | Before every api client call; `await`ed so mutations apply |
130
+ | `api.post` | `{ name, args, result }` — observe | After every successful api call |
131
+ | `api.error` | `{ name, args, error }` — observe; the original error is rethrown | After every failed api call |
132
+ | `store.dispatch` | `{ patch, state }` — observe | Store middleware, every action (sync site — `void fireHooks`) |
133
+ | `session.prompt` | `{ text, sessionID }` — mutate `ctx.text` to transform; `await`ed | Composer submit before `POST /api/session/{id}/prompt` |
134
+ | `session.adopted` | `{ sessionID }` | Session becomes the focused session |
135
+ | `pane.focused` | `{ paneID, sessionID }` | Split-view focus moves to a pane |
136
+ | `extension.loaded` | `{ id, url }` | A browser extension bundle finished loading |
137
+ | `message.render` | `{ message, sessionID? }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before the message renderer |
138
+
139
+ Browser hooks affect only their own browser — transforms affecting *all*
140
+ clients go in proxy `server.ts` middleware. Proxy mounts (`server/ext/`):
141
+ `routes` at `/api/webui/ext/<id>/…`, `middleware` over `/api/*`,
142
+ `onEvent` tap (works with all tabs closed), `pollers`, per-extension KV.
143
+
144
+ ## DOM anchors (the free layer)
145
+
146
+ `dom.ts` + the kit (`foreign`/`watch`/`styles`, mount/dispose) covers
147
+ what the React tree cannot: mid-component DOM, portals, canvas/xterm,
148
+ iframes. Stable anchors below are contract — renaming one is a version bump
149
+ + migration note. DOM is the marked last resort ("outside the contract; you
150
+ own the fragility").
151
+
152
+ | Anchor | Site |
132
153
  | --- | --- |
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)
154
+ | `data-oc-transcript` | MessageScroller content |
155
+ | `data-oc-message` + `data-oc-message-id` + `data-oc-message-type` | MessageItem root per type branch |
156
+ | `data-oc-composer` + `data-oc-composer-input` + `data-oc-composer-send` | Composer card, textarea, send button |
157
+ | `data-oc-tool-card` + `data-oc-tool-name` | ToolCard root + tool call name |
158
+ | `data-oc-session-header` | Conversation header bar |
159
+ | `data-oc-sidebar` | Sidebar root |
160
+ | `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
161
+ | `data-oc-subagent-strip` | SubagentStrip |
162
+ | `data-oc-runs-panel` | RunsPanel |
152
163
 
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. |
164
+ ## Sandbox (iterate without touching the user's webui)
158
165
 
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.)
166
+ A second, private instance for authoring/testing extensions no repo
167
+ needed. Start it the same way the package runs, plus `sandbox`:
168
+ `bunx opencode-webui sandbox` (or `./opencode-webui-<target> sandbox`).
169
+ It binds `127.0.0.1:4099` — loopback-only, NO password (a non-loopback
170
+ sandbox is refused) — and loads extensions from an ISOLATED scratch dir
171
+ (`~/.local/state/opencode-webui/sandbox-extensions/<name>/`), so WIP
172
+ is invisible to the user's main instance. Same engine, same sessions as the
173
+ main instance. Workflow: write the extension folder in the scratch dir →
174
+ watch it load in the sandbox via the manifest push (sub-second) → fix until
175
+ right → then copy the folder into `~/.config/opencode/webui-extensions/<name>/`
176
+ to ship it to the user (or `<project>/.opencode/webui-extensions/<name>/`
177
+ for one project). Do not write user extensions directly into the real dirs
178
+ while iterating — that exposes WIP to the user's browser immediately.
162
179
 
163
180
  ## Reporting bugs
164
181