opencode-webui 1.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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/assets/Inter.ttf +0 -0
- package/dist/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/dist/assets/index-C5HRLW8j.js +122 -0
- package/dist/assets/index-DUtdz9a2.css +1 -0
- package/dist/assets/opencode.svg +7 -0
- package/dist/assets/report-R1enHhQU.js +2 -0
- package/dist/assets/runtime-status-CWjwBTFm.js +1 -0
- package/dist/index.html +14 -0
- package/package.json +72 -0
- package/server/auth.ts +524 -0
- package/server/index.ts +626 -0
- package/server/skillSync.ts +49 -0
- package/server/userExtensions.ts +88 -0
- package/skills/webui/SKILL.md +122 -0
- package/ui-extensions/README.md +271 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase C — USER extension dirs.
|
|
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:
|
|
7
|
+
*
|
|
8
|
+
* ~/.config/opencode/webui-extensions/<name>/main.tsx (global)
|
|
9
|
+
* <cwd>/.opencode/webui-extensions/<name>/main.tsx (per project)
|
|
10
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
export type UIEntry = { id: string; entry: string; mtimeMs: number; source?: string };
|
|
23
|
+
|
|
24
|
+
export const USER_EXT_LIST_TTL_MS = 5_000;
|
|
25
|
+
|
|
26
|
+
export function globalUserExtensionsDir(): string {
|
|
27
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
28
|
+
return join(base, "opencode", "webui-extensions");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function projectUserExtensionsDir(): string {
|
|
32
|
+
return join(process.cwd(), ".opencode", "webui-extensions");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const warned = new Set<string>();
|
|
36
|
+
|
|
37
|
+
/** Console.warn at most once per key per process — discovery re-runs every 5s. */
|
|
38
|
+
export function warnOnce(key: string, message: string): void {
|
|
39
|
+
if (warned.has(key)) return;
|
|
40
|
+
warned.add(key);
|
|
41
|
+
console.warn(`[webui] ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let cache: { at: number; entries: UIEntry[] } | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* User UI entries, global root first. Absent roots and unreadable entries are
|
|
48
|
+
* skipped silently (that is the normal case); duplicate ids across roots warn
|
|
49
|
+
* once and keep the first.
|
|
50
|
+
*/
|
|
51
|
+
export function discoverUserUIEntries(): UIEntry[] {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (cache && now - cache.at < USER_EXT_LIST_TTL_MS) return cache.entries;
|
|
54
|
+
|
|
55
|
+
const entries: UIEntry[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const root of [globalUserExtensionsDir(), projectUserExtensionsDir()]) {
|
|
58
|
+
let names: string[];
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(root, { withFileTypes: true })
|
|
61
|
+
.filter((d) => d.isDirectory())
|
|
62
|
+
.map((d) => d.name);
|
|
63
|
+
} catch {
|
|
64
|
+
continue; // absent root is the normal case
|
|
65
|
+
}
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
if (seen.has(name)) {
|
|
68
|
+
warnOnce(`user-dup:${name}`, `user extension "${name}" exists in two roots — keeping the first`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const entry = join(root, name, "main.tsx");
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(entry)) continue;
|
|
74
|
+
entries.push({
|
|
75
|
+
id: name,
|
|
76
|
+
entry,
|
|
77
|
+
mtimeMs: statSync(entry).mtimeMs,
|
|
78
|
+
source: `user:${entry}`,
|
|
79
|
+
});
|
|
80
|
+
seen.add(name);
|
|
81
|
+
} catch {
|
|
82
|
+
/* unreadable entry — skip */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cache = { at: now, entries };
|
|
87
|
+
return entries;
|
|
88
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
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).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenCode webui (opencode-webui)
|
|
7
|
+
|
|
8
|
+
The browser frontend for the OpenCode engine — same engine, same sessions as
|
|
9
|
+
the `opencode` TUI, with a Bun proxy in front so the browser never holds
|
|
10
|
+
service credentials. One port for UI + `/api/*`: http://localhost:4097
|
|
11
|
+
(`WEBUI_PROXY_PORT`).
|
|
12
|
+
|
|
13
|
+
## Environment
|
|
14
|
+
|
|
15
|
+
| Variable | Default | Purpose |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `WEBUI_PASSWORD` | generated on first boot, printed once | Shared login passphrase. |
|
|
18
|
+
| `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
|
|
19
|
+
| `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
|
|
20
|
+
| `WEBUI_DEBUG` | unset | `1` — server/proxy debug logs to stdout. |
|
|
21
|
+
| `WEBUI_DEBUG_LOG` | `/tmp/webui-debug.log` | File the frontend log sink (`POST /api/debug`) appends to. |
|
|
22
|
+
|
|
23
|
+
## What you can do for the user
|
|
24
|
+
|
|
25
|
+
- **File a webui bug** — the composer ships a built-in `/report` command that
|
|
26
|
+
bundles diagnostics (build version, user agent, enabled extension ids, error
|
|
27
|
+
ring) into a prefilled GitHub issue for AbdelftahZowail/opencode-webui (see
|
|
28
|
+
Reporting bugs below).
|
|
29
|
+
- **Explain the extension system** from the tables below — they are extracted
|
|
30
|
+
verbatim from the authoring guide (`ui-extensions/README.md`), which is the
|
|
31
|
+
source of truth.
|
|
32
|
+
- **Author a user-dir extension** for the user — a folder, no build step, no
|
|
33
|
+
restart.
|
|
34
|
+
|
|
35
|
+
### Minimal user-dir extension
|
|
36
|
+
|
|
37
|
+
Drop a folder — `~/.config/opencode/webui-extensions/<name>/main.tsx`
|
|
38
|
+
(per-user) or `<project>/.opencode/webui-extensions/<name>/main.tsx`
|
|
39
|
+
(per-project). The proxy bundles it and the page loads it within a poll cycle
|
|
40
|
+
(~8s); toggle per extension in Settings › Extensions. Runtime extensions reach
|
|
41
|
+
the app ONLY through the versioned `window.__opencodeUI` bridge (`register`,
|
|
42
|
+
`react`, `useStore`, `api`, `notify`, `getHooks`):
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// ~/.config/opencode/webui-extensions/hello/main.tsx
|
|
46
|
+
const { register, react } = window.__opencodeUI;
|
|
47
|
+
|
|
48
|
+
register({
|
|
49
|
+
kind: "region",
|
|
50
|
+
id: "hello",
|
|
51
|
+
region: "footer",
|
|
52
|
+
render: () => react.createElement("span", null, "hello from a user extension"),
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Extension kinds (the contract)
|
|
57
|
+
|
|
58
|
+
| Kind | What it does | Where it surfaces |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `region` | render into any `<Slot region="…">` marker placed by core (see generated table below) | wherever core placed a `<Slot>` |
|
|
61
|
+
| `command` | entry in the palette's "Extension commands" group (`run({ sessionID })`; `keybind` like `ctrl+shift+k` for global hotkey) | command palette (⌘/ctrl-K) + keybind |
|
|
62
|
+
| `slash` | **UI-only** slash entry for Composer `/name` (local `run(args,{sessionID})`; not engine) | Composer autocomplete (`/` menu) |
|
|
63
|
+
| `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 |
|
|
64
|
+
| `message.decoration` | small extras under message rows; `render({ messageID, message }) => node\|null` | under every message row |
|
|
65
|
+
| `message.part` | inject after *each* part (text/tool/reasoning) inside a message; `render({messageID, message, part, partIndex})` | inside `MessageItem` per part |
|
|
66
|
+
| `tool.renderer` | custom card for a specific tool name (`toolName:"bash"\|"edit"\|…`, `render(part)`) | `ToolCard` per tool call |
|
|
67
|
+
| `contextMenu` | right-click menu item (`target:"message"\|"session"\|"file"`, `label`, `run`, `order`) | context menu |
|
|
68
|
+
| `hook` | intercept/behavior (`event:string`, `handler(ctx,next)`) — see Hook events below | store / Composer / MessageItem |
|
|
69
|
+
| `page` | full surface at `/ext/{id}` (route derived from the id) | sidebar links + direct URL |
|
|
70
|
+
| `settings` | titled section inside Settings › Extensions (`render: () => ReactNode`) | Settings dialog |
|
|
71
|
+
|
|
72
|
+
### Hook events
|
|
73
|
+
|
|
74
|
+
| Event | `ctx` shape | When |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `session.prompt` | `{ text: string, sessionID: string }` — mutate `ctx.text` to transform; call `next()` to continue | Composer `submit` before `POST /api/session/{id}/prompt` |
|
|
77
|
+
| `message.render` | `{ message: MessageInfo, sessionID?: string }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before `message` renderer |
|
|
78
|
+
| `store.dispatch` | `{ action, ... }` | store middleware observer |
|
|
79
|
+
|
|
80
|
+
## Regions (render points)
|
|
81
|
+
|
|
82
|
+
| Region | Render point |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `app.header` | `src/App.tsx:238` |
|
|
85
|
+
| `composer.above` | `src/components/Composer.tsx:932` |
|
|
86
|
+
| `composer.below` | `src/components/Composer.tsx:1252` |
|
|
87
|
+
| `composer.toolbar` | `src/components/Composer.tsx:1240` |
|
|
88
|
+
| `footer` | `src/App.tsx:303` |
|
|
89
|
+
| `header.session.actions` | `src/components/Conversation.tsx:572` |
|
|
90
|
+
| `header.session.before` | `src/components/Conversation.tsx:520` |
|
|
91
|
+
| `message.after` | `src/components/MessageItem.tsx:258` |
|
|
92
|
+
| `message.before` | `src/components/MessageItem.tsx:239` |
|
|
93
|
+
| `sidebar` | `src/components/Sidebar.tsx:682` |
|
|
94
|
+
| `sidebar.session.after` | `src/components/Sidebar.tsx:982` |
|
|
95
|
+
| `sidebar.session.before` | `src/components/Sidebar.tsx:914` |
|
|
96
|
+
| `tool.after` | `src/components/ToolCard.tsx:58` |
|
|
97
|
+
| `tool.before` | `src/components/ToolCard.tsx:57` |
|
|
98
|
+
| `transcript.above` | `src/components/Conversation.tsx:128` |
|
|
99
|
+
| `transcript.below` | `src/components/Conversation.tsx:155` |
|
|
100
|
+
| `transcript.empty` | `src/components/Conversation.tsx:434` |
|
|
101
|
+
|
|
102
|
+
## Add / remove / disable (app repo)
|
|
103
|
+
|
|
104
|
+
| Action | How |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| Add | Create `ui-extensions/<name>/`, add one import to `ui-extensions/index.ts`. Appears instantly via HMR. |
|
|
107
|
+
| Remove | Delete the import line and the folder. |
|
|
108
|
+
| Disable | Remove its id from the `enabled` list in `ui-extensions/config.ts` — one line, applies instantly via HMR, no reload. |
|
|
109
|
+
|
|
110
|
+
The `enabled` list in `ui-extensions/config.ts` is the runtime switch: only
|
|
111
|
+
ids listed there are rendered, even if the code is bundled. (A settings-panel
|
|
112
|
+
UI could drive the same list later — the mechanism is already in place.)
|
|
113
|
+
|
|
114
|
+
## Reporting bugs
|
|
115
|
+
|
|
116
|
+
`/report` in the composer files a prefilled GitHub issue against
|
|
117
|
+
AbdelftahZowail/opencode-webui with a diagnostics bundle (build version, user
|
|
118
|
+
agent, enabled extension ids, window error ring). `--agent` hands the same
|
|
119
|
+
bundle to the session agent instead: when the user asks you to file it and
|
|
120
|
+
`gh` is authenticated, create the issue yourself from the bundle — the webui
|
|
121
|
+
never holds GitHub credentials. The engine's built-in `/report` skill is a
|
|
122
|
+
different thing (different repo, different diagnostics).
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# UI Extensions
|
|
2
|
+
|
|
3
|
+
Frontend additions live in `ui-extensions/<name>/` and are **plain React
|
|
4
|
+
code, compiled into the app** — no plugin framework, no manifests, no dynamic
|
|
5
|
+
loading. This is a deliberate choice: extensions keep full type safety, hot
|
|
6
|
+
reload, and complete access to the app (store, API client, components).
|
|
7
|
+
|
|
8
|
+
## The kind contract (stable API)
|
|
9
|
+
|
|
10
|
+
Extensions register **kinds** via `register()` from `src/extensions/registry.tsx`.
|
|
11
|
+
This list is versioned — the app only breaks an extension when a kind is
|
|
12
|
+
deliberately changed. Every kind is gated per-id by `enabled` in `ui-extensions/config.ts`
|
|
13
|
+
(ancestry-aware: `my-ext.sub` is on when `my-ext` is enabled).
|
|
14
|
+
|
|
15
|
+
| Kind | What it does | Where it surfaces |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `region` | render into any `<Slot region="…">` marker placed by core (see generated table below) | wherever core placed a `<Slot>` |
|
|
18
|
+
| `command` | entry in the palette's "Extension commands" group (`run({ sessionID })`; `keybind` like `ctrl+shift+k` for global hotkey) | command palette (⌘/ctrl-K) + keybind |
|
|
19
|
+
| `slash` | **UI-only** slash entry for Composer `/name` (local `run(args,{sessionID})`; not engine) | Composer autocomplete (`/` menu) |
|
|
20
|
+
| `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 |
|
|
21
|
+
| `message.decoration` | small extras under message rows; `render({ messageID, message }) => node\|null` | under every message row |
|
|
22
|
+
| `message.part` | inject after *each* part (text/tool/reasoning) inside a message; `render({messageID, message, part, partIndex})` | inside `MessageItem` per part |
|
|
23
|
+
| `tool.renderer` | custom card for a specific tool name (`toolName:"bash"\|"edit"\|…`, `render(part)`) | `ToolCard` per tool call |
|
|
24
|
+
| `contextMenu` | right-click menu item (`target:"message"\|"session"\|"file"`, `label`, `run`, `order`) | context menu |
|
|
25
|
+
| `hook` | intercept/behavior (`event:string`, `handler(ctx,next)`) — see Hook events below | store / Composer / MessageItem |
|
|
26
|
+
| `page` | full surface at `/ext/{id}` (route derived from the id) | sidebar links + direct URL |
|
|
27
|
+
| `settings` | titled section inside Settings › Extensions (`render: () => ReactNode`) | Settings dialog |
|
|
28
|
+
|
|
29
|
+
Reference implementation of every kind was `ui-extensions/dev-sandbox/` (removed — clean launch; see git history for numbered examples).
|
|
30
|
+
|
|
31
|
+
### Hook events
|
|
32
|
+
|
|
33
|
+
`hook` is open — `event` is a `string`, known values are versioned but you can
|
|
34
|
+
register any string and core will call `getHooks(event)` at the seam when it
|
|
35
|
+
exists. Today:
|
|
36
|
+
|
|
37
|
+
| Event | `ctx` shape | When |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `session.prompt` | `{ text: string, sessionID: string }` — mutate `ctx.text` to transform; call `next()` to continue | Composer `submit` before `POST /api/session/{id}/prompt` |
|
|
40
|
+
| `message.render` | `{ message: MessageInfo, sessionID?: string }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before `message` renderer |
|
|
41
|
+
| `store.dispatch` | `{ action, ... }` | store middleware observer |
|
|
42
|
+
|
|
43
|
+
Adding a new seam (e.g. `composer.submit`, `tool.started`) is one `getHooks("new.event")` call in core — no registry bump for existing extensions.
|
|
44
|
+
|
|
45
|
+
### Slash: engine vs UI
|
|
46
|
+
|
|
47
|
+
Composer's `/` menu `src/components/Composer.tsx:576` is a merge:
|
|
48
|
+
|
|
49
|
+
1. **UI built-ins** `slashActions[]` `Composer.tsx:403` (`/new`, `/undo`, `/thinking`… — local, never hits engine)
|
|
50
|
+
2. **UI extensions** `kind:"slash"` `registry.tsx:62` (`/bench` → local `run(args,{sessionID})`)
|
|
51
|
+
3. **Engine** `GET /api/command` + `GET /api/skill` `src/api/client.ts:677` (`commands`/`skills` → `POST /api/session/{id}/command`)
|
|
52
|
+
4. Sorted + fuzzy `filterSlashEntries` `Composer.tsx:125`, capped `SLASH_MENU_LIMIT=10`.
|
|
53
|
+
|
|
54
|
+
* Want `/` to run **on the server** (tool, agent work)? Add an **engine plugin** (provides `Command`/`Skill` via `GET /api/plugin` — engine docs). It appears automatically, no UI change.
|
|
55
|
+
* Want `/` to run **locally in the UI** (toggle panel, run `api.*`, `useStore` action, `notify()`)? Use `kind:"slash"` in a UI extension. Keep the name `^[a-z0-9_-]+$`; on clash engine wins (UI extension warns and is skipped).
|
|
56
|
+
|
|
57
|
+
Palette `kind:"command"` stays the place for `⌘K` actions; `kind:"slash"` is only for the `/` autocomplete.
|
|
58
|
+
|
|
59
|
+
## Anatomy of an extension
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ui-extensions/
|
|
63
|
+
index.ts ← auto-discovery: every <name>/index.{ts,tsx} is loaded,
|
|
64
|
+
no manual imports (visibility gated by config.ts)
|
|
65
|
+
hello/
|
|
66
|
+
index.tsx ← registers against one or more kinds
|
|
67
|
+
my-feature/
|
|
68
|
+
index.tsx
|
|
69
|
+
components.tsx ← anything else; it's just your code
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`ui-extensions/index.ts` is the only thing the app imports; each extension
|
|
73
|
+
folder self-registers:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
// ui-extensions/hello/index.tsx
|
|
77
|
+
import { register } from "../../src/extensions/registry";
|
|
78
|
+
|
|
79
|
+
register({
|
|
80
|
+
kind: "region",
|
|
81
|
+
id: "hello",
|
|
82
|
+
region: "footer",
|
|
83
|
+
render: () => <span>Hello!</span>,
|
|
84
|
+
});
|
|
85
|
+
if (import.meta.hot) import.meta.hot.accept();
|
|
86
|
+
export const id = "hello";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A tool renderer receives the full tool part:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
register({
|
|
93
|
+
kind: "tool.renderer",
|
|
94
|
+
id: "my-bash-card",
|
|
95
|
+
toolName: "bash",
|
|
96
|
+
render: (part) => <pre>{part.state.content}</pre>,
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A UI-only slash:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
register({
|
|
104
|
+
kind: "slash",
|
|
105
|
+
id: "my.slash",
|
|
106
|
+
name: "bench",
|
|
107
|
+
description: "Run bench locally",
|
|
108
|
+
aliases: ["b"],
|
|
109
|
+
run: (args, { sessionID }) => console.log("bench", args, sessionID),
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A message replacement (own `system`/`synthetic` without touching core):
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
register({
|
|
117
|
+
kind: "message",
|
|
118
|
+
id: "my.instructions",
|
|
119
|
+
type: "system",
|
|
120
|
+
render: ({ message }) => {
|
|
121
|
+
// return null to fall back to core's InstructionCard
|
|
122
|
+
if (!String((message as any).text ?? "").includes("The Code Mode tool catalog")) return null;
|
|
123
|
+
const title = (message as any).description ?? "Instructions updated";
|
|
124
|
+
return <div className="rounded-md border px-3 py-1.5 text-xs">{title}</div>;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## What extensions can use
|
|
130
|
+
|
|
131
|
+
Everything the app can — it's the same build:
|
|
132
|
+
|
|
133
|
+
- `useStore` / store actions from `src/store.ts` (session state, sending prompts, permissions, `selectSession`, `sendPromptTo`, `interrupt`, …)
|
|
134
|
+
- `api` from `src/api/client.ts` (any endpoint: `listSessions`, `messages`, `fsRead`, `shellCreate`, …)
|
|
135
|
+
- `window.__opencodeUI.notify({title, description, variant})` — toasts (also `import {notify} from "../../src/lib/notify"` in built-ins)
|
|
136
|
+
- `window.__opencodeUI.getHooks` — inspect registered hooks (runtime bridge)
|
|
137
|
+
- UI primitives from `src/components/ui/` (shadcn: `Button`, `Dialog`,
|
|
138
|
+
`DropdownMenu`, `Command`, `Tooltip`, `ContextMenu`, …) — always build on these so
|
|
139
|
+
extensions look native
|
|
140
|
+
- The OC-2 design tokens in `src/styles.css` — consume as
|
|
141
|
+
`var(--background-base)`, `var(--text-weak)`, `var(--border-base)`, etc.
|
|
142
|
+
**Never hardcode colors**; tokens keep extensions theme-compatible
|
|
143
|
+
- Any component, hook, or CSS class
|
|
144
|
+
- Right-click menus and per-part injections need no extra setup — just register `contextMenu`/`message.part` kinds.
|
|
145
|
+
|
|
146
|
+
## Add / remove / disable
|
|
147
|
+
|
|
148
|
+
| Action | How |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| Add | Create `ui-extensions/<name>/`, add one import to `ui-extensions/index.ts`. Appears instantly via HMR. |
|
|
151
|
+
| Remove | Delete the import line and the folder. |
|
|
152
|
+
| Disable | Remove its id from the `enabled` list in `ui-extensions/config.ts` — one line, applies instantly via HMR, no reload. |
|
|
153
|
+
|
|
154
|
+
The `enabled` list in `ui-extensions/config.ts` is the runtime switch: only
|
|
155
|
+
ids listed there are rendered, even if the code is bundled. (A settings-panel
|
|
156
|
+
UI could drive the same list later — the mechanism is already in place.)
|
|
157
|
+
|
|
158
|
+
## Sharing with others
|
|
159
|
+
|
|
160
|
+
An extension is a React component, so npm is the sharing format:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
bun add @someone/opencode-webui-status-bar
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
// ui-extensions/index.ts
|
|
168
|
+
import { register } from "../src/extensions/registry";
|
|
169
|
+
import { StatusBar } from "@someone/opencode-webui-status-bar";
|
|
170
|
+
register({ kind: "region", id: "status-bar", region: "footer", render: () => <StatusBar /> });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
That's it — no plugin API to learn, nothing to build. Publishing a shared
|
|
174
|
+
extension is just publishing a component library.
|
|
175
|
+
|
|
176
|
+
## Preview before production
|
|
177
|
+
|
|
178
|
+
The dev server (localhost:5173) is the preview: every edit hot-reloads there,
|
|
179
|
+
and production (the built app on 4097) only changes when you run
|
|
180
|
+
`bun run build && bun start`. For a second, isolated preview page of WIP
|
|
181
|
+
changes, run `bun run preview` (localhost:5174, proxy :4098).
|
|
182
|
+
|
|
183
|
+
### Hot reload
|
|
184
|
+
|
|
185
|
+
Extension entries end with `if (import.meta.hot) import.meta.hot.accept();` and
|
|
186
|
+
export their `id` — keep both. Editing an extension hot-swaps it live (same-id
|
|
187
|
+
registry swap, slots repaint); ADDING a folder is hot too (`index.ts`
|
|
188
|
+
re-discovers without a reload). DELETING a folder is hot too: Vite accepts the
|
|
189
|
+
removal at the `index.ts` HMR boundary and `pruneExtensions` drops the slots
|
|
190
|
+
without a reload (proven by `scripts/uitest/extensions-check.ts` Phase C).
|
|
191
|
+
Flipping `config.ts` still reloads (it is imported by core). Slots removed by
|
|
192
|
+
an edit disappear cleanly and instantly.
|
|
193
|
+
|
|
194
|
+
## Region markers
|
|
195
|
+
|
|
196
|
+
Drop-in render points addressed by string. Empty regions cost nothing;
|
|
197
|
+
register against one with `{ kind: "region", region: "...", render }`.
|
|
198
|
+
|
|
199
|
+
<!-- regions:auto:start -->
|
|
200
|
+
| Region | Render point |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `app.header` | `src/App.tsx:238` |
|
|
203
|
+
| `composer.above` | `src/components/Composer.tsx:932` |
|
|
204
|
+
| `composer.below` | `src/components/Composer.tsx:1252` |
|
|
205
|
+
| `composer.toolbar` | `src/components/Composer.tsx:1240` |
|
|
206
|
+
| `footer` | `src/App.tsx:303` |
|
|
207
|
+
| `header.session.actions` | `src/components/Conversation.tsx:572` |
|
|
208
|
+
| `header.session.before` | `src/components/Conversation.tsx:520` |
|
|
209
|
+
| `message.after` | `src/components/MessageItem.tsx:258` |
|
|
210
|
+
| `message.before` | `src/components/MessageItem.tsx:239` |
|
|
211
|
+
| `sidebar` | `src/components/Sidebar.tsx:682` |
|
|
212
|
+
| `sidebar.session.after` | `src/components/Sidebar.tsx:982` |
|
|
213
|
+
| `sidebar.session.before` | `src/components/Sidebar.tsx:914` |
|
|
214
|
+
| `tool.after` | `src/components/ToolCard.tsx:58` |
|
|
215
|
+
| `tool.before` | `src/components/ToolCard.tsx:57` |
|
|
216
|
+
| `transcript.above` | `src/components/Conversation.tsx:128` |
|
|
217
|
+
| `transcript.below` | `src/components/Conversation.tsx:155` |
|
|
218
|
+
| `transcript.empty` | `src/components/Conversation.tsx:434` |
|
|
219
|
+
<!-- regions:auto:end -->
|
|
220
|
+
|
|
221
|
+
Run `bun run regions` after adding a `<Slot region="…">` in core — the table is auto-generated.
|
|
222
|
+
|
|
223
|
+
## Limitations & when to use what
|
|
224
|
+
|
|
225
|
+
Extensions are **not a second engine**. These stay engine-owned:
|
|
226
|
+
|
|
227
|
+
| Area | UI extension can do | Engine plugin must do |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| Slash that runs on server | Show it as UI-only `kind:"slash"` but it won't hit `POST /api/session/{id}/command` | Provide `Command`/`Skill` via engine plugin (`GET /api/plugin` → `GET /api/command`) |
|
|
230
|
+
| New tool that the model can call | Render it differently via `kind:"tool.renderer"` | Provide the tool itself (engine `Tool` + execution) |
|
|
231
|
+
| New permission/form/question kind | Render extra decoration, auto-reply via `api` + `hook:store.dispatch` observer | Define it on engine |
|
|
232
|
+
| Session/model/agent lifecycle | Read/trigger via `useStore`/`api` | Own it |
|
|
233
|
+
|
|
234
|
+
**No snooping needed:** if a region/kind isn't in the tables above, it doesn't exist. Adding a region is one line `<Slot region="area.thing" />` in core + `bun run regions`; adding a kind is a deliberate contract change in `src/extensions/registry.tsx` (+ docs here + `src/extensions/registry.tsx:5` header + `AGENTS.md`). Don't invent speculative kinds — add a `region` first.
|
|
235
|
+
|
|
236
|
+
**What you don't need to fork core for anymore:**
|
|
237
|
+
|
|
238
|
+
* Verbose `Instructions updated` / catalog dump `src/components/MessageItem.tsx:268` → `kind:"message"` `type:"system"|"synthetic"` with `return null` fallback
|
|
239
|
+
* Per-session badges / cost / PR status `src/components/Sidebar.tsx:892` → `region:"sidebar.session.before/after"` with `sessionID` ctx
|
|
240
|
+
* Extra header buttons `src/components/Conversation.tsx:518` → `region:"header.session.before/after"`
|
|
241
|
+
* Composer buttons next to `Send` `src/components/Composer.tsx:1238` → `region:"composer.toolbar"`
|
|
242
|
+
* Wrapping a tool or message `src/components/ToolCard.tsx:29` → `region:"tool.before/after"` / `region:"message.before/after"` or `kind:"message"` / `kind:"tool.renderer"` for full replacement
|
|
243
|
+
* Intercepting a prompt `src/components/Composer.tsx:727` → `hook:"session.prompt"` mutate `ctx.text`; observing renders → `hook:"message.render"` mutate `ctx.message`
|
|
244
|
+
|
|
245
|
+
## Runtime plugin extensions (plugin-shipped UI)
|
|
246
|
+
|
|
247
|
+
An opencode v2 plugin can carry a WEB UI half that this app loads at runtime —
|
|
248
|
+
no webui rebuild, ever.
|
|
249
|
+
|
|
250
|
+
**Convention**: for a plugin whose entry is `<dir>/foo.ts`, the UI entry is
|
|
251
|
+
`<dir>/ui/main.tsx` (or a sibling `<dir>/foo.ui.tsx`). Only plugins loaded
|
|
252
|
+
from LOCAL sources (`Plugin.Source {type:"local"}`) are discovered in v1;
|
|
253
|
+
npm-package plugins would need node resolution and are future work.
|
|
254
|
+
|
|
255
|
+
**Pipeline**: `GET /api/plugin` (engine) → proxy finds UI entries →
|
|
256
|
+
`Bun.build` bundles each entry as a SELF-CONTAINED ES module (it bundles its
|
|
257
|
+
own React copy) → served at `/api/webui/extensions/{id}/bundle.js?v=mtime`.
|
|
258
|
+
The page lists them from `GET /api/webui/extensions`, allow-lists their ids in
|
|
259
|
+
the registry, and `import()`s each bundle. Bundles reach the app ONLY through
|
|
260
|
+
the `window.__opencodeUI` bridge (`{ version, register, react, jsxRuntime,
|
|
261
|
+
useStore, api }`) — that object is the versioned public API for runtime
|
|
262
|
+
extensions; deep internal imports are not available to them.
|
|
263
|
+
|
|
264
|
+
**Gating**: default ON (installing the plugin means you wanted it). Toggle any
|
|
265
|
+
of them in Settings › Extensions — persisted per browser, applies within one
|
|
266
|
+
poll cycle (~8s; off = unregistered immediately). Built-in `ui-extensions/`
|
|
267
|
+
are not listed there; they stay gated by `config.ts`.
|
|
268
|
+
|
|
269
|
+
**Talking to the engine half**: the UI half and the engine half of a plugin
|
|
270
|
+
communicate through the engine's normal HTTP/SSE surface — call routes via
|
|
271
|
+
`api`, subscribe to events via the store's SSE connection, exactly like core.
|