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.
- package/README.md +21 -8
- package/dist/assets/brother-agent-C1lqxeTh.js +1 -0
- package/dist/assets/groq-voice-m-OaABoC.js +1 -0
- package/dist/assets/index-Bs1cKqOa.js +129 -0
- package/dist/assets/index-D0Y4JI3u.css +1 -0
- package/dist/assets/jsx-runtime-B-hcVAMW.js +1 -0
- package/dist/assets/report-CczF_91u.js +2 -0
- package/dist/assets/rich-render-C-7ieXZz.js +1 -0
- package/dist/index.html +17 -2
- package/package.json +3 -3
- package/server/ext/engine.ts +161 -0
- package/server/ext/kv.ts +81 -0
- package/server/ext/registry.ts +279 -0
- package/server/ext/types.ts +124 -0
- package/server/index.ts +433 -58
- package/server/userExtensions.ts +175 -48
- package/skills/webui/SKILL.md +118 -97
- package/webui-extensions/README.md +462 -0
- package/dist/assets/index-B7R1vNdF.css +0 -1
- package/dist/assets/index-BV-oA2S9.js +0 -128
- package/dist/assets/report-nsiIAZ9q.js +0 -2
- package/ui-extensions/README.md +0 -285
package/server/userExtensions.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Extension folder discovery — ONE loader, THREE sources (spec §4, §6, §11.3).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 = {
|
|
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
|
|
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
|
-
*
|
|
54
|
-
* skipped silently
|
|
55
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
}
|
package/skills/webui/SKILL.md
CHANGED
|
@@ -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
|
|
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/
|
|
28
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
29
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
30
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
31
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
32
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
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 (`
|
|
69
|
+
verbatim from the authoring guide (`webui-extensions/README.md`), which is the
|
|
52
70
|
source of truth.
|
|
53
|
-
- **Author
|
|
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
|
-
|
|
76
|
+
## The model in one minute
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
79
|
-
|
|
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: "
|
|
83
|
-
id: "
|
|
84
|
-
|
|
85
|
-
render: () =>
|
|
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
|
-
##
|
|
114
|
+
## Five kinds, one job each (the contract)
|
|
90
115
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
(
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
|
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
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `composer
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|