summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-03-27 15:00:26 +0100
committerGitHub <[email protected]>2026-03-27 15:00:26 +0100
commit6274b0677c1c65815c525b9b199f1ce5c6fb97fc (patch)
tree399fdba174e350c2f86c2deba047ac8b17ce43bf
parentd8ad8338f5311ac6692ebc362d28389e028f6aad (diff)
downloadopencode-6274b0677c1c65815c525b9b199f1ce5c6fb97fc.tar.gz
opencode-6274b0677c1c65815c525b9b199f1ce5c6fb97fc.zip
tui plugins (#19347)
-rw-r--r--.opencode/plugins/smoke-theme.json223
-rw-r--r--.opencode/plugins/tui-smoke.tsx852
-rw-r--r--.opencode/themes/.gitignore1
-rw-r--r--.opencode/tui.json19
-rw-r--r--bun.lock16
-rw-r--r--packages/app/src/components/status-popover-body.tsx4
-rw-r--r--packages/opencode/bunfig.toml2
-rwxr-xr-xpackages/opencode/script/build.ts5
-rw-r--r--packages/opencode/specs/tui-plugins.md377
-rw-r--r--packages/opencode/src/bun/index.ts11
-rw-r--r--packages/opencode/src/bun/registry.ts6
-rw-r--r--packages/opencode/src/cli/cmd/db.ts5
-rw-r--r--packages/opencode/src/cli/cmd/plug.ts231
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx365
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx36
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx3
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx33
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/error-component.tsx91
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx14
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx63
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/exit.tsx3
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/keybind.tsx21
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts41
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/route.tsx9
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sdk.tsx3
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx242
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx (renamed from packages/opencode/src/cli/cmd/tui/component/tips.tsx)2
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx48
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx61
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx60
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx91
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx64
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx94
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx46
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx262
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/api.tsx406
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/index.ts3
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/internal.ts25
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts972
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/slots.tsx61
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/home.tsx48
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx1
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx303
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts5
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog.tsx18
-rw-r--r--packages/opencode/src/cli/error.ts15
-rw-r--r--packages/opencode/src/config/config.ts173
-rw-r--r--packages/opencode/src/config/tui-schema.ts2
-rw-r--r--packages/opencode/src/config/tui.ts148
-rw-r--r--packages/opencode/src/flag/flag.ts25
-rw-r--r--packages/opencode/src/index.ts17
-rw-r--r--packages/opencode/src/plugin/index.ts189
-rw-r--r--packages/opencode/src/plugin/install.ts351
-rw-r--r--packages/opencode/src/plugin/meta.ts165
-rw-r--r--packages/opencode/src/plugin/shared.ts149
-rw-r--r--packages/opencode/src/provider/auth.ts6
-rw-r--r--packages/opencode/src/session/message-v2.ts3
-rw-r--r--packages/opencode/src/tool/batch.ts3
-rw-r--r--packages/opencode/src/util/error.ts77
-rw-r--r--packages/opencode/src/util/flock.ts333
-rw-r--r--packages/opencode/src/util/network.ts (renamed from packages/opencode/src/util/proxied.ts)6
-rw-r--r--packages/opencode/src/util/process.ts3
-rw-r--r--packages/opencode/src/util/record.ts3
-rw-r--r--packages/opencode/src/worktree/index.ts12
-rw-r--r--packages/opencode/test/cli/tui/keybind-plugin.test.ts90
-rw-r--r--packages/opencode/test/cli/tui/plugin-add.test.ts61
-rw-r--r--packages/opencode/test/cli/tui/plugin-install.test.ts95
-rw-r--r--packages/opencode/test/cli/tui/plugin-lifecycle.test.ts225
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts132
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-pure.test.ts71
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader.test.ts563
-rw-r--r--packages/opencode/test/cli/tui/plugin-toggle.test.ts157
-rw-r--r--packages/opencode/test/cli/tui/theme-store.test.ts50
-rw-r--r--packages/opencode/test/config/config.test.ts213
-rw-r--r--packages/opencode/test/config/tui.test.ts161
-rw-r--r--packages/opencode/test/fixture/flock-worker.ts72
-rw-r--r--packages/opencode/test/fixture/plug-worker.ts93
-rw-r--r--packages/opencode/test/fixture/plugin-meta-worker.ts26
-rw-r--r--packages/opencode/test/fixture/tui-plugin.ts334
-rw-r--r--packages/opencode/test/fixture/tui-runtime.ts34
-rw-r--r--packages/opencode/test/plugin/auth-override.test.ts21
-rw-r--r--packages/opencode/test/plugin/install-concurrency.test.ts134
-rw-r--r--packages/opencode/test/plugin/install.test.ts410
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts548
-rw-r--r--packages/opencode/test/plugin/meta.test.ts137
-rw-r--r--packages/opencode/test/util/error.test.ts38
-rw-r--r--packages/opencode/test/util/flock.test.ts383
-rw-r--r--packages/plugin/package.json17
-rw-r--r--packages/plugin/src/index.ts22
-rw-r--r--packages/plugin/src/tui.ts419
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts10
91 files changed, 10544 insertions, 898 deletions
diff --git a/.opencode/plugins/smoke-theme.json b/.opencode/plugins/smoke-theme.json
new file mode 100644
index 000000000..6e4595d44
--- /dev/null
+++ b/.opencode/plugins/smoke-theme.json
@@ -0,0 +1,223 @@
+{
+ "$schema": "https://opencode.ai/theme.json",
+ "defs": {
+ "nord0": "#2E3440",
+ "nord1": "#3B4252",
+ "nord2": "#434C5E",
+ "nord3": "#4C566A",
+ "nord4": "#D8DEE9",
+ "nord5": "#E5E9F0",
+ "nord6": "#ECEFF4",
+ "nord7": "#8FBCBB",
+ "nord8": "#88C0D0",
+ "nord9": "#81A1C1",
+ "nord10": "#5E81AC",
+ "nord11": "#BF616A",
+ "nord12": "#D08770",
+ "nord13": "#EBCB8B",
+ "nord14": "#A3BE8C",
+ "nord15": "#B48EAD"
+ },
+ "theme": {
+ "primary": {
+ "dark": "nord10",
+ "light": "nord9"
+ },
+ "secondary": {
+ "dark": "nord9",
+ "light": "nord9"
+ },
+ "accent": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "error": {
+ "dark": "nord11",
+ "light": "nord11"
+ },
+ "warning": {
+ "dark": "nord12",
+ "light": "nord12"
+ },
+ "success": {
+ "dark": "nord14",
+ "light": "nord14"
+ },
+ "info": {
+ "dark": "nord8",
+ "light": "nord10"
+ },
+ "text": {
+ "dark": "nord6",
+ "light": "nord0"
+ },
+ "textMuted": {
+ "dark": "#8B95A7",
+ "light": "nord1"
+ },
+ "background": {
+ "dark": "nord0",
+ "light": "nord6"
+ },
+ "backgroundPanel": {
+ "dark": "nord1",
+ "light": "nord5"
+ },
+ "backgroundElement": {
+ "dark": "nord2",
+ "light": "nord4"
+ },
+ "border": {
+ "dark": "nord2",
+ "light": "nord3"
+ },
+ "borderActive": {
+ "dark": "nord3",
+ "light": "nord2"
+ },
+ "borderSubtle": {
+ "dark": "nord2",
+ "light": "nord3"
+ },
+ "diffAdded": {
+ "dark": "nord14",
+ "light": "nord14"
+ },
+ "diffRemoved": {
+ "dark": "nord11",
+ "light": "nord11"
+ },
+ "diffContext": {
+ "dark": "#8B95A7",
+ "light": "nord3"
+ },
+ "diffHunkHeader": {
+ "dark": "#8B95A7",
+ "light": "nord3"
+ },
+ "diffHighlightAdded": {
+ "dark": "nord14",
+ "light": "nord14"
+ },
+ "diffHighlightRemoved": {
+ "dark": "nord11",
+ "light": "nord11"
+ },
+ "diffAddedBg": {
+ "dark": "#36413C",
+ "light": "#E6EBE7"
+ },
+ "diffRemovedBg": {
+ "dark": "#43393D",
+ "light": "#ECE6E8"
+ },
+ "diffContextBg": {
+ "dark": "nord1",
+ "light": "nord5"
+ },
+ "diffLineNumber": {
+ "dark": "nord2",
+ "light": "nord4"
+ },
+ "diffAddedLineNumberBg": {
+ "dark": "#303A35",
+ "light": "#DDE4DF"
+ },
+ "diffRemovedLineNumberBg": {
+ "dark": "#3C3336",
+ "light": "#E4DDE0"
+ },
+ "markdownText": {
+ "dark": "nord4",
+ "light": "nord0"
+ },
+ "markdownHeading": {
+ "dark": "nord8",
+ "light": "nord10"
+ },
+ "markdownLink": {
+ "dark": "nord9",
+ "light": "nord9"
+ },
+ "markdownLinkText": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "markdownCode": {
+ "dark": "nord14",
+ "light": "nord14"
+ },
+ "markdownBlockQuote": {
+ "dark": "#8B95A7",
+ "light": "nord3"
+ },
+ "markdownEmph": {
+ "dark": "nord12",
+ "light": "nord12"
+ },
+ "markdownStrong": {
+ "dark": "nord13",
+ "light": "nord13"
+ },
+ "markdownHorizontalRule": {
+ "dark": "#8B95A7",
+ "light": "nord3"
+ },
+ "markdownListItem": {
+ "dark": "nord8",
+ "light": "nord10"
+ },
+ "markdownListEnumeration": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "markdownImage": {
+ "dark": "nord9",
+ "light": "nord9"
+ },
+ "markdownImageText": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "markdownCodeBlock": {
+ "dark": "nord4",
+ "light": "nord0"
+ },
+ "syntaxComment": {
+ "dark": "#8B95A7",
+ "light": "nord3"
+ },
+ "syntaxKeyword": {
+ "dark": "nord9",
+ "light": "nord9"
+ },
+ "syntaxFunction": {
+ "dark": "nord8",
+ "light": "nord8"
+ },
+ "syntaxVariable": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "syntaxString": {
+ "dark": "nord14",
+ "light": "nord14"
+ },
+ "syntaxNumber": {
+ "dark": "nord15",
+ "light": "nord15"
+ },
+ "syntaxType": {
+ "dark": "nord7",
+ "light": "nord7"
+ },
+ "syntaxOperator": {
+ "dark": "nord9",
+ "light": "nord9"
+ },
+ "syntaxPunctuation": {
+ "dark": "nord4",
+ "light": "nord0"
+ }
+ }
+}
diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx
new file mode 100644
index 000000000..3e90bafb6
--- /dev/null
+++ b/.opencode/plugins/tui-smoke.tsx
@@ -0,0 +1,852 @@
+/** @jsxImportSource @opentui/solid */
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { RGBA, VignetteEffect } from "@opentui/core"
+import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
+
+const tabs = ["overview", "counter", "help"]
+const bind = {
+ modal: "ctrl+shift+m",
+ screen: "ctrl+shift+o",
+ home: "escape,ctrl+h",
+ left: "left,h",
+ right: "right,l",
+ up: "up,k",
+ down: "down,j",
+ alert: "a",
+ confirm: "c",
+ prompt: "p",
+ select: "s",
+ modal_accept: "enter,return",
+ modal_close: "escape",
+ dialog_close: "escape",
+ local: "x",
+ local_push: "enter,return",
+ local_close: "q,backspace",
+ host: "z",
+}
+
+const pick = (value: unknown, fallback: string) => {
+ if (typeof value !== "string") return fallback
+ if (!value.trim()) return fallback
+ return value
+}
+
+const num = (value: unknown, fallback: number) => {
+ if (typeof value !== "number") return fallback
+ return value
+}
+
+const rec = (value: unknown) => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
+ return Object.fromEntries(Object.entries(value))
+}
+
+type Cfg = {
+ label: string
+ route: string
+ vignette: number
+ keybinds: Record<string, unknown> | undefined
+}
+
+type Route = {
+ modal: string
+ screen: string
+}
+
+type State = {
+ tab: number
+ count: number
+ source: string
+ note: string
+ selected: string
+ local: number
+}
+
+const cfg = (options: Record<string, unknown> | undefined) => {
+ return {
+ label: pick(options?.label, "smoke"),
+ route: pick(options?.route, "workspace-smoke"),
+ vignette: Math.max(0, num(options?.vignette, 0.35)),
+ keybinds: rec(options?.keybinds),
+ }
+}
+
+const names = (input: Cfg) => {
+ return {
+ modal: `${input.route}.modal`,
+ screen: `${input.route}.screen`,
+ }
+}
+
+type Keys = TuiKeybindSet
+const ui = {
+ panel: "#1d1d1d",
+ border: "#4a4a4a",
+ text: "#f0f0f0",
+ muted: "#a5a5a5",
+ accent: "#5f87ff",
+}
+
+type Color = RGBA | string
+
+const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
+ const value = map[name]
+ if (typeof value === "string") return value
+ if (value instanceof RGBA) return value
+ return fallback
+}
+
+const look = (map: Record<string, unknown>) => {
+ return {
+ panel: ink(map, "backgroundPanel", ui.panel),
+ border: ink(map, "border", ui.border),
+ text: ink(map, "text", ui.text),
+ muted: ink(map, "textMuted", ui.muted),
+ accent: ink(map, "primary", ui.accent),
+ selected: ink(map, "selectedListItemText", ui.text),
+ }
+}
+
+const tone = (api: TuiPluginApi) => {
+ return look(api.theme.current)
+}
+
+type Skin = {
+ panel: Color
+ border: Color
+ text: Color
+ muted: Color
+ accent: Color
+ selected: Color
+}
+
+const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
+ return (
+ <box
+ onMouseUp={() => {
+ props.run()
+ }}
+ backgroundColor={props.on ? props.skin.accent : props.skin.border}
+ paddingLeft={1}
+ paddingRight={1}
+ >
+ <text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
+ </box>
+ )
+}
+
+const parse = (params: Record<string, unknown> | undefined) => {
+ const tab = typeof params?.tab === "number" ? params.tab : 0
+ const count = typeof params?.count === "number" ? params.count : 0
+ const source = typeof params?.source === "string" ? params.source : "unknown"
+ const note = typeof params?.note === "string" ? params.note : ""
+ const selected = typeof params?.selected === "string" ? params.selected : ""
+ const local = typeof params?.local === "number" ? params.local : 0
+ return {
+ tab: Math.max(0, Math.min(tab, tabs.length - 1)),
+ count,
+ source,
+ note,
+ selected,
+ local: Math.max(0, local),
+ }
+}
+
+const current = (api: TuiPluginApi, route: Route) => {
+ const value = api.route.current
+ const ok = Object.values(route).includes(value.name)
+ if (!ok) return parse(undefined)
+ if (!("params" in value)) return parse(undefined)
+ return parse(value.params)
+}
+
+const opts = [
+ {
+ title: "Overview",
+ value: 0,
+ description: "Switch to overview tab",
+ },
+ {
+ title: "Counter",
+ value: 1,
+ description: "Switch to counter tab",
+ },
+ {
+ title: "Help",
+ value: 2,
+ description: "Switch to help tab",
+ },
+]
+
+const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
+ api.ui.dialog.setSize("medium")
+ api.ui.dialog.replace(() => (
+ <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
+ <text fg={skin.text}>
+ <b>{input.label} host overlay</b>
+ </text>
+ <text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
+ <text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
+ <box flexDirection="row" gap={1}>
+ <Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
+ </box>
+ </box>
+ ))
+}
+
+const warn = (api: TuiPluginApi, route: Route, value: State) => {
+ const DialogAlert = api.ui.DialogAlert
+ api.ui.dialog.setSize("medium")
+ api.ui.dialog.replace(() => (
+ <DialogAlert
+ title="Smoke alert"
+ message="Testing built-in alert dialog"
+ onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
+ />
+ ))
+}
+
+const check = (api: TuiPluginApi, route: Route, value: State) => {
+ const DialogConfirm = api.ui.DialogConfirm
+ api.ui.dialog.setSize("medium")
+ api.ui.dialog.replace(() => (
+ <DialogConfirm
+ title="Smoke confirm"
+ message="Apply +1 to counter?"
+ onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
+ onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
+ />
+ ))
+}
+
+const entry = (api: TuiPluginApi, route: Route, value: State) => {
+ const DialogPrompt = api.ui.DialogPrompt
+ api.ui.dialog.setSize("medium")
+ api.ui.dialog.replace(() => (
+ <DialogPrompt
+ title="Smoke prompt"
+ value={value.note}
+ onConfirm={(note) => {
+ api.ui.dialog.clear()
+ api.route.navigate(route.screen, { ...value, note, source: "prompt" })
+ }}
+ onCancel={() => {
+ api.ui.dialog.clear()
+ api.route.navigate(route.screen, value)
+ }}
+ />
+ ))
+}
+
+const picker = (api: TuiPluginApi, route: Route, value: State) => {
+ const DialogSelect = api.ui.DialogSelect
+ api.ui.dialog.setSize("medium")
+ api.ui.dialog.replace(() => (
+ <DialogSelect
+ title="Smoke select"
+ options={opts}
+ current={value.tab}
+ onSelect={(item) => {
+ api.ui.dialog.clear()
+ api.route.navigate(route.screen, {
+ ...value,
+ tab: typeof item.value === "number" ? item.value : value.tab,
+ selected: item.title,
+ source: "select",
+ })
+ }}
+ />
+ ))
+}
+
+const Screen = (props: {
+ api: TuiPluginApi
+ input: Cfg
+ route: Route
+ keys: Keys
+ meta: TuiPluginMeta
+ params?: Record<string, unknown>
+}) => {
+ const dim = useTerminalDimensions()
+ const value = parse(props.params)
+ const skin = tone(props.api)
+ const set = (local: number, base?: State) => {
+ const next = base ?? current(props.api, props.route)
+ props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
+ }
+ const push = (base?: State) => {
+ const next = base ?? current(props.api, props.route)
+ set(next.local + 1, next)
+ }
+ const open = () => {
+ const next = current(props.api, props.route)
+ if (next.local > 0) return
+ set(1, next)
+ }
+ const pop = (base?: State) => {
+ const next = base ?? current(props.api, props.route)
+ const local = Math.max(0, next.local - 1)
+ set(local, next)
+ }
+ const show = () => {
+ setTimeout(() => {
+ open()
+ }, 0)
+ }
+ useKeyboard((evt) => {
+ if (props.api.route.current.name !== props.route.screen) return
+ const next = current(props.api, props.route)
+ if (props.api.ui.dialog.open) {
+ if (props.keys.match("dialog_close", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.ui.dialog.clear()
+ return
+ }
+ return
+ }
+
+ if (next.local > 0) {
+ if (evt.name === "escape" || props.keys.match("local_close", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ pop(next)
+ return
+ }
+
+ if (props.keys.match("local_push", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ push(next)
+ return
+ }
+ return
+ }
+
+ if (props.keys.match("home", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate("home")
+ return
+ }
+
+ if (props.keys.match("left", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
+ return
+ }
+
+ if (props.keys.match("right", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
+ return
+ }
+
+ if (props.keys.match("up", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
+ return
+ }
+
+ if (props.keys.match("down", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
+ return
+ }
+
+ if (props.keys.match("modal", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.modal, next)
+ return
+ }
+
+ if (props.keys.match("local", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ open()
+ return
+ }
+
+ if (props.keys.match("host", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ host(props.api, props.input, skin)
+ return
+ }
+
+ if (props.keys.match("alert", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ warn(props.api, props.route, next)
+ return
+ }
+
+ if (props.keys.match("confirm", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ check(props.api, props.route, next)
+ return
+ }
+
+ if (props.keys.match("prompt", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ entry(props.api, props.route, next)
+ return
+ }
+
+ if (props.keys.match("select", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ picker(props.api, props.route, next)
+ }
+ })
+
+ return (
+ <box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
+ <box
+ flexDirection="column"
+ width="100%"
+ height="100%"
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ >
+ <box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
+ <text fg={skin.text}>
+ <b>{props.input.label} screen</b>
+ <span style={{ fg: skin.muted }}> plugin route</span>
+ </text>
+ <text fg={skin.muted}>{props.keys.print("home")} home</text>
+ </box>
+
+ <box flexDirection="row" gap={1} paddingBottom={1}>
+ {tabs.map((item, i) => {
+ const on = value.tab === i
+ return (
+ <Btn
+ txt={item}
+ run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
+ skin={skin}
+ on={on}
+ />
+ )
+ })}
+ </box>
+
+ <box
+ border
+ borderColor={skin.border}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ flexGrow={1}
+ >
+ {value.tab === 0 ? (
+ <box flexDirection="column" gap={1}>
+ <text fg={skin.text}>Route: {props.route.screen}</text>
+ <text fg={skin.muted}>plugin state: {props.meta.state}</text>
+ <text fg={skin.muted}>
+ first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
+ {props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
+ </text>
+ <text fg={skin.muted}>plugin source: {props.meta.source}</text>
+ <text fg={skin.muted}>source: {value.source}</text>
+ <text fg={skin.muted}>note: {value.note || "(none)"}</text>
+ <text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
+ <text fg={skin.muted}>local stack depth: {value.local}</text>
+ <text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
+ </box>
+ ) : null}
+
+ {value.tab === 1 ? (
+ <box flexDirection="column" gap={1}>
+ <text fg={skin.text}>Counter: {value.count}</text>
+ <text fg={skin.muted}>
+ {props.keys.print("up")} / {props.keys.print("down")} change value
+ </text>
+ </box>
+ ) : null}
+
+ {value.tab === 2 ? (
+ <box flexDirection="column" gap={1}>
+ <text fg={skin.muted}>
+ {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
+ confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
+ </text>
+ <text fg={skin.muted}>
+ {props.keys.print("local")} local stack | {props.keys.print("host")} host stack
+ </text>
+ <text fg={skin.muted}>
+ local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
+ close
+ </text>
+ <text fg={skin.muted}>{props.keys.print("home")} returns home</text>
+ </box>
+ ) : null}
+ </box>
+
+ <box flexDirection="row" gap={1} paddingTop={1}>
+ <Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
+ <Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
+ <Btn txt="local overlay" run={show} skin={skin} />
+ <Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
+ <Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
+ <Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
+ <Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
+ <Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
+ </box>
+ </box>
+
+ <box
+ visible={value.local > 0}
+ width={dim().width}
+ height={dim().height}
+ alignItems="center"
+ position="absolute"
+ zIndex={3000}
+ paddingTop={dim().height / 4}
+ left={0}
+ top={0}
+ backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
+ onMouseUp={() => {
+ pop()
+ }}
+ >
+ <box
+ onMouseUp={(evt) => {
+ evt.stopPropagation()
+ }}
+ width={60}
+ maxWidth={dim().width - 2}
+ backgroundColor={skin.panel}
+ border
+ borderColor={skin.border}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ gap={1}
+ flexDirection="column"
+ >
+ <text fg={skin.text}>
+ <b>{props.input.label} local overlay</b>
+ </text>
+ <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
+ <text fg={skin.muted}>
+ {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
+ </text>
+ <box flexDirection="row" gap={1}>
+ <Btn txt="push" run={push} skin={skin} on />
+ <Btn txt="pop" run={pop} skin={skin} />
+ </box>
+ </box>
+ </box>
+ </box>
+ )
+}
+
+const Modal = (props: {
+ api: TuiPluginApi
+ input: Cfg
+ route: Route
+ keys: Keys
+ params?: Record<string, unknown>
+}) => {
+ const Dialog = props.api.ui.Dialog
+ const value = parse(props.params)
+ const skin = tone(props.api)
+
+ useKeyboard((evt) => {
+ if (props.api.route.current.name !== props.route.modal) return
+
+ if (props.keys.match("modal_accept", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
+ return
+ }
+
+ if (props.keys.match("modal_close", evt)) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ props.api.route.navigate("home")
+ }
+ })
+
+ return (
+ <box width="100%" height="100%" backgroundColor={skin.panel}>
+ <Dialog onClose={() => props.api.route.navigate("home")}>
+ <box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
+ <text fg={skin.text}>
+ <b>{props.input.label} modal</b>
+ </text>
+ <text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
+ <text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
+ <text fg={skin.muted}>
+ {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
+ </text>
+ <box flexDirection="row" gap={1}>
+ <Btn
+ txt="open screen"
+ run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
+ skin={skin}
+ on
+ />
+ <Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
+ </box>
+ </box>
+ </Dialog>
+ </box>
+ )
+}
+
+const home = (input: Cfg): TuiSlotPlugin => ({
+ slots: {
+ home_logo(ctx) {
+ const map = ctx.theme.current
+ const skin = look(map)
+ const art = [
+ " $$\\",
+ " $$ |",
+ " $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
+ "$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
+ "\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
+ " \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
+ "$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
+ "\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
+ ]
+ const fill = [
+ skin.accent,
+ skin.muted,
+ ink(map, "info", ui.accent),
+ skin.text,
+ ink(map, "success", ui.accent),
+ ink(map, "warning", ui.accent),
+ ink(map, "secondary", ui.accent),
+ ink(map, "error", ui.accent),
+ ]
+
+ return (
+ <box flexDirection="column">
+ {art.map((line, i) => (
+ <text fg={fill[i]}>{line}</text>
+ ))}
+ </box>
+ )
+ },
+ home_bottom(ctx) {
+ const skin = look(ctx.theme.current)
+ const text = "extra content in the unified home bottom slot"
+
+ return (
+ <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
+ <box
+ border
+ borderColor={skin.border}
+ backgroundColor={skin.panel}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ width="100%"
+ >
+ <text fg={skin.muted}>
+ <span style={{ fg: skin.accent }}>{input.label}</span> {text}
+ </text>
+ </box>
+ </box>
+ )
+ },
+ },
+})
+
+const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
+ order,
+ slots: {
+ sidebar_content(ctx, value) {
+ const skin = look(ctx.theme.current)
+
+ return (
+ <box
+ border
+ borderColor={skin.border}
+ backgroundColor={skin.panel}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ flexDirection="column"
+ gap={1}
+ >
+ <text fg={skin.accent}>
+ <b>{title}</b>
+ </text>
+ <text fg={skin.text}>{text}</text>
+ <text fg={skin.muted}>
+ {input.label} order {order} · session {value.session_id.slice(0, 8)}
+ </text>
+ </box>
+ )
+ },
+ },
+})
+
+const slot = (input: Cfg): TuiSlotPlugin[] => [
+ home(input),
+ block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
+ block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
+ block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
+]
+
+const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
+ const route = names(input)
+ api.command.register(() => [
+ {
+ title: `${input.label} modal`,
+ value: "plugin.smoke.modal",
+ keybind: keys.get("modal"),
+ category: "Plugin",
+ slash: {
+ name: "smoke",
+ },
+ onSelect: () => {
+ api.route.navigate(route.modal, { source: "command" })
+ },
+ },
+ {
+ title: `${input.label} screen`,
+ value: "plugin.smoke.screen",
+ keybind: keys.get("screen"),
+ category: "Plugin",
+ slash: {
+ name: "smoke-screen",
+ },
+ onSelect: () => {
+ api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
+ },
+ },
+ {
+ title: `${input.label} alert dialog`,
+ value: "plugin.smoke.alert",
+ category: "Plugin",
+ slash: {
+ name: "smoke-alert",
+ },
+ onSelect: () => {
+ warn(api, route, current(api, route))
+ },
+ },
+ {
+ title: `${input.label} confirm dialog`,
+ value: "plugin.smoke.confirm",
+ category: "Plugin",
+ slash: {
+ name: "smoke-confirm",
+ },
+ onSelect: () => {
+ check(api, route, current(api, route))
+ },
+ },
+ {
+ title: `${input.label} prompt dialog`,
+ value: "plugin.smoke.prompt",
+ category: "Plugin",
+ slash: {
+ name: "smoke-prompt",
+ },
+ onSelect: () => {
+ entry(api, route, current(api, route))
+ },
+ },
+ {
+ title: `${input.label} select dialog`,
+ value: "plugin.smoke.select",
+ category: "Plugin",
+ slash: {
+ name: "smoke-select",
+ },
+ onSelect: () => {
+ picker(api, route, current(api, route))
+ },
+ },
+ {
+ title: `${input.label} host overlay`,
+ value: "plugin.smoke.host",
+ category: "Plugin",
+ slash: {
+ name: "smoke-host",
+ },
+ onSelect: () => {
+ host(api, input, tone(api))
+ },
+ },
+ {
+ title: `${input.label} go home`,
+ value: "plugin.smoke.home",
+ category: "Plugin",
+ enabled: api.route.current.name !== "home",
+ onSelect: () => {
+ api.route.navigate("home")
+ },
+ },
+ {
+ title: `${input.label} toast`,
+ value: "plugin.smoke.toast",
+ category: "Plugin",
+ onSelect: () => {
+ api.ui.toast({
+ variant: "info",
+ title: "Smoke",
+ message: "Plugin toast works",
+ duration: 2000,
+ })
+ },
+ },
+ ])
+}
+
+const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
+ if (options?.enabled === false) return
+
+ await api.theme.install("./smoke-theme.json")
+ api.theme.set("smoke-theme")
+
+ const value = cfg(options ?? undefined)
+ const route = names(value)
+ const keys = api.keybind.create(bind, value.keybinds)
+ const fx = new VignetteEffect(value.vignette)
+ const post = fx.apply.bind(fx)
+ api.renderer.addPostProcessFn(post)
+ api.lifecycle.onDispose(() => {
+ api.renderer.removePostProcessFn(post)
+ })
+
+ api.route.register([
+ {
+ name: route.screen,
+ render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
+ },
+ {
+ name: route.modal,
+ render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
+ },
+ ])
+
+ reg(api, value, keys)
+ for (const item of slot(value)) {
+ api.slots.register(item)
+ }
+}
+
+export default {
+ id: "tui-smoke",
+ tui,
+}
diff --git a/.opencode/themes/.gitignore b/.opencode/themes/.gitignore
new file mode 100644
index 000000000..5b41319c6
--- /dev/null
+++ b/.opencode/themes/.gitignore
@@ -0,0 +1 @@
+smoke-theme.json
diff --git a/.opencode/tui.json b/.opencode/tui.json
new file mode 100644
index 000000000..f228c2088
--- /dev/null
+++ b/.opencode/tui.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://opencode.ai/tui.json",
+ "theme": "smoke-theme",
+ "plugin": [
+ [
+ "./plugins/tui-smoke.tsx",
+ {
+ "enabled": false,
+ "label": "workspace",
+ "keybinds": {
+ "modal": "ctrl+alt+m",
+ "screen": "ctrl+alt+o",
+ "home": "escape,ctrl+shift+h",
+ "dialog_close": "escape,q"
+ }
+ }
+ ]
+ ]
+}
diff --git a/bun.lock b/bun.lock
index 54e1c768d..1ff4b4f72 100644
--- a/bun.lock
+++ b/bun.lock
@@ -428,11 +428,21 @@
"zod": "catalog:",
},
"devDependencies": {
+ "@opentui/core": "0.1.90",
+ "@opentui/solid": "0.1.90",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
+ "peerDependencies": {
+ "@opentui/core": ">=0.1.90",
+ "@opentui/solid": ">=0.1.90",
+ },
+ "optionalPeers": [
+ "@opentui/core",
+ "@opentui/solid",
+ ],
},
"packages/script": {
"name": "@opencode-ai/script",
@@ -3837,7 +3847,7 @@
"pagefind": ["[email protected]", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
- "pako": ["[email protected]", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+ "pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"param-case": ["[email protected]", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -5677,12 +5687,12 @@
"type-is/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "unicode-trie/pako": ["[email protected]", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+
"unifont/ofetch": ["[email protected]", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
- "utif2/pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
-
"vite-plugin-icons-spritesheet/glob": ["[email protected]", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index aaf9f58d6..0f6a1c135 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
- const plugins = createMemo(() => sync.data.config.plugin ?? [])
+ const plugins = createMemo(() =>
+ (sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
+ )
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml
index c3b727076..33b39f719 100644
--- a/packages/opencode/bunfig.toml
+++ b/packages/opencode/bunfig.toml
@@ -1,7 +1,7 @@
preload = ["@opentui/solid/preload"]
[test]
-preload = ["./test/preload.ts"]
+preload = ["@opentui/solid/preload", "./test/preload.ts"]
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index 734181076..b104dd267 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
-import solidPlugin from "@opentui/solid/bun-plugin"
+import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -63,6 +63,7 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
+const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const createEmbeddedWebUIBundle = async () => {
@@ -207,7 +208,7 @@ for (const item of targets) {
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
- plugins: [solidPlugin],
+ plugins: [plugin],
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
new file mode 100644
index 000000000..1a7ba55a0
--- /dev/null
+++ b/packages/opencode/specs/tui-plugins.md
@@ -0,0 +1,377 @@
+# TUI plugins
+
+Technical reference for the current TUI plugin system.
+
+## Overview
+
+- TUI plugin config lives in `tui.json`.
+- Author package entrypoint is `@opencode-ai/plugin/tui`.
+- Internal plugins load inside the CLI app the same way external TUI plugins do.
+- Package plugins can be installed from CLI or TUI.
+
+## TUI config
+
+Example:
+
+```json
+{
+ "$schema": "https://opencode.ai/tui.json",
+ "theme": "smoke-theme",
+ "plugin": ["@acme/[email protected]", ["./plugins/demo.tsx", { "label": "demo" }]],
+ "plugin_enabled": {
+ "acme.demo": false
+ }
+}
+```
+
+- `plugin` entries can be either a string spec or `[spec, options]`.
+- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
+- Relative path specs are resolved relative to the config file that declared them.
+- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
+- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
+- `plugin_enabled` is keyed by plugin id, not by plugin spec.
+- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
+- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
+- `plugin_enabled` is merged across config layers.
+- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
+
+## Author package shape
+
+Package entrypoint:
+
+- Import types from `@opencode-ai/plugin/tui`.
+- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`.
+
+Minimal module shape:
+
+```tsx
+/** @jsxImportSource @opentui/solid */
+import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+
+const tui: TuiPlugin = async (api, options, meta) => {
+ api.command.register(() => [
+ {
+ title: "Demo",
+ value: "demo.open",
+ onSelect: () => api.route.navigate("demo"),
+ },
+ ])
+
+ api.route.register([
+ {
+ name: "demo",
+ render: () => (
+ <box>
+ <text>demo</text>
+ </box>
+ ),
+ },
+ ])
+}
+
+export default {
+ id: "acme.demo",
+ tui,
+}
+```
+
+- Loader only reads the module default export object. Named exports are ignored.
+- TUI shape is `default export { id?, tui }`.
+- `tui` signature is `(api, options, meta) => Promise<void>`.
+- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
+- File/path plugins must export a non-empty `id`.
+- npm plugins may omit `id`; package `name` is used.
+- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
+- If a path spec points at a directory, that directory must have `package.json` with `main`.
+- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
+
+## Package manifest and install
+
+Package manifest is read from `package.json` field `oc-plugin`.
+
+Example:
+
+```json
+{
+ "name": "@acme/opencode-plugin",
+ "type": "module",
+ "main": "./dist/index.js",
+ "engines": {
+ "opencode": "^1.0.0"
+ },
+ "oc-plugin": [
+ ["server", { "custom": true }],
+ ["tui", { "compact": true }]
+ ]
+}
+```
+
+### Version compatibility
+
+npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
+
+```json
+{
+ "engines": {
+ "opencode": "^1.0.0"
+ }
+}
+```
+
+- The value is a semver range checked against the running OpenCode version.
+- If the range is not satisfied, the plugin is skipped with a warning and a session error.
+- If `engines.opencode` is absent, no check is performed (backward compatible).
+- File plugins are never checked; only npm package plugins are validated.
+
+- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
+- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
+- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
+- Alias: `opencode plug <module>`.
+- `-g` / `--global` writes into the global config dir.
+- Local installs resolve target dir inside `patchPluginConfig`.
+- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
+- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
+- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
+- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
+- Without `--force`, an already-configured npm package name is a no-op.
+- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
+- Tuple targets in `oc-plugin` provide default options written into config.
+- A package can target `server`, `tui`, or both.
+- There is no uninstall, list, or update CLI command for external plugins.
+- Local file plugins are configured directly in `tui.json`.
+
+When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes:
+
+- `package.json`
+- `bun.lock`
+- `node_modules/`
+- `.gitignore`
+
+That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`.
+
+## TUI plugin API
+
+Top-level API groups exposed to `tui(api, options, meta)`:
+
+- `api.app.version`
+- `api.command.register(cb)` / `api.command.trigger(value)`
+- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
+- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
+- `api.keybind.match`, `print`, `create`
+- `api.tuiConfig`
+- `api.kv.get`, `set`, `ready`
+- `api.state`
+- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
+- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
+- `api.event.on(type, handler)`
+- `api.renderer`
+- `api.slots.register(plugin)`
+- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
+- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
+
+### Commands
+
+`api.command.register` returns an unregister function. Command rows support:
+
+- `title`, `value`
+- `description`, `category`
+- `keybind`
+- `suggested`, `hidden`, `enabled`
+- `slash: { name, aliases? }`
+- `onSelect`
+
+Command behavior:
+
+- Registrations are reactive.
+- Later registrations win for duplicate `value` and for keybind handling.
+- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
+
+### Routes
+
+- Reserved route names: `home` and `session`.
+- Any other name is treated as a plugin route.
+- `api.route.current` returns one of:
+ - `{ name: "home" }`
+ - `{ name: "session", params: { sessionID, initialPrompt? } }`
+ - `{ name: string, params?: Record<string, unknown> }`
+- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`.
+- If multiple plugins register the same route name, the last registered route wins.
+- Unknown plugin routes render a fallback screen with a `go home` action.
+
+### Dialogs and toast
+
+- `ui.Dialog` is the base dialog wrapper.
+- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
+- `ui.toast(...)` shows a toast.
+- `ui.dialog` exposes the host dialog stack:
+ - `replace(render, onClose?)`
+ - `clear()`
+ - `setSize("medium" | "large" | "xlarge")`
+ - readonly `size`, `depth`, `open`
+
+### Keybinds
+
+- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
+- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
+- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
+- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
+
+### KV, state, client, events
+
+- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
+- `api.kv` exposes `ready`.
+- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots.
+- `api.state` exposes synced TUI state:
+ - `ready`
+ - `config`
+ - `provider`
+ - `path.{state,config,worktree,directory}`
+ - `vcs?.branch`
+ - `workspace.list()` / `workspace.get(workspaceID)`
+ - `session.count()`
+ - `session.diff(sessionID)`
+ - `session.todo(sessionID)`
+ - `session.messages(sessionID)`
+ - `session.status(sessionID)`
+ - `session.permission(sessionID)`
+ - `session.question(sessionID)`
+ - `part(messageID)`
+ - `lsp()`
+ - `mcp()`
+- `api.client` always reflects the current runtime client.
+- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
+- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
+- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
+- `api.renderer` exposes the raw `CliRenderer`.
+
+### Theme
+
+- `api.theme.current` exposes the resolved current theme tokens.
+- `api.theme.selected` is the selected theme name.
+- `api.theme.has(name)` checks for an installed theme.
+- `api.theme.set(name)` switches theme and returns `boolean`.
+- `api.theme.mode()` returns `"dark" | "light"`.
+- `api.theme.install(jsonPath)` installs a theme JSON file.
+- `api.theme.ready` reports theme readiness.
+
+Theme install behavior:
+
+- Relative theme paths are resolved from the plugin root.
+- Theme name is the JSON basename.
+- Install is skipped if that theme name already exists.
+- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
+- Global plugins persist installed themes under the global `themes` dir.
+- Invalid or unreadable theme files are ignored.
+
+### Slots
+
+Current host slot names:
+
+- `app`
+- `home_logo`
+- `home_bottom`
+- `sidebar_title` with props `{ session_id, title, share_url? }`
+- `sidebar_content` with props `{ session_id }`
+- `sidebar_footer` with props `{ session_id }`
+
+Slot notes:
+
+- Slot context currently exposes only `theme`.
+- `api.slots.register(plugin)` returns the host-assigned slot plugin id.
+- `api.slots.register(plugin)` does not return an unregister function.
+- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
+- Plugin-provided `id` is not allowed.
+- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
+- Plugins cannot define new slot names in this branch.
+
+### Plugin control and lifecycle
+
+- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`.
+- `enabled` is the persisted desired state. `active` means the plugin is currently initialized.
+- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin.
+- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope.
+- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
+- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
+- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
+- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
+- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
+- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
+- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
+- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
+- If activation fails, the plugin can remain `enabled=true` and `active=false`.
+- `api.lifecycle.signal` is aborted before cleanup runs.
+- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
+
+## Plugin metadata
+
+`meta` passed to `tui(api, options, meta)` contains:
+
+- `state`: `first | updated | same`
+- `id`, `source`, `spec`, `target`
+- npm-only fields when available: `requested`, `version`
+- file-only field when available: `modified`
+- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint`
+
+Metadata is persisted by plugin id.
+
+- File plugin fingerprint is `target|modified`.
+- npm plugin fingerprint is `target|requested|version`.
+- Internal plugins get synthetic metadata with `state: "same"`.
+
+## Runtime behavior
+
+- Internal TUI plugins load first.
+- External TUI plugins load from `tuiConfig.plugin`.
+- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
+- External plugin resolution and import are parallel.
+- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
+- File plugins that fail initially are retried once after waiting for config dependency installation.
+- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
+- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
+- Runtime install and runtime add are separate operations.
+- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
+- TUI runtime tracks and disposes:
+ - command registrations
+ - route registrations
+ - event subscriptions
+ - slot registrations
+ - explicit `lifecycle.onDispose(...)` handlers
+- Cleanup runs in reverse order.
+- Cleanup is awaited.
+- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
+
+## Built-in plugins
+
+- `internal:home-tips`
+- `internal:sidebar-context`
+- `internal:sidebar-mcp`
+- `internal:sidebar-lsp`
+- `internal:sidebar-todo`
+- `internal:sidebar-files`
+- `internal:sidebar-footer`
+- `internal:plugin-manager`
+
+Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
+
+The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.
+
+- Keybind name is `plugin_manager`.
+- Default keybind is `none`.
+- It lists both internal and external plugins.
+- It toggles based on `active`.
+- Its own row is disabled only inside the manager dialog.
+- It also exposes command `plugins.install` with title `Install plugin`.
+- Inside the Plugins dialog, key `shift+i` opens the install prompt.
+- Install prompt asks for npm package name.
+- Scope defaults to local, and `tab` toggles local/global.
+- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
+- Manager install uses `api.plugins.install(spec, { global })`.
+- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
+- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
+- If runtime add fails, TUI shows a warning and restart remains the fallback.
+
+## Current in-repo examples
+
+- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
+- Local smoke config: `.opencode/tui.json`
+- Local smoke theme: `.opencode/plugins/smoke-theme.json`
diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts
index d6c453825..dbdf5a2bc 100644
--- a/packages/opencode/src/bun/index.ts
+++ b/packages/opencode/src/bun/index.ts
@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
-import { proxied } from "@/util/proxied"
+import { online, proxied } from "@/util/network"
import { Process } from "../util/process"
export namespace BunProc {
@@ -68,12 +68,13 @@ export namespace BunProc {
if (!modExists || !cachedVersion) {
// continue to install
- } else if (version !== "latest" && cachedVersion === version) {
- return mod
} else if (version === "latest") {
- const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
- if (!isOutdated) return mod
+ if (!online()) return mod
+ const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
+ if (!stale) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
+ } else if (cachedVersion === version) {
+ return mod
}
// Build command arguments
diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts
index e43e20e6c..dead5e74d 100644
--- a/packages/opencode/src/bun/registry.ts
+++ b/packages/opencode/src/bun/registry.ts
@@ -1,6 +1,7 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
+import { online } from "@/util/network"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
@@ -10,6 +11,11 @@ export namespace PackageRegistry {
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
+ if (!online()) {
+ log.debug("offline, skipping bun info", { pkg, field })
+ return null
+ }
+
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
cwd,
env: {
diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts
index 8ca4b9a42..03e765dab 100644
--- a/packages/opencode/src/cli/cmd/db.ts
+++ b/packages/opencode/src/cli/cmd/db.ts
@@ -6,6 +6,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "../../storage/json-migration"
import { EOL } from "os"
+import { errorMessage } from "../../util/error"
const QueryCommand = cmd({
command: "$0 [query]",
@@ -39,7 +40,7 @@ const QueryCommand = cmd({
}
}
} catch (err) {
- UI.error(err instanceof Error ? err.message : String(err))
+ UI.error(errorMessage(err))
process.exit(1)
}
db.close()
@@ -100,7 +101,7 @@ const MigrateCommand = cmd({
}
} catch (err) {
if (tty) process.stderr.write("\x1b[?25h")
- UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
+ UI.error(`Migration failed: ${errorMessage(err)}`)
process.exit(1)
} finally {
sqlite.close()
diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts
new file mode 100644
index 000000000..ae2ea4ffd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/plug.ts
@@ -0,0 +1,231 @@
+import { intro, log, outro, spinner } from "@clack/prompts"
+import type { Argv } from "yargs"
+
+import { ConfigPaths } from "../../config/paths"
+import { Global } from "../../global"
+import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
+import { resolvePluginTarget } from "../../plugin/shared"
+import { Instance } from "../../project/instance"
+import { errorMessage } from "../../util/error"
+import { Filesystem } from "../../util/filesystem"
+import { Process } from "../../util/process"
+import { UI } from "../ui"
+import { cmd } from "./cmd"
+
+type Spin = {
+ start: (msg: string) => void
+ stop: (msg: string, code?: number) => void
+}
+
+export type PlugDeps = {
+ spinner: () => Spin
+ log: {
+ error: (msg: string) => void
+ info: (msg: string) => void
+ success: (msg: string) => void
+ }
+ resolve: (spec: string) => Promise<string>
+ readText: (file: string) => Promise<string>
+ write: (file: string, text: string) => Promise<void>
+ exists: (file: string) => Promise<boolean>
+ files: (dir: string, name: "opencode" | "tui") => string[]
+ global: string
+}
+
+export type PlugInput = {
+ mod: string
+ global?: boolean
+ force?: boolean
+}
+
+export type PlugCtx = {
+ vcs?: string
+ worktree: string
+ directory: string
+}
+
+const defaultPlugDeps: PlugDeps = {
+ spinner: () => spinner(),
+ log: {
+ error: (msg) => log.error(msg),
+ info: (msg) => log.info(msg),
+ success: (msg) => log.success(msg),
+ },
+ resolve: (spec) => resolvePluginTarget(spec),
+ readText: (file) => Filesystem.readText(file),
+ write: async (file, text) => {
+ await Filesystem.write(file, text)
+ },
+ exists: (file) => Filesystem.exists(file),
+ files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
+ global: Global.Path.config,
+}
+
+function cause(err: unknown) {
+ if (!err || typeof err !== "object") return
+ if (!("cause" in err)) return
+ return (err as { cause?: unknown }).cause
+}
+
+export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
+ const mod = input.mod
+ const force = Boolean(input.force)
+ const global = Boolean(input.global)
+
+ return async (ctx: PlugCtx) => {
+ const install = dep.spinner()
+ install.start("Installing plugin package...")
+ const target = await installPlugin(mod, dep)
+ if (!target.ok) {
+ install.stop("Install failed", 1)
+ dep.log.error(`Could not install "${mod}"`)
+ const hit = cause(target.error) ?? target.error
+ if (hit instanceof Process.RunFailedError) {
+ const lines = hit.stderr
+ .toString()
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
+ const detail = errs[0] ?? lines.at(-1)
+ if (detail) dep.log.error(detail)
+ if (lines.some((line) => line.includes("No version matching"))) {
+ dep.log.info("This package depends on a version that is not available in your npm registry.")
+ dep.log.info("Check npm registry/auth settings and try again.")
+ }
+ }
+ if (!(hit instanceof Process.RunFailedError)) {
+ dep.log.error(errorMessage(hit))
+ }
+ return false
+ }
+ install.stop("Plugin package ready")
+
+ const inspect = dep.spinner()
+ inspect.start("Reading plugin manifest...")
+ const manifest = await readPluginManifest(target.target)
+ if (!manifest.ok) {
+ if (manifest.code === "manifest_read_failed") {
+ inspect.stop("Manifest read failed", 1)
+ dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
+ dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
+ return false
+ }
+
+ if (manifest.code === "manifest_no_targets") {
+ inspect.stop("No plugin targets found", 1)
+ dep.log.error(`"${mod}" does not declare supported targets in package.json`)
+ dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
+ return false
+ }
+
+ inspect.stop("Manifest read failed", 1)
+ return false
+ }
+
+ inspect.stop(
+ `Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
+ )
+
+ const patch = dep.spinner()
+ patch.start("Updating plugin config...")
+ const out = await patchPluginConfig(
+ {
+ spec: mod,
+ targets: manifest.targets,
+ force,
+ global,
+ vcs: ctx.vcs,
+ worktree: ctx.worktree,
+ directory: ctx.directory,
+ config: dep.global,
+ },
+ dep,
+ )
+ if (!out.ok) {
+ if (out.code === "invalid_json") {
+ patch.stop(`Failed updating ${out.kind} config`, 1)
+ dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
+ dep.log.info("Fix the config file and run the command again.")
+ return false
+ }
+
+ patch.stop("Failed updating plugin config", 1)
+ dep.log.error(errorMessage(out.error))
+ return false
+ }
+ patch.stop("Plugin config updated")
+ for (const item of out.items) {
+ if (item.mode === "noop") {
+ dep.log.info(`Already configured in ${item.file}`)
+ continue
+ }
+ if (item.mode === "replace") {
+ dep.log.info(`Replaced in ${item.file}`)
+ continue
+ }
+ dep.log.info(`Added to ${item.file}`)
+ }
+
+ dep.log.success(`Installed ${mod}`)
+ dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
+ return true
+ }
+}
+
+export const PluginCommand = cmd({
+ command: "plugin <module>",
+ aliases: ["plug"],
+ describe: "install plugin and update config",
+ builder: (yargs: Argv) => {
+ return yargs
+ .positional("module", {
+ type: "string",
+ describe: "npm module name",
+ })
+ .option("global", {
+ alias: ["g"],
+ type: "boolean",
+ default: false,
+ describe: "install in global config",
+ })
+ .option("force", {
+ alias: ["f"],
+ type: "boolean",
+ default: false,
+ describe: "replace existing plugin version",
+ })
+ },
+ handler: async (args) => {
+ const mod = String(args.module ?? "").trim()
+ if (!mod) {
+ UI.error("module is required")
+ process.exitCode = 1
+ return
+ }
+
+ UI.empty()
+ intro(`Install plugin ${mod}`)
+
+ const run = createPlugTask({
+ mod,
+ global: Boolean(args.global),
+ force: Boolean(args.force),
+ })
+ let ok = true
+
+ await Instance.provide({
+ directory: process.cwd(),
+ fn: async () => {
+ ok = await run({
+ vcs: Instance.project.vcs,
+ worktree: Instance.worktree,
+ directory: Instance.directory,
+ })
+ },
+ })
+
+ outro("Done")
+ if (!ok) process.exitCode = 1
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4897cb7e8..2557d965a 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -1,15 +1,30 @@
-import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
-import { MouseButton, TextAttributes } from "@opentui/core"
+import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
-import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
-import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
+import {
+ Switch,
+ Match,
+ createEffect,
+ createMemo,
+ ErrorBoundary,
+ createSignal,
+ onMount,
+ batch,
+ Show,
+ on,
+ onCleanup,
+} from "solid-js"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
+import { ErrorComponent } from "@tui/component/error-component"
+import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { SDKProvider, useSDK } from "@tui/context/sdk"
+import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
@@ -21,7 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
-import { KeybindProvider } from "@tui/context/keybind"
+import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@@ -40,8 +55,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
-import { TuiConfigProvider } from "./context/tui-config"
+import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
+import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
+import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,7 +121,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}
import type { EventSource } from "./context/sdk"
-import { Installation } from "@/installation"
+
+function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
+ return {
+ targetFps: 60,
+ gatherStats: false,
+ exitOnCtrlC: false,
+ useKittyKeyboard: { events: process.platform === "win32" },
+ autoFocus: false,
+ openConsoleOnError: false,
+ consoleOptions: {
+ keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
+ onCopySelection: (text) => {
+ Clipboard.copy(text).catch((error) => {
+ console.error(`Failed to copy console selection to clipboard: ${error}`)
+ })
+ },
+ },
+ }
+}
+
+function errorMessage(error: unknown) {
+ const formatted = FormatError(error)
+ if (formatted !== undefined) return formatted
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "data" in error &&
+ typeof error.data === "object" &&
+ error.data !== null &&
+ "message" in error.data &&
+ typeof error.data.message === "string"
+ ) {
+ return error.data.message
+ }
+ return FormatUnknownError(error)
+}
export function tui(input: {
url: string
@@ -132,77 +184,68 @@ export function tui(input: {
resolve()
}
- render(
- () => {
- return (
- <ErrorBoundary
- fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
- >
- <ArgsProvider {...input.args}>
- <ExitProvider onExit={onExit}>
- <KVProvider>
- <ToastProvider>
- <RouteProvider>
- <TuiConfigProvider config={input.config}>
- <SDKProvider
- url={input.url}
- directory={input.directory}
- fetch={input.fetch}
- headers={input.headers}
- events={input.events}
- >
- <SyncProvider>
- <ThemeProvider mode={mode}>
- <LocalProvider>
- <KeybindProvider>
- <PromptStashProvider>
- <DialogProvider>
- <CommandProvider>
- <FrecencyProvider>
- <PromptHistoryProvider>
- <PromptRefProvider>
- <App onSnapshot={input.onSnapshot} />
- </PromptRefProvider>
- </PromptHistoryProvider>
- </FrecencyProvider>
- </CommandProvider>
- </DialogProvider>
- </PromptStashProvider>
- </KeybindProvider>
- </LocalProvider>
- </ThemeProvider>
- </SyncProvider>
- </SDKProvider>
- </TuiConfigProvider>
- </RouteProvider>
- </ToastProvider>
- </KVProvider>
- </ExitProvider>
- </ArgsProvider>
- </ErrorBoundary>
- )
- },
- {
- targetFps: 60,
- gatherStats: false,
- exitOnCtrlC: false,
- useKittyKeyboard: { events: process.platform === "win32" },
- autoFocus: false,
- openConsoleOnError: false,
- consoleOptions: {
- keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
- onCopySelection: (text) => {
- Clipboard.copy(text).catch((error) => {
- console.error(`Failed to copy console selection to clipboard: ${error}`)
- })
- },
- },
- },
- )
+ const onBeforeExit = async () => {
+ await TuiPluginRuntime.dispose()
+ }
+
+ const renderer = await createCliRenderer(rendererConfig(input.config))
+
+ await render(() => {
+ return (
+ <ErrorBoundary
+ fallback={(error, reset) => (
+ <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
+ )}
+ >
+ <ArgsProvider {...input.args}>
+ <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
+ <KVProvider>
+ <ToastProvider>
+ <RouteProvider>
+ <TuiConfigProvider config={input.config}>
+ <SDKProvider
+ url={input.url}
+ directory={input.directory}
+ fetch={input.fetch}
+ headers={input.headers}
+ events={input.events}
+ >
+ <SyncProvider>
+ <ThemeProvider mode={mode}>
+ <LocalProvider>
+ <KeybindProvider>
+ <PromptStashProvider>
+ <DialogProvider>
+ <CommandProvider>
+ <FrecencyProvider>
+ <PromptHistoryProvider>
+ <PromptRefProvider>
+ <App onSnapshot={input.onSnapshot} />
+ </PromptRefProvider>
+ </PromptHistoryProvider>
+ </FrecencyProvider>
+ </CommandProvider>
+ </DialogProvider>
+ </PromptStashProvider>
+ </KeybindProvider>
+ </LocalProvider>
+ </ThemeProvider>
+ </SyncProvider>
+ </SDKProvider>
+ </TuiConfigProvider>
+ </RouteProvider>
+ </ToastProvider>
+ </KVProvider>
+ </ExitProvider>
+ </ArgsProvider>
+ </ErrorBoundary>
+ )
+ }, renderer)
})
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {
+ const tuiConfig = useTuiConfig()
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -211,12 +254,47 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
+ const keybind = useKeybind()
const sdk = useSDK()
const toast = useToast()
- const { theme, mode, setMode, locked, lock, unlock } = useTheme()
+ const themeState = useTheme()
+ const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
+ const routes: RouteMap = new Map()
+ const [routeRev, setRouteRev] = createSignal(0)
+ const routeView = (name: string) => {
+ routeRev()
+ return routes.get(name)?.at(-1)?.render
+ }
+
+ const api = createTuiApi({
+ command,
+ tuiConfig,
+ dialog,
+ keybind,
+ kv,
+ route,
+ routes,
+ bump: () => setRouteRev((x) => x + 1),
+ sdk,
+ sync,
+ theme: themeState,
+ toast,
+ renderer,
+ })
+ onCleanup(() => {
+ api.dispose()
+ })
+ const [ready, setReady] = createSignal(false)
+ TuiPluginRuntime.init(api)
+ .catch((error) => {
+ console.error("Failed to load TUI plugins", error)
+ })
+ .finally(() => {
+ setReady(true)
+ })
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -259,10 +337,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
- createEffect(() => {
- console.log(JSON.stringify(route.data))
- })
-
// Update terminal window title based on current route and session
createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@@ -279,9 +353,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return
}
- // Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
+ return
+ }
+
+ if (route.data.type === "plugin") {
+ renderer.setTerminalTitle(`OC | ${route.data.id}`)
}
})
@@ -723,17 +801,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
sdk.event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
- const message = (() => {
- if (!error) return "An error occurred"
-
- if (typeof error === "object") {
- const data = error.data
- if ("message" in data && typeof data.message === "string") {
- return data.message
- }
- }
- return String(error)
- })()
+ const message = errorMessage(error)
toast.show({
variant: "error",
@@ -789,6 +857,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
exit()
})
+ const plugin = createMemo(() => {
+ if (!ready()) return
+ if (route.data.type !== "plugin") return
+ const render = routeView(route.data.id)
+ if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
+ return render({ params: route.data.data })
+ })
+
return (
<box
width={dimensions().width}
@@ -804,97 +880,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
- <Switch>
- <Match when={route.data.type === "home"}>
- <Home />
- </Match>
- <Match when={route.data.type === "session"}>
- <Session />
- </Match>
- </Switch>
- </box>
- )
-}
-
-function ErrorComponent(props: {
- error: Error
- reset: () => void
- onExit: () => Promise<void>
- mode?: "dark" | "light"
-}) {
- const term = useTerminalDimensions()
- const renderer = useRenderer()
-
- const handleExit = async () => {
- renderer.setTerminalTitle("")
- renderer.destroy()
- win32FlushInputBuffer()
- await props.onExit()
- }
-
- useKeyboard((evt) => {
- if (evt.ctrl && evt.name === "c") {
- handleExit()
- }
- })
- const [copied, setCopied] = createSignal(false)
-
- const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
-
- // Choose safe fallback colors per mode since theme context may not be available
- const isLight = props.mode === "light"
- const colors = {
- bg: isLight ? "#ffffff" : "#0a0a0a",
- text: isLight ? "#1a1a1a" : "#eeeeee",
- muted: isLight ? "#8a8a8a" : "#808080",
- primary: isLight ? "#3b7dd8" : "#fab283",
- }
-
- if (props.error.message) {
- issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
- }
-
- if (props.error.stack) {
- issueURL.searchParams.set(
- "description",
- "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
- )
- }
-
- issueURL.searchParams.set("opencode-version", Installation.VERSION)
-
- const copyIssueURL = () => {
- Clipboard.copy(issueURL.toString()).then(() => {
- setCopied(true)
- })
- }
-
- return (
- <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
- <box flexDirection="row" gap={1} alignItems="center">
- <text attributes={TextAttributes.BOLD} fg={colors.text}>
- Please report an issue.
- </text>
- <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
- <text attributes={TextAttributes.BOLD} fg={colors.bg}>
- Copy issue URL (exception info pre-filled)
- </text>
- </box>
- {copied() && <text fg={colors.muted}>Successfully copied</text>}
- </box>
- <box flexDirection="row" gap={2} alignItems="center">
- <text fg={colors.text}>A fatal error occurred!</text>
- <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
- <text fg={colors.bg}>Reset TUI</text>
- </box>
- <box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
- <text fg={colors.bg}>Exit</text>
- </box>
- </box>
- <scrollbox height={Math.floor(term().height * 0.7)}>
- <text fg={colors.muted}>{props.error.stack}</text>
- </scrollbox>
- <text fg={colors.text}>{props.error.message}</text>
+ <Show when={Flag.OPENCODE_SHOW_TTFD}>
+ <TimeToFirstDraw />
+ </Show>
+ <Show when={ready()}>
+ <Switch>
+ <Match when={route.data.type === "home"}>
+ <Home />
+ </Match>
+ <Match when={route.data.type === "session"}>
+ <Session />
+ </Match>
+ </Switch>
+ </Show>
+ {plugin()}
+ <TuiPluginRuntime.Slot name="app" />
+ <StartupLoading ready={ready} />
</box>
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
index be031296e..f42ba15ec 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
@@ -4,13 +4,15 @@ import {
createContext,
createMemo,
createSignal,
+ getOwner,
onCleanup,
+ runWithOwner,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
-import { type KeybindKey, useKeybind } from "@tui/context/keybind"
+import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@@ -21,7 +23,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
- keybind?: KeybindKey
+ keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean
@@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
}
function init() {
+ const root = getOwner()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
@@ -100,11 +103,32 @@ function init() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setRegistrations((arr) => [results, ...arr])
- onCleanup(() => {
- setRegistrations((arr) => arr.filter((x) => x !== results))
+ const owner = getOwner() ?? root
+ if (!owner) return () => {}
+
+ let list: Accessor<CommandOption[]> | undefined
+
+ // TUI plugins now register commands via an async store that runs outside an active reactive scope.
+ // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
+ runWithOwner(owner, () => {
+ list = createMemo(cb)
+ const ref = list
+ if (!ref) return
+ setRegistrations((arr) => [ref, ...arr])
+ onCleanup(() => {
+ setRegistrations((arr) => arr.filter((x) => x !== ref))
+ })
})
+
+ if (!list) return () => {}
+ let done = false
+ return () => {
+ if (done) return
+ done = true
+ const ref = list
+ if (!ref) return
+ setRegistrations((arr) => arr.filter((x) => x !== ref))
+ }
},
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
index 3b6b5ef21..ebc65a45b 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -16,7 +16,8 @@ export function DialogStatus() {
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
- const result = list.map((value) => {
+ const result = list.map((item) => {
+ const value = typeof item === "string" ? item : item[0]
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
index 09bb492f6..84127b576 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
@@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
-import type { Session } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
+function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
+ return createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: sdk.fetch,
+ directory: sync.data.path.directory || sdk.directory,
+ experimental_workspaceID: workspaceID,
+ })
+}
+
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
@@ -29,12 +37,7 @@ async function openWorkspace(input: {
)
}
- const client = createOpencodeClient({
- baseUrl: input.sdk.url,
- fetch: input.sdk.fetch,
- directory: input.sync.data.path.directory || input.sdk.directory,
- experimental_workspaceID: input.workspaceID,
- })
+ const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
@@ -187,12 +190,7 @@ export function DialogWorkspaceList() {
await open(workspaceID)
return
}
- const client = createOpencodeClient({
- baseUrl: sdk.url,
- fetch: sdk.fetch,
- directory: sync.data.path.directory || sdk.directory,
- experimental_workspaceID: workspaceID,
- })
+ const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
@@ -223,12 +221,7 @@ export function DialogWorkspaceList() {
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
- const client = createOpencodeClient({
- baseUrl: sdk.url,
- fetch: sdk.fetch,
- directory: sync.data.path.directory || sdk.directory,
- experimental_workspaceID: workspace.id,
- })
+ const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx
new file mode 100644
index 000000000..c568e54e4
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx
@@ -0,0 +1,91 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { Clipboard } from "@tui/util/clipboard"
+import { createSignal } from "solid-js"
+import { Installation } from "@/installation"
+import { win32FlushInputBuffer } from "../win32"
+
+export function ErrorComponent(props: {
+ error: Error
+ reset: () => void
+ onBeforeExit?: () => Promise<void>
+ onExit: () => Promise<void>
+ mode?: "dark" | "light"
+}) {
+ const term = useTerminalDimensions()
+ const renderer = useRenderer()
+
+ const handleExit = async () => {
+ await props.onBeforeExit?.()
+ renderer.setTerminalTitle("")
+ renderer.destroy()
+ win32FlushInputBuffer()
+ await props.onExit()
+ }
+
+ useKeyboard((evt) => {
+ if (evt.ctrl && evt.name === "c") {
+ handleExit()
+ }
+ })
+ const [copied, setCopied] = createSignal(false)
+
+ const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
+
+ // Choose safe fallback colors per mode since theme context may not be available
+ const isLight = props.mode === "light"
+ const colors = {
+ bg: isLight ? "#ffffff" : "#0a0a0a",
+ text: isLight ? "#1a1a1a" : "#eeeeee",
+ muted: isLight ? "#8a8a8a" : "#808080",
+ primary: isLight ? "#3b7dd8" : "#fab283",
+ }
+
+ if (props.error.message) {
+ issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
+ }
+
+ if (props.error.stack) {
+ issueURL.searchParams.set(
+ "description",
+ "```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
+ )
+ }
+
+ issueURL.searchParams.set("opencode-version", Installation.VERSION)
+
+ const copyIssueURL = () => {
+ Clipboard.copy(issueURL.toString()).then(() => {
+ setCopied(true)
+ })
+ }
+
+ return (
+ <box flexDirection="column" gap={1} backgroundColor={colors.bg}>
+ <box flexDirection="row" gap={1} alignItems="center">
+ <text attributes={TextAttributes.BOLD} fg={colors.text}>
+ Please report an issue.
+ </text>
+ <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
+ <text attributes={TextAttributes.BOLD} fg={colors.bg}>
+ Copy issue URL (exception info pre-filled)
+ </text>
+ </box>
+ {copied() && <text fg={colors.muted}>Successfully copied</text>}
+ </box>
+ <box flexDirection="row" gap={2} alignItems="center">
+ <text fg={colors.text}>A fatal error occurred!</text>
+ <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
+ <text fg={colors.bg}>Reset TUI</text>
+ </box>
+ <box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
+ <text fg={colors.bg}>Exit</text>
+ </box>
+ </box>
+ <scrollbox height={Math.floor(term().height * 0.7)}>
+ <text fg={colors.muted}>{props.error.stack}</text>
+ </scrollbox>
+ <text fg={colors.text}>{props.error.message}</text>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx b/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx
new file mode 100644
index 000000000..77e2ea8dd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx
@@ -0,0 +1,14 @@
+import { useTheme } from "../context/theme"
+
+export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
+ const { theme } = useTheme()
+
+ return (
+ <box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
+ <text fg={theme.warning}>Unknown plugin route: {props.id}</text>
+ <box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
+ <text fg={theme.text}>go home</text>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx
new file mode 100644
index 000000000..6665c0c2e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx
@@ -0,0 +1,63 @@
+import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useTheme } from "../context/theme"
+import { Spinner } from "./spinner"
+
+export function StartupLoading(props: { ready: () => boolean }) {
+ const theme = useTheme().theme
+ const [show, setShow] = createSignal(false)
+ const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
+ let wait: NodeJS.Timeout | undefined
+ let hold: NodeJS.Timeout | undefined
+ let stamp = 0
+
+ createEffect(() => {
+ if (props.ready()) {
+ if (wait) {
+ clearTimeout(wait)
+ wait = undefined
+ }
+ if (!show()) return
+ if (hold) return
+
+ const left = 3000 - (Date.now() - stamp)
+ if (left <= 0) {
+ setShow(false)
+ return
+ }
+
+ hold = setTimeout(() => {
+ hold = undefined
+ setShow(false)
+ }, left).unref()
+ return
+ }
+
+ if (hold) {
+ clearTimeout(hold)
+ hold = undefined
+ }
+ if (show()) return
+ if (wait) return
+
+ wait = setTimeout(() => {
+ wait = undefined
+ stamp = Date.now()
+ setShow(true)
+ }, 500).unref()
+ })
+
+ onCleanup(() => {
+ if (wait) clearTimeout(wait)
+ if (hold) clearTimeout(hold)
+ })
+
+ return (
+ <Show when={show()}>
+ <box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
+ <box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
+ <Spinner color={theme.textMuted}>{text()}</Spinner>
+ </box>
+ </box>
+ </Show>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
index 236320cf0..205025f86 100644
--- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
- init: (input: { onExit?: () => Promise<void> }) => {
+ init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
(reason?: unknown) => {
if (task) return task
task = (async () => {
+ await input.onBeforeExit?.()
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
index 566d66ade..8d3fe487d 100644
--- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
- match(key: KeybindKey, evt: ParsedKey) {
- const keybind = keybinds()[key]
- if (!keybind) return false
+ match(key: string, evt: ParsedKey) {
+ const list = keybinds()[key] ?? Keybind.parse(key)
+ if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt)
- for (const key of keybind) {
- if (Keybind.match(key, parsed)) {
+ for (const item of list) {
+ if (Keybind.match(item, parsed)) {
return true
}
}
+ return false
},
- print(key: KeybindKey) {
- const first = keybinds()[key]?.at(0)
+ print(key: string) {
+ const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
- const result = Keybind.toString(first)
- return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
+ const text = Keybind.toString(first)
+ const lead = keybinds().leader?.[0]
+ if (!lead) return text
+ return text.replace("<leader>", Keybind.toString(lead))
},
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
new file mode 100644
index 000000000..a84e10128
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
@@ -0,0 +1,41 @@
+import type { ParsedKey } from "@opentui/core"
+
+export type PluginKeybindMap = Record<string, string>
+
+type Base = {
+ match: (key: string, evt: ParsedKey) => boolean
+ print: (key: string) => string
+}
+
+export type PluginKeybind = {
+ readonly all: PluginKeybindMap
+ get: (name: string) => string
+ match: (name: string, evt: ParsedKey) => boolean
+ print: (name: string) => string
+}
+
+const txt = (value: unknown) => {
+ if (typeof value !== "string") return
+ if (!value.trim()) return
+ return value
+}
+
+export function createPluginKeybind(
+ base: Base,
+ defaults: PluginKeybindMap,
+ overrides?: Record<string, unknown>,
+): PluginKeybind {
+ const all = Object.freeze(
+ Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
+ )
+ const get = (name: string) => all[name] ?? name
+
+ return {
+ get all() {
+ return all
+ },
+ get,
+ match: (name, evt) => base.match(get(name), evt),
+ print: (name) => base.print(get(name)),
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
index e96cd2c3a..939c2d5dc 100644
--- a/packages/opencode/src/cli/cmd/tui/context/route.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -14,7 +14,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
-export type Route = HomeRoute | SessionRoute
+export type PluginRoute = {
+ type: "plugin"
+ id: string
+ data?: Record<string, unknown>
+}
+
+export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
- console.log("navigate", route)
setStore(route)
},
}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 2403a4e93..a0f1b3224 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() {
return sdk
},
+ get workspaceID() {
+ return workspaceID
+ },
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index a3d268afd..008f1bf80 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
+import { isRecord } from "@/util/record"
+import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
-type ThemeColors = {
- primary: RGBA
- secondary: RGBA
- accent: RGBA
- error: RGBA
- warning: RGBA
- success: RGBA
- info: RGBA
- text: RGBA
- textMuted: RGBA
- selectedListItemText: RGBA
- background: RGBA
- backgroundPanel: RGBA
- backgroundElement: RGBA
- backgroundMenu: RGBA
- border: RGBA
- borderActive: RGBA
- borderSubtle: RGBA
- diffAdded: RGBA
- diffRemoved: RGBA
- diffContext: RGBA
- diffHunkHeader: RGBA
- diffHighlightAdded: RGBA
- diffHighlightRemoved: RGBA
- diffAddedBg: RGBA
- diffRemovedBg: RGBA
- diffContextBg: RGBA
- diffLineNumber: RGBA
- diffAddedLineNumberBg: RGBA
- diffRemovedLineNumberBg: RGBA
- markdownText: RGBA
- markdownHeading: RGBA
- markdownLink: RGBA
- markdownLinkText: RGBA
- markdownCode: RGBA
- markdownBlockQuote: RGBA
- markdownEmph: RGBA
- markdownStrong: RGBA
- markdownHorizontalRule: RGBA
- markdownListItem: RGBA
- markdownListEnumeration: RGBA
- markdownImage: RGBA
- markdownImageText: RGBA
- markdownCodeBlock: RGBA
- syntaxComment: RGBA
- syntaxKeyword: RGBA
- syntaxFunction: RGBA
- syntaxVariable: RGBA
- syntaxString: RGBA
- syntaxNumber: RGBA
- syntaxType: RGBA
- syntaxOperator: RGBA
- syntaxPunctuation: RGBA
-}
-
-type Theme = ThemeColors & {
+type Theme = TuiThemeCurrent & {
_hasSelectedListItemText: boolean
- thinkingOpacity: number
}
+type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@@ -128,10 +75,10 @@ type Variant = {
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA
-type ThemeJson = {
+export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
- theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
+ theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
@@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
-function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
+type State = {
+ themes: Record<string, ThemeJson>
+ mode: "dark" | "light"
+ lock: "dark" | "light" | undefined
+ active: string
+ ready: boolean
+}
+
+const pluginThemes: Record<string, ThemeJson> = {}
+let customThemes: Record<string, ThemeJson> = {}
+let systemTheme: ThemeJson | undefined
+
+function listThemes() {
+ // Priority: defaults < plugin installs < custom files < generated system.
+ const themes = {
+ ...DEFAULT_THEMES,
+ ...pluginThemes,
+ ...customThemes,
+ }
+ if (!systemTheme) return themes
+ return {
+ ...themes,
+ system: systemTheme,
+ }
+}
+
+function syncThemes() {
+ setStore("themes", listThemes())
+}
+
+const [store, setStore] = createStore<State>({
+ themes: listThemes(),
+ mode: "dark",
+ lock: undefined,
+ active: "opencode",
+ ready: false,
+})
+
+export function allThemes() {
+ return store.themes
+}
+
+function isTheme(theme: unknown): theme is ThemeJson {
+ if (!isRecord(theme)) return false
+ if (!isRecord(theme.theme)) return false
+ return true
+}
+
+export function hasTheme(name: string) {
+ if (!name) return false
+ return allThemes()[name] !== undefined
+}
+
+export function addTheme(name: string, theme: unknown) {
+ if (!name) return false
+ if (!isTheme(theme)) return false
+ if (hasTheme(name)) return false
+ pluginThemes[name] = theme
+ syncThemes()
+ return true
+}
+
+export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
- function resolveColor(c: ColorValue): RGBA {
+ function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c)
- if (defs[c] != null) {
- return resolveColor(defs[c])
- } else if (theme.theme[c as keyof ThemeColors] !== undefined) {
- return resolveColor(theme.theme[c as keyof ThemeColors]!)
- } else {
+ if (chain.includes(c)) {
+ throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
+ }
+
+ const next = defs[c] ?? theme.theme[c as ThemeColor]
+ if (next === undefined) {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
+ return resolveColor(next, [...chain, c])
}
if (typeof c === "number") {
return ansiToRgba(c)
}
- return resolveColor(c[mode])
+ return resolveColor(c[mode], chain)
}
const resolved = Object.fromEntries(
@@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
.map(([key, value]) => {
return [key, resolveColor(value as ColorValue)]
}),
- ) as Partial<ThemeColors>
+ ) as Partial<Record<ThemeColor, RGBA>>
// Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
@@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (value === "dark" || value === "light") return value
return
}
- const lock = pick(kv.get("theme_mode_lock"))
- const [store, setStore] = createStore({
- themes: DEFAULT_THEMES,
- mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
- lock,
- active: (config.theme ?? kv.get("theme", "opencode")) as string,
- ready: false,
- })
+
+ setStore(
+ produce((draft) => {
+ const lock = pick(kv.get("theme_mode_lock"))
+ const mode = pick(kv.get("theme_mode", props.mode))
+ draft.mode = lock ?? mode ?? props.mode
+ draft.lock = lock
+ const active = config.theme ?? kv.get("theme", "opencode")
+ draft.active = typeof active === "string" ? active : "opencode"
+ draft.ready = false
+ }),
+ )
createEffect(() => {
const theme = config.theme
@@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
- resolveSystemTheme(store.mode)
- getCustomThemes()
- .then((custom) => {
- setStore(
- produce((draft) => {
- Object.assign(draft.themes, custom)
- }),
- )
- })
- .catch(() => {
- setStore("active", "opencode")
- })
- .finally(() => {
- if (store.active !== "system") {
- setStore("ready", true)
- }
- })
+ Promise.allSettled([
+ resolveSystemTheme(store.mode),
+ getCustomThemes()
+ .then((custom) => {
+ customThemes = custom
+ syncThemes()
+ })
+ .catch(() => {
+ setStore("active", "opencode")
+ }),
+ ]).finally(() => {
+ setStore("ready", true)
+ })
}
onMount(init)
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
- renderer
+ return renderer
.getPalette({
size: 16,
})
- .then((colors) => {
+ .then((colors: TerminalColors) => {
if (!colors.palette[0]) {
+ systemTheme = undefined
+ syncThemes()
if (store.active === "system") {
- setStore(
- produce((draft) => {
- draft.active = "opencode"
- draft.ready = true
- }),
- )
+ setStore("active", "opencode")
}
return
}
- setStore(
- produce((draft) => {
- draft.themes.system = generateSystem(colors, mode)
- if (store.active === "system") {
- draft.ready = true
- }
- }),
- )
+ systemTheme = generateSystem(colors, mode)
+ syncThemes()
+ })
+ .catch(() => {
+ systemTheme = undefined
+ syncThemes()
+ if (store.active === "system") {
+ setStore("active", "opencode")
+ }
})
}
@@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
+
+ const refresh = () => {
+ renderer.clearPaletteCache()
+ init()
+ }
+ process.on("SIGUSR2", refresh)
+
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
+ process.off("SIGUSR2", refresh)
})
const values = createMemo(() => {
@@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
- return store.themes
+ return allThemes()
+ },
+ has(name: string) {
+ return hasTheme(name)
},
syntax,
subtleSyntax,
@@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
pin(mode)
},
set(theme: string) {
+ if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
+ return true
},
get ready() {
return store.ready
diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
index 73d82248a..08e429617 100644
--- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx
@@ -1,4 +1,4 @@
-import { createMemo, createSignal, For } from "solid-js"
+import { For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeCount = Object.keys(DEFAULT_THEMES).length
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
new file mode 100644
index 000000000..1a1d3c174
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
@@ -0,0 +1,48 @@
+import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+import { createMemo, Show } from "solid-js"
+import { Tips } from "./tips-view"
+
+const id = "internal:home-tips"
+
+function View(props: { show: boolean }) {
+ return (
+ <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
+ <Show when={props.show}>
+ <Tips />
+ </Show>
+ </box>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.command.register(() => [
+ {
+ title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
+ value: "tips.toggle",
+ keybind: "tips_toggle",
+ category: "System",
+ hidden: api.route.current.name !== "home",
+ onSelect() {
+ api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
+ api.ui.dialog.clear()
+ },
+ },
+ ])
+
+ api.slots.register({
+ order: 100,
+ slots: {
+ home_bottom() {
+ const hidden = createMemo(() => api.kv.get("tips_hidden", false))
+ const first = createMemo(() => api.state.session.count() === 0)
+ const show = createMemo(() => !first() && !hidden())
+ return <View show={show()} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
new file mode 100644
index 000000000..c8538ae2a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
@@ -0,0 +1,61 @@
+import type { AssistantMessage } from "@opencode-ai/sdk/v2"
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo } from "solid-js"
+
+const id = "internal:sidebar-context"
+
+const money = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+})
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+ const theme = () => props.api.theme.current
+ const msg = createMemo(() => props.api.state.session.messages(props.session_id))
+ const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
+
+ const state = createMemo(() => {
+ const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
+ if (!last) {
+ return {
+ tokens: 0,
+ percent: null,
+ }
+ }
+
+ const tokens =
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
+ return {
+ tokens,
+ percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
+ }
+ })
+
+ return (
+ <box>
+ <text fg={theme().text}>
+ <b>Context</b>
+ </text>
+ <text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
+ <text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
+ <text fg={theme().textMuted}>{money.format(cost())} spent</text>
+ </box>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 100,
+ slots: {
+ sidebar_content(_ctx, props) {
+ return <View api={api} session_id={props.session_id} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
new file mode 100644
index 000000000..16bed7287
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
@@ -0,0 +1,60 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+
+const id = "internal:sidebar-files"
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+ const [open, setOpen] = createSignal(true)
+ const theme = () => props.api.theme.current
+ const list = createMemo(() => props.api.state.session.diff(props.session_id))
+
+ return (
+ <Show when={list().length > 0}>
+ <box>
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+ <Show when={list().length > 2}>
+ <text fg={theme().text}>{open() ? "â–¼" : "â–¶"}</text>
+ </Show>
+ <text fg={theme().text}>
+ <b>Modified Files</b>
+ </text>
+ </box>
+ <Show when={list().length <= 2 || open()}>
+ <For each={list()}>
+ {(item) => (
+ <box flexDirection="row" gap={1} justifyContent="space-between">
+ <text fg={theme().textMuted} wrapMode="none">
+ {item.file}
+ </text>
+ <box flexDirection="row" gap={1} flexShrink={0}>
+ <Show when={item.additions}>
+ <text fg={theme().diffAdded}>+{item.additions}</text>
+ </Show>
+ <Show when={item.deletions}>
+ <text fg={theme().diffRemoved}>-{item.deletions}</text>
+ </Show>
+ </box>
+ </box>
+ )}
+ </For>
+ </Show>
+ </box>
+ </Show>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 500,
+ slots: {
+ sidebar_content(_ctx, props) {
+ return <View api={api} session_id={props.session_id} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
new file mode 100644
index 000000000..a6bff01a5
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
@@ -0,0 +1,91 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, Show } from "solid-js"
+import { Global } from "@/global"
+
+const id = "internal:sidebar-footer"
+
+function View(props: { api: TuiPluginApi }) {
+ const theme = () => props.api.theme.current
+ const has = createMemo(() =>
+ props.api.state.provider.some(
+ (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
+ ),
+ )
+ const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
+ const show = createMemo(() => !has() && !done())
+ const path = createMemo(() => {
+ const dir = props.api.state.path.directory || process.cwd()
+ const out = dir.replace(Global.Path.home, "~")
+ const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
+ const list = text.split("/")
+ return {
+ parent: list.slice(0, -1).join("/"),
+ name: list.at(-1) ?? "",
+ }
+ })
+
+ return (
+ <box gap={1}>
+ <Show when={show()}>
+ <box
+ backgroundColor={theme().backgroundElement}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ paddingRight={2}
+ flexDirection="row"
+ gap={1}
+ >
+ <text flexShrink={0} fg={theme().text}>
+ ⬖
+ </text>
+ <box flexGrow={1} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text fg={theme().text}>
+ <b>Getting started</b>
+ </text>
+ <text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
+ ✕
+ </text>
+ </box>
+ <text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
+ <text fg={theme().textMuted}>
+ Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
+ </text>
+ <box flexDirection="row" gap={1} justifyContent="space-between">
+ <text fg={theme().text}>Connect provider</text>
+ <text fg={theme().textMuted}>/connect</text>
+ </box>
+ </box>
+ </box>
+ </Show>
+ <text>
+ <span style={{ fg: theme().textMuted }}>{path().parent}/</span>
+ <span style={{ fg: theme().text }}>{path().name}</span>
+ </text>
+ <text fg={theme().textMuted}>
+ <span style={{ fg: theme().success }}>•</span> <b>Open</b>
+ <span style={{ fg: theme().text }}>
+ <b>Code</b>
+ </span>{" "}
+ <span>{props.api.app.version}</span>
+ </text>
+ </box>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 100,
+ slots: {
+ sidebar_footer() {
+ return <View api={api} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
new file mode 100644
index 000000000..db9b3a7e5
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
@@ -0,0 +1,64 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+
+const id = "internal:sidebar-lsp"
+
+function View(props: { api: TuiPluginApi }) {
+ const [open, setOpen] = createSignal(true)
+ const theme = () => props.api.theme.current
+ const list = createMemo(() => props.api.state.lsp())
+ const off = createMemo(() => props.api.state.config.lsp === false)
+
+ return (
+ <box>
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+ <Show when={list().length > 2}>
+ <text fg={theme().text}>{open() ? "â–¼" : "â–¶"}</text>
+ </Show>
+ <text fg={theme().text}>
+ <b>LSP</b>
+ </text>
+ </box>
+ <Show when={list().length <= 2 || open()}>
+ <Show when={list().length === 0}>
+ <text fg={theme().textMuted}>
+ {off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"}
+ </text>
+ </Show>
+ <For each={list()}>
+ {(item) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: item.status === "connected" ? theme().success : theme().error,
+ }}
+ >
+ •
+ </text>
+ <text fg={theme().textMuted}>
+ {item.id} {item.root}
+ </text>
+ </box>
+ )}
+ </For>
+ </Show>
+ </box>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 300,
+ slots: {
+ sidebar_content() {
+ return <View api={api} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
new file mode 100644
index 000000000..178050abd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
@@ -0,0 +1,94 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
+
+const id = "internal:sidebar-mcp"
+
+function View(props: { api: TuiPluginApi }) {
+ const [open, setOpen] = createSignal(true)
+ const theme = () => props.api.theme.current
+ const list = createMemo(() => props.api.state.mcp())
+ const on = createMemo(() => list().filter((item) => item.status === "connected").length)
+ const bad = createMemo(
+ () =>
+ list().filter(
+ (item) =>
+ item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
+ ).length,
+ )
+
+ const dot = (status: string) => {
+ if (status === "connected") return theme().success
+ if (status === "failed") return theme().error
+ if (status === "disabled") return theme().textMuted
+ if (status === "needs_auth") return theme().warning
+ if (status === "needs_client_registration") return theme().error
+ return theme().textMuted
+ }
+
+ return (
+ <Show when={list().length > 0}>
+ <box>
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+ <Show when={list().length > 2}>
+ <text fg={theme().text}>{open() ? "â–¼" : "â–¶"}</text>
+ </Show>
+ <text fg={theme().text}>
+ <b>MCP</b>
+ <Show when={!open()}>
+ <span style={{ fg: theme().textMuted }}>
+ {" "}
+ ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
+ </span>
+ </Show>
+ </text>
+ </box>
+ <Show when={list().length <= 2 || open()}>
+ <For each={list()}>
+ {(item) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: dot(item.status),
+ }}
+ >
+ •
+ </text>
+ <text fg={theme().text} wrapMode="word">
+ {item.name}{" "}
+ <span style={{ fg: theme().textMuted }}>
+ <Switch fallback={item.status}>
+ <Match when={item.status === "connected"}>Connected</Match>
+ <Match when={item.status === "failed"}>
+ <i>{item.error}</i>
+ </Match>
+ <Match when={item.status === "disabled"}>Disabled</Match>
+ <Match when={item.status === "needs_auth"}>Needs auth</Match>
+ <Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
+ </Switch>
+ </span>
+ </text>
+ </box>
+ )}
+ </For>
+ </Show>
+ </box>
+ </Show>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 200,
+ slots: {
+ sidebar_content() {
+ return <View api={api} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
new file mode 100644
index 000000000..c9e904deb
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
@@ -0,0 +1,46 @@
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createMemo, For, Show, createSignal } from "solid-js"
+import { TodoItem } from "../../component/todo-item"
+
+const id = "internal:sidebar-todo"
+
+function View(props: { api: TuiPluginApi; session_id: string }) {
+ const [open, setOpen] = createSignal(true)
+ const theme = () => props.api.theme.current
+ const list = createMemo(() => props.api.state.session.todo(props.session_id))
+ const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed"))
+
+ return (
+ <Show when={show()}>
+ <box>
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
+ <Show when={list().length > 2}>
+ <text fg={theme().text}>{open() ? "â–¼" : "â–¶"}</text>
+ </Show>
+ <text fg={theme().text}>
+ <b>Todo</b>
+ </text>
+ </box>
+ <Show when={list().length <= 2 || open()}>
+ <For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
+ </Show>
+ </box>
+ </Show>
+ )
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.slots.register({
+ order: 400,
+ slots: {
+ sidebar_content(_ctx, props) {
+ return <View api={api} session_id={props.session_id} />
+ },
+ },
+ })
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
new file mode 100644
index 000000000..8293be506
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
@@ -0,0 +1,262 @@
+import { Keybind } from "@/util/keybind"
+import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { fileURLToPath } from "url"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createEffect, createMemo, createSignal } from "solid-js"
+
+const id = "internal:plugin-manager"
+const key = Keybind.parse("space").at(0)
+const add = Keybind.parse("shift+i").at(0)
+const tab = Keybind.parse("tab").at(0)
+
+function state(api: TuiPluginApi, item: TuiPluginStatus) {
+ if (!item.enabled) {
+ return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
+ }
+
+ return (
+ <span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
+ {item.active ? "active" : "inactive"}
+ </span>
+ )
+}
+
+function source(spec: string) {
+ if (!spec.startsWith("file://")) return
+ return fileURLToPath(spec)
+}
+
+function meta(item: TuiPluginStatus, width: number) {
+ if (item.source === "internal") {
+ if (width >= 120) return "Built-in plugin"
+ return "Built-in"
+ }
+ const next = source(item.spec)
+ if (next) return next
+ return item.spec
+}
+
+function Install(props: { api: TuiPluginApi }) {
+ const [global, setGlobal] = createSignal(false)
+ const [busy, setBusy] = createSignal(false)
+
+ useKeyboard((evt) => {
+ if (evt.name !== "tab") return
+ evt.preventDefault()
+ evt.stopPropagation()
+ if (busy()) return
+ setGlobal((x) => !x)
+ })
+
+ return (
+ <props.api.ui.DialogPrompt
+ title="Install plugin"
+ placeholder="npm package name"
+ description={() => (
+ <box flexDirection="row" gap={1}>
+ <text fg={props.api.theme.current.textMuted}>scope:</text>
+ <text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
+ <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+ </box>
+ )}
+ onConfirm={(raw) => {
+ if (busy()) return
+ const mod = raw.trim()
+ if (!mod) {
+ props.api.ui.toast({
+ variant: "error",
+ message: "Plugin package name is required",
+ })
+ return
+ }
+
+ setBusy(true)
+ props.api.plugins
+ .install(mod, { global: global() })
+ .then((out) => {
+ if (!out.ok) {
+ props.api.ui.toast({
+ variant: "error",
+ message: out.message,
+ })
+ if (out.missing) {
+ props.api.ui.toast({
+ variant: "info",
+ message: "Check npm registry/auth settings and try again.",
+ })
+ }
+ show(props.api)
+ return
+ }
+
+ props.api.ui.toast({
+ variant: "success",
+ message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
+ })
+ if (!out.tui) {
+ props.api.ui.toast({
+ variant: "info",
+ message: "Package has no TUI target to load in this app.",
+ })
+ show(props.api)
+ return
+ }
+
+ return props.api.plugins.add(mod).then((ok) => {
+ if (!ok) {
+ props.api.ui.toast({
+ variant: "warning",
+ message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
+ })
+ show(props.api)
+ return
+ }
+
+ props.api.ui.toast({
+ variant: "success",
+ message: `Loaded ${mod} in current session.`,
+ })
+ show(props.api)
+ })
+ })
+ .finally(() => {
+ setBusy(false)
+ })
+ }}
+ onCancel={() => {
+ show(props.api)
+ }}
+ />
+ )
+}
+
+function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
+ return {
+ title: item.id,
+ value: item.id,
+ category: item.source === "internal" ? "Internal" : "External",
+ description: meta(item, width),
+ footer: state(api, item),
+ disabled: item.id === id,
+ }
+}
+
+function showInstall(api: TuiPluginApi) {
+ api.ui.dialog.replace(() => <Install api={api} />)
+}
+
+function View(props: { api: TuiPluginApi }) {
+ const size = useTerminalDimensions()
+ const [list, setList] = createSignal(props.api.plugins.list())
+ const [cur, setCur] = createSignal<string | undefined>()
+ const [lock, setLock] = createSignal(false)
+
+ createEffect(() => {
+ const width = size().width
+ if (width >= 128) {
+ props.api.ui.dialog.setSize("xlarge")
+ return
+ }
+ if (width >= 96) {
+ props.api.ui.dialog.setSize("large")
+ return
+ }
+ props.api.ui.dialog.setSize("medium")
+ })
+
+ const rows = createMemo(() =>
+ [...list()]
+ .sort((a, b) => {
+ const x = a.source === "internal" ? 1 : 0
+ const y = b.source === "internal" ? 1 : 0
+ if (x !== y) return x - y
+ return a.id.localeCompare(b.id)
+ })
+ .map((item) => row(props.api, item, size().width)),
+ )
+
+ const flip = (x: string) => {
+ if (lock()) return
+ const item = list().find((entry) => entry.id === x)
+ if (!item) return
+ setLock(true)
+ const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
+ task
+ .then((ok) => {
+ if (!ok) {
+ props.api.ui.toast({
+ variant: "error",
+ message: `Failed to update plugin ${item.id}`,
+ })
+ }
+ setList(props.api.plugins.list())
+ })
+ .finally(() => {
+ setLock(false)
+ })
+ }
+
+ return (
+ <DialogSelect
+ title="Plugins"
+ options={rows()}
+ current={cur()}
+ onMove={(item) => setCur(item.value)}
+ keybind={[
+ {
+ title: "toggle",
+ keybind: key,
+ disabled: lock(),
+ onTrigger: (item) => {
+ setCur(item.value)
+ flip(item.value)
+ },
+ },
+ {
+ title: "install",
+ keybind: add,
+ disabled: lock(),
+ onTrigger: () => {
+ showInstall(props.api)
+ },
+ },
+ ]}
+ onSelect={(item) => {
+ setCur(item.value)
+ flip(item.value)
+ }}
+ />
+ )
+}
+
+function show(api: TuiPluginApi) {
+ api.ui.dialog.replace(() => <View api={api} />)
+}
+
+const tui: TuiPlugin = async (api) => {
+ api.command.register(() => [
+ {
+ title: "Plugins",
+ value: "plugins.list",
+ keybind: "plugin_manager",
+ category: "System",
+ onSelect() {
+ show(api)
+ },
+ },
+ {
+ title: "Install plugin",
+ value: "plugins.install",
+ category: "System",
+ onSelect() {
+ showInstall(api)
+ },
+ },
+ ])
+}
+
+export default {
+ id,
+ tui,
+}
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
new file mode 100644
index 000000000..2bfd96ac3
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
@@ -0,0 +1,406 @@
+import type { ParsedKey } from "@opentui/core"
+import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
+import type { useCommandDialog } from "@tui/component/dialog-command"
+import type { useKeybind } from "@tui/context/keybind"
+import type { useRoute } from "@tui/context/route"
+import type { useSDK } from "@tui/context/sdk"
+import type { useSync } from "@tui/context/sync"
+import type { useTheme } from "@tui/context/theme"
+import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
+import type { TuiConfig } from "@/config/tui"
+import { createPluginKeybind } from "../context/plugin-keybinds"
+import type { useKV } from "../context/kv"
+import { DialogAlert } from "../ui/dialog-alert"
+import { DialogConfirm } from "../ui/dialog-confirm"
+import { DialogPrompt } from "../ui/dialog-prompt"
+import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
+import type { useToast } from "../ui/toast"
+import { Installation } from "@/installation"
+import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
+
+type RouteEntry = {
+ key: symbol
+ render: TuiRouteDefinition["render"]
+}
+
+export type RouteMap = Map<string, RouteEntry[]>
+
+type Input = {
+ command: ReturnType<typeof useCommandDialog>
+ tuiConfig: TuiConfig.Info
+ dialog: ReturnType<typeof useDialog>
+ keybind: ReturnType<typeof useKeybind>
+ kv: ReturnType<typeof useKV>
+ route: ReturnType<typeof useRoute>
+ routes: RouteMap
+ bump: () => void
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ theme: ReturnType<typeof useTheme>
+ toast: ReturnType<typeof useToast>
+ renderer: TuiPluginApi["renderer"]
+}
+
+type TuiHostPluginApi = TuiPluginApi & {
+ map: Map<string | undefined, OpencodeClient>
+ dispose: () => void
+}
+
+function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
+ const key = Symbol()
+ for (const item of list) {
+ const prev = routes.get(item.name) ?? []
+ prev.push({ key, render: item.render })
+ routes.set(item.name, prev)
+ }
+ bump()
+
+ return () => {
+ for (const item of list) {
+ const prev = routes.get(item.name)
+ if (!prev) continue
+ const next = prev.filter((x) => x.key !== key)
+ if (!next.length) {
+ routes.delete(item.name)
+ continue
+ }
+ routes.set(item.name, next)
+ }
+ bump()
+ }
+}
+
+function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
+ if (name === "home") {
+ route.navigate({ type: "home" })
+ return
+ }
+
+ if (name === "session") {
+ const sessionID = params?.sessionID
+ if (typeof sessionID !== "string") return
+ route.navigate({ type: "session", sessionID })
+ return
+ }
+
+ route.navigate({ type: "plugin", id: name, data: params })
+}
+
+function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
+ if (route.data.type === "home") return { name: "home" }
+ if (route.data.type === "session") {
+ return {
+ name: "session",
+ params: {
+ sessionID: route.data.sessionID,
+ initialPrompt: route.data.initialPrompt,
+ },
+ }
+ }
+
+ return {
+ name: route.data.id,
+ params: route.data.data,
+ }
+}
+
+function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
+ return {
+ ...item,
+ onSelect: () => item.onSelect?.(),
+ }
+}
+
+function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
+ return {
+ title: item.title,
+ value: item.value,
+ description: item.description,
+ footer: item.footer,
+ category: item.category,
+ disabled: item.disabled,
+ }
+}
+
+function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
+ if (!cb) return
+ return (item: SelectOption<Value>) => cb(pickOption(item))
+}
+
+function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
+ return {
+ get ready() {
+ return sync.ready
+ },
+ get config() {
+ return sync.data.config
+ },
+ get provider() {
+ return sync.data.provider
+ },
+ get path() {
+ return sync.data.path
+ },
+ get vcs() {
+ if (!sync.data.vcs) return
+ return {
+ branch: sync.data.vcs.branch,
+ }
+ },
+ workspace: {
+ list() {
+ return sync.data.workspaceList
+ },
+ get(workspaceID) {
+ return sync.workspace.get(workspaceID)
+ },
+ },
+ session: {
+ count() {
+ return sync.data.session.length
+ },
+ diff(sessionID) {
+ return sync.data.session_diff[sessionID] ?? []
+ },
+ todo(sessionID) {
+ return sync.data.todo[sessionID] ?? []
+ },
+ messages(sessionID) {
+ return sync.data.message[sessionID] ?? []
+ },
+ status(sessionID) {
+ return sync.data.session_status[sessionID]
+ },
+ permission(sessionID) {
+ return sync.data.permission[sessionID] ?? []
+ },
+ question(sessionID) {
+ return sync.data.question[sessionID] ?? []
+ },
+ },
+ part(messageID) {
+ return sync.data.part[messageID] ?? []
+ },
+ lsp() {
+ return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
+ },
+ mcp() {
+ return Object.entries(sync.data.mcp)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([name, item]) => ({
+ name,
+ status: item.status,
+ error: item.status === "failed" ? item.error : undefined,
+ }))
+ },
+ }
+}
+
+function appApi(): TuiPluginApi["app"] {
+ return {
+ get version() {
+ return Installation.VERSION
+ },
+ }
+}
+
+export function createTuiApi(input: Input): TuiHostPluginApi {
+ const map = new Map<string | undefined, OpencodeClient>()
+ const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
+ const hit = map.get(workspaceID)
+ if (hit) return hit
+
+ const next = createOpencodeClient({
+ baseUrl: input.sdk.url,
+ fetch: input.sdk.fetch,
+ directory: input.sync.data.path.directory || input.sdk.directory,
+ experimental_workspaceID: workspaceID,
+ })
+ map.set(workspaceID, next)
+ return next
+ }
+ const workspace: TuiPluginApi["workspace"] = {
+ current() {
+ return input.sdk.workspaceID
+ },
+ set(workspaceID) {
+ input.sdk.setWorkspace(workspaceID)
+ },
+ }
+ const lifecycle: TuiPluginApi["lifecycle"] = {
+ signal: new AbortController().signal,
+ onDispose() {
+ return () => {}
+ },
+ }
+
+ return {
+ app: appApi(),
+ command: {
+ register(cb) {
+ return input.command.register(() => cb())
+ },
+ trigger(value) {
+ input.command.trigger(value)
+ },
+ },
+ route: {
+ register(list) {
+ return routeRegister(input.routes, list, input.bump)
+ },
+ navigate(name, params) {
+ routeNavigate(input.route, name, params)
+ },
+ get current() {
+ return routeCurrent(input.route)
+ },
+ },
+ ui: {
+ Dialog(props) {
+ return (
+ <DialogUI size={props.size} onClose={props.onClose}>
+ {props.children}
+ </DialogUI>
+ )
+ },
+ DialogAlert(props) {
+ return <DialogAlert {...props} />
+ },
+ DialogConfirm(props) {
+ return <DialogConfirm {...props} />
+ },
+ DialogPrompt(props) {
+ return <DialogPrompt {...props} description={props.description} />
+ },
+ DialogSelect(props) {
+ return (
+ <DialogSelect
+ title={props.title}
+ placeholder={props.placeholder}
+ options={props.options.map(mapOption)}
+ flat={props.flat}
+ onMove={mapOptionCb(props.onMove)}
+ onFilter={props.onFilter}
+ onSelect={mapOptionCb(props.onSelect)}
+ skipFilter={props.skipFilter}
+ current={props.current}
+ />
+ )
+ },
+ toast(inputToast) {
+ input.toast.show({
+ title: inputToast.title,
+ message: inputToast.message,
+ variant: inputToast.variant ?? "info",
+ duration: inputToast.duration,
+ })
+ },
+ dialog: {
+ replace(render, onClose) {
+ input.dialog.replace(render, onClose)
+ },
+ clear() {
+ input.dialog.clear()
+ },
+ setSize(size) {
+ input.dialog.setSize(size)
+ },
+ get size() {
+ return input.dialog.size
+ },
+ get depth() {
+ return input.dialog.stack.length
+ },
+ get open() {
+ return input.dialog.stack.length > 0
+ },
+ },
+ },
+ keybind: {
+ match(key, evt: ParsedKey) {
+ return input.keybind.match(key, evt)
+ },
+ print(key) {
+ return input.keybind.print(key)
+ },
+ create(defaults, overrides) {
+ return createPluginKeybind(input.keybind, defaults, overrides)
+ },
+ },
+ get tuiConfig() {
+ return input.tuiConfig
+ },
+ kv: {
+ get(key, fallback) {
+ return input.kv.get(key, fallback)
+ },
+ set(key, value) {
+ input.kv.set(key, value)
+ },
+ get ready() {
+ return input.kv.ready
+ },
+ },
+ state: stateApi(input.sync),
+ get client() {
+ return input.sdk.client
+ },
+ scopedClient: scoped,
+ workspace,
+ event: input.sdk.event,
+ renderer: input.renderer,
+ slots: {
+ register() {
+ throw new Error("slots.register is only available in plugin context")
+ },
+ },
+ plugins: {
+ list() {
+ return []
+ },
+ async activate() {
+ return false
+ },
+ async deactivate() {
+ return false
+ },
+ async add() {
+ return false
+ },
+ async install() {
+ return {
+ ok: false,
+ message: "plugins.install is only available in plugin context",
+ }
+ },
+ },
+ lifecycle,
+ theme: {
+ get current() {
+ return input.theme.theme
+ },
+ get selected() {
+ return input.theme.selected
+ },
+ has(name) {
+ return input.theme.has(name)
+ },
+ set(name) {
+ return input.theme.set(name)
+ },
+ async install(_jsonPath) {
+ throw new Error("theme.install is only available in plugin context")
+ },
+ mode() {
+ return input.theme.mode()
+ },
+ get ready() {
+ return input.theme.ready
+ },
+ },
+ map,
+ dispose() {
+ map.clear()
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/index.ts b/packages/opencode/src/cli/cmd/tui/plugin/index.ts
new file mode 100644
index 000000000..c970a318f
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/plugin/index.ts
@@ -0,0 +1,3 @@
+export { TuiPluginRuntime } from "./runtime"
+export { createTuiApi } from "./api"
+export type { RouteMap } from "./api"
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts
new file mode 100644
index 000000000..9e28bbd2e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts
@@ -0,0 +1,25 @@
+import HomeTips from "../feature-plugins/home/tips"
+import SidebarContext from "../feature-plugins/sidebar/context"
+import SidebarMcp from "../feature-plugins/sidebar/mcp"
+import SidebarLsp from "../feature-plugins/sidebar/lsp"
+import SidebarTodo from "../feature-plugins/sidebar/todo"
+import SidebarFiles from "../feature-plugins/sidebar/files"
+import SidebarFooter from "../feature-plugins/sidebar/footer"
+import PluginManager from "../feature-plugins/system/plugins"
+import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
+
+export type InternalTuiPlugin = TuiPluginModule & {
+ id: string
+ tui: TuiPlugin
+}
+
+export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
+ HomeTips,
+ SidebarContext,
+ SidebarMcp,
+ SidebarLsp,
+ SidebarTodo,
+ SidebarFiles,
+ SidebarFooter,
+ PluginManager,
+]
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
new file mode 100644
index 000000000..9cc5194df
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
@@ -0,0 +1,972 @@
+import "@opentui/solid/runtime-plugin-support"
+import {
+ type TuiDispose,
+ type TuiPlugin,
+ type TuiPluginApi,
+ type TuiPluginInstallResult,
+ type TuiPluginModule,
+ type TuiPluginMeta,
+ type TuiPluginStatus,
+ type TuiTheme,
+} from "@opencode-ai/plugin/tui"
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { Config } from "@/config/config"
+import { TuiConfig } from "@/config/tui"
+import { Log } from "@/util/log"
+import { errorData, errorMessage } from "@/util/error"
+import { isRecord } from "@/util/record"
+import { Instance } from "@/project/instance"
+import {
+ checkPluginCompatibility,
+ getDefaultPlugin,
+ isDeprecatedPlugin,
+ pluginSource,
+ readPluginId,
+ resolvePluginEntrypoint,
+ resolvePluginId,
+ resolvePluginTarget,
+ type PluginSource,
+} from "@/plugin/shared"
+import { PluginMeta } from "@/plugin/meta"
+import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
+import { addTheme, hasTheme } from "../context/theme"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Process } from "@/util/process"
+import { Flag } from "@/flag/flag"
+import { Installation } from "@/installation"
+import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
+import { setupSlots, Slot as View } from "./slots"
+import type { HostPluginApi, HostSlots } from "./slots"
+
+type PluginLoad = {
+ item?: Config.PluginSpec
+ spec: string
+ target: string
+ retry: boolean
+ source: PluginSource | "internal"
+ id: string
+ module: TuiPluginModule
+ install_theme: TuiTheme["install"]
+}
+
+type Api = HostPluginApi
+
+type PluginScope = {
+ lifecycle: TuiPluginApi["lifecycle"]
+ track: (fn: (() => void) | undefined) => () => void
+ dispose: () => Promise<void>
+}
+
+type PluginEntry = {
+ id: string
+ load: PluginLoad
+ meta: TuiPluginMeta
+ plugin: TuiPlugin
+ options: Config.PluginOptions | undefined
+ enabled: boolean
+ scope?: PluginScope
+}
+
+type RuntimeState = {
+ directory: string
+ api: Api
+ slots: HostSlots
+ plugins: PluginEntry[]
+ plugins_by_id: Map<string, PluginEntry>
+ pending: Map<
+ string,
+ {
+ item: Config.PluginSpec
+ meta: TuiConfig.PluginMeta
+ }
+ >
+}
+
+const log = Log.create({ service: "tui.plugin" })
+const DISPOSE_TIMEOUT_MS = 5000
+const KV_KEY = "plugin_enabled"
+
+function fail(message: string, data: Record<string, unknown>) {
+ if (!("error" in data)) {
+ log.error(message, data)
+ console.error(`[tui.plugin] ${message}`, data)
+ return
+ }
+
+ const text = `${message}: ${errorMessage(data.error)}`
+ const next = { ...data, error: errorData(data.error) }
+ log.error(text, next)
+ console.error(`[tui.plugin] ${text}`, next)
+}
+
+type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
+
+function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ resolve({ type: "timeout" })
+ }, ms)
+
+ Promise.resolve()
+ .then(fn)
+ .then(
+ () => {
+ resolve({ type: "ok" })
+ },
+ (error) => {
+ resolve({ type: "error", error })
+ },
+ )
+ .finally(() => {
+ clearTimeout(timer)
+ })
+ })
+}
+
+function isTheme(value: unknown) {
+ if (!isRecord(value)) return false
+ if (!("theme" in value)) return false
+ if (!isRecord(value.theme)) return false
+ return true
+}
+
+function resolveRoot(root: string) {
+ if (root.startsWith("file://")) {
+ const file = fileURLToPath(root)
+ if (root.endsWith("/")) return file
+ return path.dirname(file)
+ }
+ if (path.isAbsolute(root)) return root
+ return path.resolve(process.cwd(), root)
+}
+
+function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
+ return async (file) => {
+ const raw = file.startsWith("file://") ? fileURLToPath(file) : file
+ const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
+ const theme = path.basename(src, path.extname(src))
+ if (hasTheme(theme)) return
+
+ const text = await Filesystem.readText(src).catch((error) => {
+ log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
+ return
+ })
+ if (text === undefined) return
+
+ const fail = Symbol()
+ const data = await Promise.resolve(text)
+ .then((x) => JSON.parse(x))
+ .catch((error) => {
+ log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
+ return fail
+ })
+ if (data === fail) return
+
+ if (!isTheme(data)) {
+ log.warn("invalid tui plugin theme", { path: spec, theme: src })
+ return
+ }
+
+ const source_dir = path.dirname(meta.source)
+ const local_dir =
+ path.basename(source_dir) === ".opencode"
+ ? path.join(source_dir, "themes")
+ : path.join(source_dir, ".opencode", "themes")
+ const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
+ const dest = path.join(dest_dir, `${theme}.json`)
+ if (!(await Filesystem.exists(dest))) {
+ await Filesystem.write(dest, text).catch((error) => {
+ log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
+ })
+ }
+
+ addTheme(theme, data)
+ }
+}
+
+async function loadExternalPlugin(
+ item: Config.PluginSpec,
+ meta: TuiConfig.PluginMeta | undefined,
+ retry = false,
+): Promise<PluginLoad | undefined> {
+ const spec = Config.pluginSpecifier(item)
+ if (isDeprecatedPlugin(spec)) return
+ log.info("loading tui plugin", { path: spec, retry })
+ const resolved = await resolvePluginTarget(spec).catch((error) => {
+ fail("failed to resolve tui plugin", { path: spec, retry, error })
+ return
+ })
+ if (!resolved) return
+
+ const source = pluginSource(spec)
+ if (source === "npm") {
+ const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
+ .then(() => true)
+ .catch((error) => {
+ fail("tui plugin incompatible", { path: spec, retry, error })
+ return false
+ })
+ if (!ok) return
+ }
+
+ const target = resolved
+ if (!meta) {
+ fail("missing tui plugin metadata", {
+ path: spec,
+ retry,
+ })
+ return
+ }
+
+ const root = resolveRoot(source === "file" ? spec : target)
+ const install_theme = createThemeInstaller(meta, root, spec)
+ const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
+ fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
+ return
+ })
+ if (!entry) return
+
+ const mod = await import(entry)
+ .then((raw) => {
+ const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
+ if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
+ return mod
+ })
+ .catch((error) => {
+ fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
+ return
+ })
+ if (!mod) return
+
+ const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
+ fail("failed to load tui plugin", { path: spec, target, retry, error })
+ return
+ })
+ if (!id) return
+
+ return {
+ item,
+ spec,
+ target,
+ retry,
+ source,
+ id,
+ module: mod,
+ install_theme,
+ }
+}
+
+function createMeta(
+ source: PluginLoad["source"],
+ spec: string,
+ target: string,
+ meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
+ id?: string,
+): TuiPluginMeta {
+ if (meta) {
+ return {
+ state: meta.state,
+ ...meta.entry,
+ }
+ }
+
+ const now = Date.now()
+ return {
+ state: source === "internal" ? "same" : "first",
+ id: id ?? spec,
+ source,
+ spec,
+ target,
+ first_time: now,
+ last_time: now,
+ time_changed: now,
+ load_count: 1,
+ fingerprint: target,
+ }
+}
+
+function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
+ const spec = item.id
+ const target = spec
+
+ return {
+ spec,
+ target,
+ retry: false,
+ source: "internal",
+ id: item.id,
+ module: item,
+ install_theme: createThemeInstaller(
+ {
+ scope: "global",
+ source: target,
+ },
+ process.cwd(),
+ spec,
+ ),
+ }
+}
+
+function createPluginScope(load: PluginLoad, id: string) {
+ const ctrl = new AbortController()
+ let list: { key: symbol; fn: TuiDispose }[] = []
+ let done = false
+
+ const onDispose = (fn: TuiDispose) => {
+ if (done) return () => {}
+ const key = Symbol()
+ list.push({ key, fn })
+ let drop = false
+ return () => {
+ if (drop) return
+ drop = true
+ list = list.filter((x) => x.key !== key)
+ }
+ }
+
+ const track = (fn: (() => void) | undefined) => {
+ if (!fn) return () => {}
+ const off = onDispose(fn)
+ let drop = false
+ return () => {
+ if (drop) return
+ drop = true
+ off()
+ fn()
+ }
+ }
+
+ const lifecycle: TuiPluginApi["lifecycle"] = {
+ signal: ctrl.signal,
+ onDispose,
+ }
+
+ const dispose = async () => {
+ if (done) return
+ done = true
+ ctrl.abort()
+ const queue = [...list].reverse()
+ list = []
+ const until = Date.now() + DISPOSE_TIMEOUT_MS
+ for (const item of queue) {
+ const left = until - Date.now()
+ if (left <= 0) {
+ fail("timed out cleaning up tui plugin", {
+ path: load.spec,
+ id,
+ timeout: DISPOSE_TIMEOUT_MS,
+ })
+ break
+ }
+
+ const out = await runCleanup(item.fn, left)
+ if (out.type === "ok") continue
+ if (out.type === "timeout") {
+ fail("timed out cleaning up tui plugin", {
+ path: load.spec,
+ id,
+ timeout: DISPOSE_TIMEOUT_MS,
+ })
+ break
+ }
+
+ if (out.type === "error") {
+ fail("failed to clean up tui plugin", {
+ path: load.spec,
+ id,
+ error: out.error,
+ })
+ }
+ }
+ }
+
+ return {
+ lifecycle,
+ track,
+ dispose,
+ }
+}
+
+function readPluginEnabledMap(value: unknown) {
+ if (!isRecord(value)) return {}
+ return Object.fromEntries(
+ Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
+ )
+}
+
+function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+ return {
+ ...readPluginEnabledMap(config.plugin_enabled),
+ ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
+ }
+}
+
+function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
+ api.kv.set(KV_KEY, {
+ ...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
+ [id]: enabled,
+ })
+}
+
+function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
+ return state.plugins.map((plugin) => ({
+ id: plugin.id,
+ source: plugin.meta.source,
+ spec: plugin.meta.spec,
+ target: plugin.meta.target,
+ enabled: plugin.enabled,
+ active: plugin.scope !== undefined,
+ }))
+}
+
+async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
+ plugin.enabled = false
+ if (persist) writePluginEnabledState(state.api, plugin.id, false)
+ if (!plugin.scope) return true
+ const scope = plugin.scope
+ plugin.scope = undefined
+ await scope.dispose()
+ return true
+}
+
+async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
+ plugin.enabled = true
+ if (persist) writePluginEnabledState(state.api, plugin.id, true)
+ if (plugin.scope) return true
+
+ const scope = createPluginScope(plugin.load, plugin.id)
+ const api = pluginApi(state, plugin.load, scope, plugin.id)
+ const ok = await Promise.resolve()
+ .then(async () => {
+ await plugin.plugin(api, plugin.options, plugin.meta)
+ return true
+ })
+ .catch((error) => {
+ fail("failed to initialize tui plugin", {
+ path: plugin.load.spec,
+ id: plugin.id,
+ error,
+ })
+ return false
+ })
+
+ if (!ok) {
+ await scope.dispose()
+ return false
+ }
+
+ if (!plugin.enabled) {
+ await scope.dispose()
+ return true
+ }
+
+ plugin.scope = scope
+ return true
+}
+
+async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
+ if (!state) return false
+ const plugin = state.plugins_by_id.get(id)
+ if (!plugin) return false
+ return activatePluginEntry(state, plugin, persist)
+}
+
+async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
+ if (!state) return false
+ const plugin = state.plugins_by_id.get(id)
+ if (!plugin) return false
+ return deactivatePluginEntry(state, plugin, persist)
+}
+
+function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
+ const api = runtime.api
+ const host = runtime.slots
+ const command: TuiPluginApi["command"] = {
+ register(cb) {
+ return scope.track(api.command.register(cb))
+ },
+ trigger(value) {
+ api.command.trigger(value)
+ },
+ }
+
+ const route: TuiPluginApi["route"] = {
+ register(list) {
+ return scope.track(api.route.register(list))
+ },
+ navigate(name, params) {
+ api.route.navigate(name, params)
+ },
+ get current() {
+ return api.route.current
+ },
+ }
+
+ const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
+ install: load.install_theme,
+ })
+
+ const event: TuiPluginApi["event"] = {
+ on(type, handler) {
+ return scope.track(api.event.on(type, handler))
+ },
+ }
+
+ let count = 0
+
+ const slots: TuiPluginApi["slots"] = {
+ register(plugin) {
+ const id = count ? `${base}:${count}` : base
+ count += 1
+ scope.track(host.register({ ...plugin, id }))
+ return id
+ },
+ }
+
+ return {
+ app: api.app,
+ command,
+ route,
+ ui: api.ui,
+ keybind: api.keybind,
+ tuiConfig: api.tuiConfig,
+ kv: api.kv,
+ state: api.state,
+ theme,
+ get client() {
+ return api.client
+ },
+ scopedClient: api.scopedClient,
+ workspace: api.workspace,
+ event,
+ renderer: api.renderer,
+ slots,
+ plugins: {
+ list() {
+ return listPluginStatus(runtime)
+ },
+ activate(id) {
+ return activatePluginById(runtime, id, true)
+ },
+ deactivate(id) {
+ return deactivatePluginById(runtime, id, true)
+ },
+ add(spec) {
+ return addPluginBySpec(runtime, spec)
+ },
+ install(spec, options) {
+ return installPluginBySpec(runtime, spec, options?.global)
+ },
+ },
+ lifecycle: scope.lifecycle,
+ }
+}
+
+function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
+ // TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
+ const plugin = load.module.tui
+ if (!plugin) return []
+ const options = load.item ? Config.pluginOptions(load.item) : undefined
+ return [
+ {
+ id: load.id,
+ load,
+ meta,
+ plugin,
+ options,
+ enabled: true,
+ },
+ ]
+}
+
+function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
+ if (state.plugins_by_id.has(plugin.id)) {
+ fail("duplicate tui plugin id", {
+ id: plugin.id,
+ path: plugin.load.spec,
+ })
+ return false
+ }
+
+ state.plugins_by_id.set(plugin.id, plugin)
+ state.plugins.push(plugin)
+ return true
+}
+
+function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+ const map = pluginEnabledState(state, config)
+ for (const plugin of state.plugins) {
+ const enabled = map[plugin.id]
+ if (enabled === undefined) continue
+ plugin.enabled = enabled
+ }
+}
+
+async function resolveExternalPlugins(
+ list: Config.PluginSpec[],
+ wait: () => Promise<void>,
+ meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
+) {
+ const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
+ const ready: PluginLoad[] = []
+ let deps: Promise<void> | undefined
+
+ for (let i = 0; i < list.length; i++) {
+ let entry = loaded[i]
+ if (!entry) {
+ const item = list[i]
+ if (!item) continue
+ const spec = Config.pluginSpecifier(item)
+ if (pluginSource(spec) !== "file") continue
+ deps ??= wait().catch((error) => {
+ log.warn("failed waiting for tui plugin dependencies", { error })
+ })
+ await deps
+ entry = await loadExternalPlugin(item, meta(item), true)
+ }
+ if (!entry) continue
+ ready.push(entry)
+ }
+
+ return ready
+}
+
+async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
+ if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
+
+ const meta = await PluginMeta.touchMany(
+ ready.map((item) => ({
+ spec: item.spec,
+ target: item.target,
+ id: item.id,
+ })),
+ ).catch((error) => {
+ log.warn("failed to track tui plugins", { error })
+ return undefined
+ })
+
+ const plugins: PluginEntry[] = []
+ let ok = true
+ for (let i = 0; i < ready.length; i++) {
+ const entry = ready[i]
+ if (!entry) continue
+ const hit = meta?.[i]
+ if (hit && hit.state !== "same") {
+ log.info("tui plugin metadata updated", {
+ path: entry.spec,
+ retry: entry.retry,
+ state: hit.state,
+ source: hit.entry.source,
+ version: hit.entry.version,
+ modified: hit.entry.modified,
+ })
+ }
+
+ const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
+ for (const plugin of collectPluginEntries(entry, row)) {
+ if (!addPluginEntry(state, plugin)) {
+ ok = false
+ continue
+ }
+ plugins.push(plugin)
+ }
+ }
+
+ return { plugins, ok }
+}
+
+function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
+ return {
+ scope: "local",
+ source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
+ }
+}
+
+function installCause(err: unknown) {
+ if (!err || typeof err !== "object") return
+ if (!("cause" in err)) return
+ return (err as { cause?: unknown }).cause
+}
+
+function installDetail(err: unknown) {
+ const hit = installCause(err) ?? err
+ if (!(hit instanceof Process.RunFailedError)) {
+ return {
+ message: errorMessage(hit),
+ missing: false,
+ }
+ }
+
+ const lines = hit.stderr
+ .toString()
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
+ return {
+ message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
+ missing: lines.some((line) => line.includes("No version matching")),
+ }
+}
+
+async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
+ if (!state) return false
+ const spec = raw.trim()
+ if (!spec) return false
+
+ const pending = state.pending.get(spec)
+ const item = pending?.item ?? spec
+ const nextSpec = Config.pluginSpecifier(item)
+ if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
+ state.pending.delete(spec)
+ return true
+ }
+
+ const meta = pending?.meta ?? defaultPluginMeta(state)
+
+ const ready = await Instance.provide({
+ directory: state.directory,
+ fn: () =>
+ resolveExternalPlugins(
+ [item],
+ () => TuiConfig.waitForDependencies(),
+ () => meta,
+ ),
+ }).catch((error) => {
+ fail("failed to add tui plugin", { path: nextSpec, error })
+ return [] as PluginLoad[]
+ })
+ if (!ready.length) {
+ fail("failed to add tui plugin", { path: nextSpec })
+ return false
+ }
+
+ const first = ready[0]
+ if (!first) {
+ fail("failed to add tui plugin", { path: nextSpec })
+ return false
+ }
+ if (state.plugins_by_id.has(first.id)) {
+ state.pending.delete(spec)
+ return true
+ }
+
+ const out = await addExternalPluginEntries(state, [first])
+ let ok = out.ok && out.plugins.length > 0
+ for (const plugin of out.plugins) {
+ const active = await activatePluginEntry(state, plugin, false)
+ if (!active) ok = false
+ }
+
+ if (ok) state.pending.delete(spec)
+ if (!ok) {
+ fail("failed to add tui plugin", { path: nextSpec })
+ }
+ return ok
+}
+
+async function installPluginBySpec(
+ state: RuntimeState | undefined,
+ raw: string,
+ global = false,
+): Promise<TuiPluginInstallResult> {
+ if (!state) {
+ return {
+ ok: false,
+ message: "Plugin runtime is not ready.",
+ }
+ }
+
+ const spec = raw.trim()
+ if (!spec) {
+ return {
+ ok: false,
+ message: "Plugin package name is required",
+ }
+ }
+
+ const dir = state.api.state.path
+ if (!dir.directory) {
+ return {
+ ok: false,
+ message: "Paths are still syncing. Try again in a moment.",
+ }
+ }
+
+ const install = await installModulePlugin(spec)
+ if (!install.ok) {
+ const out = installDetail(install.error)
+ return {
+ ok: false,
+ message: out.message,
+ missing: out.missing,
+ }
+ }
+
+ const manifest = await readPluginManifest(install.target)
+ if (!manifest.ok) {
+ if (manifest.code === "manifest_no_targets") {
+ return {
+ ok: false,
+ message: `"${spec}" does not declare supported targets in package.json`,
+ }
+ }
+
+ return {
+ ok: false,
+ message: `Installed "${spec}" but failed to read ${manifest.file}`,
+ }
+ }
+
+ const patch = await patchPluginConfig({
+ spec,
+ targets: manifest.targets,
+ global,
+ vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
+ worktree: dir.worktree,
+ directory: dir.directory,
+ })
+ if (!patch.ok) {
+ if (patch.code === "invalid_json") {
+ return {
+ ok: false,
+ message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
+ }
+ }
+
+ return {
+ ok: false,
+ message: errorMessage(patch.error),
+ }
+ }
+
+ const tui = manifest.targets.find((item) => item.kind === "tui")
+ if (tui) {
+ const file = patch.items.find((item) => item.kind === "tui")?.file
+ state.pending.set(spec, {
+ item: tui.opts ? [spec, tui.opts] : spec,
+ meta: {
+ scope: global ? "global" : "local",
+ source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
+ },
+ })
+ }
+
+ return {
+ ok: true,
+ dir: patch.dir,
+ tui: Boolean(tui),
+ }
+}
+
+export namespace TuiPluginRuntime {
+ let dir = ""
+ let loaded: Promise<void> | undefined
+ let runtime: RuntimeState | undefined
+ export const Slot = View
+
+ export async function init(api: HostPluginApi) {
+ const cwd = process.cwd()
+ if (loaded) {
+ if (dir !== cwd) {
+ throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
+ }
+ return loaded
+ }
+
+ dir = cwd
+ loaded = load(api)
+ return loaded
+ }
+
+ export function list() {
+ if (!runtime) return []
+ return listPluginStatus(runtime)
+ }
+
+ export async function activatePlugin(id: string) {
+ return activatePluginById(runtime, id, true)
+ }
+
+ export async function deactivatePlugin(id: string) {
+ return deactivatePluginById(runtime, id, true)
+ }
+
+ export async function addPlugin(spec: string) {
+ return addPluginBySpec(runtime, spec)
+ }
+
+ export async function installPlugin(spec: string, options?: { global?: boolean }) {
+ return installPluginBySpec(runtime, spec, options?.global)
+ }
+
+ export async function dispose() {
+ const task = loaded
+ loaded = undefined
+ dir = ""
+ if (task) await task
+ const state = runtime
+ runtime = undefined
+ if (!state) return
+ const queue = [...state.plugins].reverse()
+ for (const plugin of queue) {
+ await deactivatePluginEntry(state, plugin, false)
+ }
+ }
+
+ async function load(api: Api) {
+ const cwd = process.cwd()
+ const slots = setupSlots(api)
+ const next: RuntimeState = {
+ directory: cwd,
+ api,
+ slots,
+ plugins: [],
+ plugins_by_id: new Map(),
+ pending: new Map(),
+ }
+ runtime = next
+
+ await Instance.provide({
+ directory: cwd,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
+ if (Flag.OPENCODE_PURE && config.plugin?.length) {
+ log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
+ }
+
+ for (const item of INTERNAL_TUI_PLUGINS) {
+ log.info("loading internal tui plugin", { id: item.id })
+ const entry = loadInternalPlugin(item)
+ const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
+ for (const plugin of collectPluginEntries(entry, meta)) {
+ addPluginEntry(next, plugin)
+ }
+ }
+
+ const ready = await resolveExternalPlugins(
+ plugins,
+ () => TuiConfig.waitForDependencies(),
+ (item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
+ )
+ await addExternalPluginEntries(next, ready)
+
+ applyInitialPluginEnabledState(next, config)
+ for (const plugin of next.plugins) {
+ if (!plugin.enabled) continue
+ // Keep plugin execution sequential for deterministic side effects:
+ // command registration order affects keybind/command precedence,
+ // route registration is last-wins when ids collide,
+ // and hook chains rely on stable plugin ordering.
+ await activatePluginEntry(next, plugin, false)
+ }
+ },
+ }).catch((error) => {
+ fail("failed to load tui plugins", { directory: cwd, error })
+ })
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
new file mode 100644
index 000000000..3fd77875e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
@@ -0,0 +1,61 @@
+import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
+import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
+import { isRecord } from "@/util/record"
+
+type SlotProps<K extends keyof TuiSlotMap> = {
+ name: K
+ mode?: SlotMode
+ children?: JSX.Element
+} & TuiSlotMap[K]
+
+type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
+export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
+
+export type HostPluginApi = TuiPluginApi
+export type HostSlots = {
+ register: (plugin: HostSlotPlugin) => () => void
+}
+
+function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
+ return null
+}
+
+let view: Slot = empty
+
+export const Slot: Slot = (props) => view(props)
+
+function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
+ if (!isRecord(value)) return false
+ if (typeof value.id !== "string") return false
+ if (!isRecord(value.slots)) return false
+ return true
+}
+
+export function setupSlots(api: HostPluginApi): HostSlots {
+ const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
+ api.renderer,
+ {
+ theme: api.theme,
+ },
+ {
+ onPluginError(event) {
+ console.error("[tui.slot] plugin error", {
+ plugin: event.pluginId,
+ slot: event.slot,
+ phase: event.phase,
+ source: event.source,
+ message: event.error.message,
+ })
+ },
+ },
+ )
+
+ const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
+ view = (props) => slot(props)
+ return {
+ register(plugin) {
+ if (!isHostSlotPlugin(plugin)) return () => {}
+ return reg.register(plugin)
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index e76e165b2..07549c6c2 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -1,9 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
-import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo"
-import { Tips } from "../component/tips"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
@@ -12,20 +10,17 @@ import { useDirectory } from "../context/directory"
import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
-import { useKV } from "../context/kv"
-import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
+import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false
export function Home() {
const sync = useSync()
- const kv = useKV()
const { theme } = useTheme()
const route = useRouteData("home")
const promptRef = usePromptRef()
- const command = useCommandDialog()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
@@ -35,30 +30,9 @@ export function Home() {
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
})
- const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
- const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
- const showTips = createMemo(() => {
- // Don't show tips for first-time users
- if (isFirstTimeUser()) return false
- return !tipsHidden()
- })
-
- command.register(() => [
- {
- title: tipsHidden() ? "Show tips" : "Hide tips",
- value: "tips.toggle",
- keybind: "tips_toggle",
- category: "System",
- onSelect: (dialog) => {
- kv.set("tips_hidden", !tipsHidden())
- dialog.clear()
- },
- },
- ])
-
const Hint = (
- <Show when={connectedMcpCount() > 0}>
- <box flexShrink={0} flexDirection="row" gap={1}>
+ <box flexShrink={0} flexDirection="row" gap={1}>
+ <Show when={connectedMcpCount() > 0}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@@ -71,8 +45,8 @@ export function Home() {
</Match>
</Switch>
</text>
- </box>
- </Show>
+ </Show>
+ </box>
)
let prompt: PromptRef
@@ -103,15 +77,15 @@ export function Home() {
)
const directory = useDirectory()
- const keybind = useKeybind()
-
return (
<>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
- <Logo />
+ <TuiPluginRuntime.Slot name="home_logo" mode="replace">
+ <Logo />
+ </TuiPluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
@@ -124,11 +98,7 @@ export function Home() {
workspaceID={route.workspaceID}
/>
</box>
- <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
- <Show when={showTips()}>
- <Tips />
- </Show>
- </box>
+ <TuiPluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 0d9ddc746..080065fd7 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
-import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 42ac5fbe0..66bf82dba 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,72 +1,13 @@
import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
-import { Locale } from "@/util/locale"
-import path from "path"
-import type { AssistantMessage } from "@opencode-ai/sdk/v2"
-import { Global } from "@/global"
import { Installation } from "@/installation"
-import { useKeybind } from "../../context/keybind"
-import { useDirectory } from "../../context/directory"
-import { useKV } from "../../context/kv"
-import { TodoItem } from "../../component/todo-item"
+import { TuiPluginRuntime } from "../../plugin"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
- const session = createMemo(() => sync.session.get(props.sessionID)!)
- const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
- const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
- const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
-
- const [expanded, setExpanded] = createStore({
- mcp: true,
- diff: true,
- todo: true,
- lsp: true,
- })
-
- // Sort MCP servers alphabetically for consistent display order
- const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
-
- // Count connected and error MCP servers for collapsed header display
- const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
- const errorMcpCount = createMemo(
- () =>
- mcpEntries().filter(
- ([_, item]) =>
- item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
- ).length,
- )
-
- const cost = createMemo(() => {
- const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(total)
- })
-
- const context = createMemo(() => {
- const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
- if (!last) return
- const total =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
- const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
- return {
- tokens: total.toLocaleString(),
- percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
- }
- })
-
- const directory = useDirectory()
- const kv = useKV()
-
- const hasProviders = createMemo(() =>
- sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
- )
- const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
+ const session = createMemo(() => sync.session.get(props.sessionID))
return (
<Show when={session()}>
@@ -90,230 +31,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
- <box paddingRight={1}>
- <text fg={theme.text}>
- <b>{session().title}</b>
- </text>
- <Show when={session().share?.url}>
- <text fg={theme.textMuted}>{session().share!.url}</text>
- </Show>
- </box>
- <box>
- <text fg={theme.text}>
- <b>Context</b>
- </text>
- <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
- <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
- <text fg={theme.textMuted}>{cost()} spent</text>
- </box>
- <Show when={mcpEntries().length > 0}>
- <box>
- <box
- flexDirection="row"
- gap={1}
- onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
- >
- <Show when={mcpEntries().length > 2}>
- <text fg={theme.text}>{expanded.mcp ? "â–¼" : "â–¶"}</text>
- </Show>
- <text fg={theme.text}>
- <b>MCP</b>
- <Show when={!expanded.mcp}>
- <span style={{ fg: theme.textMuted }}>
- {" "}
- ({connectedMcpCount()} active
- {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
- </span>
- </Show>
- </text>
- </box>
- <Show when={mcpEntries().length <= 2 || expanded.mcp}>
- <For each={mcpEntries()}>
- {([key, item]) => (
- <box flexDirection="row" gap={1}>
- <text
- flexShrink={0}
- style={{
- fg: (
- {
- connected: theme.success,
- failed: theme.error,
- disabled: theme.textMuted,
- needs_auth: theme.warning,
- needs_client_registration: theme.error,
- } as Record<string, typeof theme.success>
- )[item.status],
- }}
- >
- •
- </text>
- <text fg={theme.text} wrapMode="word">
- {key}{" "}
- <span style={{ fg: theme.textMuted }}>
- <Switch fallback={item.status}>
- <Match when={item.status === "connected"}>Connected</Match>
- <Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
- <Match when={item.status === "disabled"}>Disabled</Match>
- <Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
- <Match when={(item.status as string) === "needs_client_registration"}>
- Needs client ID
- </Match>
- </Switch>
- </span>
- </text>
- </box>
- )}
- </For>
- </Show>
- </box>
- </Show>
- <box>
- <box
- flexDirection="row"
- gap={1}
- onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
- >
- <Show when={sync.data.lsp.length > 2}>
- <text fg={theme.text}>{expanded.lsp ? "â–¼" : "â–¶"}</text>
- </Show>
+ <TuiPluginRuntime.Slot
+ name="sidebar_title"
+ mode="single_winner"
+ session_id={props.sessionID}
+ title={session()!.title}
+ share_url={session()!.share?.url}
+ >
+ <box paddingRight={1}>
<text fg={theme.text}>
- <b>LSP</b>
+ <b>{session()!.title}</b>
</text>
- </box>
- <Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
- <Show when={sync.data.lsp.length === 0}>
- <text fg={theme.textMuted}>
- {sync.data.config.lsp === false
- ? "LSPs have been disabled in settings"
- : "LSPs will activate as files are read"}
- </text>
- </Show>
- <For each={sync.data.lsp}>
- {(item) => (
- <box flexDirection="row" gap={1}>
- <text
- flexShrink={0}
- style={{
- fg: {
- connected: theme.success,
- error: theme.error,
- }[item.status],
- }}
- >
- •
- </text>
- <text fg={theme.textMuted}>
- {item.id} {item.root}
- </text>
- </box>
- )}
- </For>
- </Show>
- </box>
- <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
- <box>
- <box
- flexDirection="row"
- gap={1}
- onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
- >
- <Show when={todo().length > 2}>
- <text fg={theme.text}>{expanded.todo ? "â–¼" : "â–¶"}</text>
- </Show>
- <text fg={theme.text}>
- <b>Todo</b>
- </text>
- </box>
- <Show when={todo().length <= 2 || expanded.todo}>
- <For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
+ <Show when={session()!.share?.url}>
+ <text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
</box>
- </Show>
- <Show when={diff().length > 0}>
- <box>
- <box
- flexDirection="row"
- gap={1}
- onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
- >
- <Show when={diff().length > 2}>
- <text fg={theme.text}>{expanded.diff ? "â–¼" : "â–¶"}</text>
- </Show>
- <text fg={theme.text}>
- <b>Modified Files</b>
- </text>
- </box>
- <Show when={diff().length <= 2 || expanded.diff}>
- <For each={diff() || []}>
- {(item) => {
- return (
- <box flexDirection="row" gap={1} justifyContent="space-between">
- <text fg={theme.textMuted} wrapMode="none">
- {item.file}
- </text>
- <box flexDirection="row" gap={1} flexShrink={0}>
- <Show when={item.additions}>
- <text fg={theme.diffAdded}>+{item.additions}</text>
- </Show>
- <Show when={item.deletions}>
- <text fg={theme.diffRemoved}>-{item.deletions}</text>
- </Show>
- </box>
- </box>
- )
- }}
- </For>
- </Show>
- </box>
- </Show>
+ </TuiPluginRuntime.Slot>
+ <TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
- <Show when={!hasProviders() && !gettingStartedDismissed()}>
- <box
- backgroundColor={theme.backgroundElement}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- paddingRight={2}
- flexDirection="row"
- gap={1}
- >
- <text flexShrink={0} fg={theme.text}>
- ⬖
- </text>
- <box flexGrow={1} gap={1}>
- <box flexDirection="row" justifyContent="space-between">
- <text fg={theme.text}>
- <b>Getting started</b>
- </text>
- <text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
- ✕
- </text>
- </box>
- <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
- <text fg={theme.textMuted}>
- Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
- </text>
- <box flexDirection="row" gap={1} justifyContent="space-between">
- <text fg={theme.text}>Connect provider</text>
- <text fg={theme.textMuted}>/connect</text>
- </box>
- </box>
- </box>
- </Show>
- <text>
- <span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
- <span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
- </text>
- <text fg={theme.textMuted}>
- <span style={{ fg: theme.success }}>•</span> <b>Open</b>
- <span style={{ fg: theme.text }}>
- <b>Code</b>
- </span>{" "}
- <span>{Installation.VERSION}</span>
- </text>
+ <TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
+ <text fg={theme.textMuted}>
+ <span style={{ fg: theme.success }}>•</span> <b>Open</b>
+ <span style={{ fg: theme.text }}>
+ <b>Code</b>
+ </span>{" "}
+ <span>{Installation.VERSION}</span>
+ </text>
+ </TuiPluginRuntime.Slot>
</box>
</box>
</Show>
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index d984dc6f3..3bb56937a 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -6,6 +6,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
+import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
@@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
const reload = () => {
client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
- error: err instanceof Error ? err.message : String(err),
+ error: errorMessage(err),
})
})
}
@@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", {
- error: error instanceof Error ? error.message : String(error),
+ error: errorMessage(error),
})
})
worker.terminate()
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 43f1a1ff5..11c43fe24 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -9,7 +9,7 @@ import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
- size?: "medium" | "large"
+ size?: "medium" | "large" | "xlarge"
onClose: () => void
}>,
) {
@@ -18,6 +18,11 @@ export function Dialog(
const renderer = useRenderer()
let dismiss = false
+ const width = () => {
+ if (props.size === "xlarge") return 116
+ if (props.size === "large") return 88
+ return 60
+ }
return (
<box
@@ -35,6 +40,7 @@ export function Dialog(
height={dimensions().height}
alignItems="center"
position="absolute"
+ zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
@@ -45,7 +51,7 @@ export function Dialog(
dismiss = false
e.stopPropagation()
}}
- width={props.size === "large" ? 80 : 60}
+ width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
@@ -62,7 +68,7 @@ function init() {
element: JSX.Element
onClose?: () => void
}[],
- size: "medium" as "medium" | "large",
+ size: "medium" as "medium" | "large" | "xlarge",
})
const renderer = useRenderer()
@@ -72,6 +78,9 @@ function init() {
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
+ if (renderer.getSelection()) {
+ renderer.clearSelection()
+ }
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -132,7 +141,7 @@ function init() {
get size() {
return store.size
},
- setSize(size: "medium" | "large") {
+ setSize(size: "medium" | "large" | "xlarge") {
setStore("size", size)
},
}
@@ -151,6 +160,7 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
+ zIndex={3000}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts
index d7120aa5e..52bad892e 100644
--- a/packages/opencode/src/cli/error.ts
+++ b/packages/opencode/src/cli/error.ts
@@ -1,4 +1,5 @@
import { ConfigMarkdown } from "@/config/markdown"
+import { errorFormat } from "@/util/error"
import { Config } from "../config/config"
import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
@@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
}
export function FormatUnknownError(input: unknown): string {
- if (input instanceof Error) {
- return input.stack ?? `${input.name}: ${input.message}`
- }
-
- if (typeof input === "object" && input !== null) {
- try {
- return JSON.stringify(input, null, 2)
- } catch {
- return "Unexpected error (unserializable)"
- }
- }
-
- return String(input)
+ return errorFormat(input)
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 41fa4a1ca..3cbb34162 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -30,20 +30,27 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
-import { proxied } from "@/util/proxied"
+import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife"
import { Account } from "@/account"
+import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
-import { Lock } from "@/util/lock"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
+import { Flock } from "@/util/flock"
+import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+ const PluginOptions = z.record(z.string(), z.unknown())
+ export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
+
+ export type PluginOptions = z.infer<typeof PluginOptions>
+ export type PluginSpec = z.infer<typeof PluginSpec>
const log = Log.create({ service: "config" })
@@ -78,34 +85,65 @@ export namespace Config {
return merged
}
- export async function installDependencies(dir: string) {
+ export type InstallInput = {
+ signal?: AbortSignal
+ waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
+ }
+
+ export async function installDependencies(dir: string, input?: InstallInput) {
+ if (!(await needsInstall(dir))) return
+
+ await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
+ signal: input?.signal,
+ onWait: (tick) =>
+ input?.waitTick?.({
+ dir,
+ attempt: tick.attempt,
+ delay: tick.delay,
+ waited: tick.waited,
+ }),
+ })
+
+ input?.signal?.throwIfAborted()
+ if (!(await needsInstall(dir))) return
+
const pkg = path.join(dir, "package.json")
- const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
+ const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
- "@opencode-ai/plugin": targetVersion,
+ "@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
- const hasGitIgnore = await Filesystem.exists(gitignore)
- if (!hasGitIgnore)
+ const ignore = await Filesystem.exists(gitignore)
+ if (!ignore) {
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
+ }
+
+ // Bun can race cache writes on Windows when installs run in parallel across dirs.
+ // Serialize installs globally on win32, but keep parallel installs on other platforms.
+ await using __ =
+ process.platform === "win32"
+ ? await Flock.acquire("config-install:bun", {
+ signal: input?.signal,
+ })
+ : undefined
- // Install any additional dependencies defined in the package.json
- // This allows local plugins and custom tools to use external packages
- using _ = await Lock.write("bun-install")
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
- { cwd: dir },
+ {
+ cwd: dir,
+ abort: input?.signal,
+ },
).catch((err) => {
if (err instanceof Process.RunFailedError) {
const detail = {
@@ -149,8 +187,8 @@ export namespace Config {
return false
}
- const nodeModules = path.join(dir, "node_modules")
- if (!existsSync(nodeModules)) return true
+ const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
+ if (!existsSync(mod)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
@@ -163,8 +201,9 @@ export namespace Config {
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
- const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
- if (!isOutdated) return false
+ if (!online()) return false
+ const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
+ if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
@@ -303,7 +342,7 @@ export namespace Config {
}
async function loadPlugin(dir: string) {
- const plugins: string[] = []
+ const plugins: PluginSpec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
@@ -316,25 +355,44 @@ export namespace Config {
return plugins
}
- /**
- * Extracts a canonical plugin name from a plugin specifier.
- * - For file:// URLs: extracts filename without extension
- * - For npm packages: extracts package name without version
- *
- * @example
- * getPluginName("file:///path/to/plugin/foo.js") // "foo"
- * getPluginName("[email protected]") // "oh-my-opencode"
- * getPluginName("@scope/[email protected]") // "@scope/pkg"
- */
- export function getPluginName(plugin: string): string {
- if (plugin.startsWith("file://")) {
- return path.parse(new URL(plugin).pathname).name
+ export function pluginSpecifier(plugin: PluginSpec): string {
+ return Array.isArray(plugin) ? plugin[0] : plugin
+ }
+
+ export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
+ return Array.isArray(plugin) ? plugin[1] : undefined
+ }
+
+ export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
+ const spec = pluginSpecifier(plugin)
+ if (!isPathPluginSpec(spec)) return plugin
+ if (spec.startsWith("file://")) {
+ const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
+ }
+ if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
+ const base = pathToFileURL(spec).href
+ const resolved = await resolvePathPluginTarget(base).catch(() => base)
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
}
- const lastAt = plugin.lastIndexOf("@")
- if (lastAt > 0) {
- return plugin.substring(0, lastAt)
+ try {
+ const base = import.meta.resolve!(spec, configFilepath)
+ const resolved = await resolvePathPluginTarget(base).catch(() => base)
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
+ } catch {
+ try {
+ const require = createRequire(configFilepath)
+ const base = pathToFileURL(require.resolve(spec)).href
+ const resolved = await resolvePathPluginTarget(base).catch(() => base)
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
+ } catch {
+ return plugin
+ }
}
- return plugin
}
/**
@@ -348,17 +406,13 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
- export function deduplicatePlugins(plugins: string[]): string[] {
- // seenNames: canonical plugin names for duplicate detection
- // e.g., "oh-my-opencode", "@scope/pkg"
+ export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
const seenNames = new Set<string>()
-
- // uniqueSpecifiers: full plugin specifiers to return
- // e.g., "[email protected]", "file:///path/to/plugin.js"
- const uniqueSpecifiers: string[] = []
+ const uniqueSpecifiers: PluginSpec[] = []
for (const specifier of plugins.toReversed()) {
- const name = getPluginName(specifier)
+ const spec = pluginSpecifier(specifier)
+ const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (!seenNames.has(name)) {
seenNames.add(name)
uniqueSpecifiers.push(specifier)
@@ -757,6 +811,7 @@ export namespace Config {
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
+ plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
})
.strict()
@@ -858,13 +913,13 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
- plugin: z.string().array().optional(),
snapshot: z
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
+ plugin: PluginSpec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -1070,10 +1125,6 @@ export namespace Config {
return candidates[0]
}
- function isRecord(value: unknown): value is Record<string, unknown> {
- return !!value && typeof value === "object" && !Array.isArray(value)
- }
-
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
@@ -1189,19 +1240,9 @@ export namespace Config {
}
const data = parsed.data
if (data.plugin && isFile) {
- for (let i = 0; i < data.plugin.length; i++) {
- const plugin = data.plugin[i]
- try {
- data.plugin[i] = import.meta.resolve!(plugin, options.path)
- } catch (e) {
- try {
- const require = createRequire(options.path)
- const resolvedPath = require.resolve(plugin)
- data.plugin[i] = pathToFileURL(resolvedPath).href
- } catch {
- // Ignore, plugin might be a generic string identifier like "mcp-server"
- }
- }
+ const list = data.plugin
+ for (let i = 0; i < list.length; i++) {
+ list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
}
return data
@@ -1326,12 +1367,14 @@ export namespace Config {
}
}
- deps.push(
- iife(async () => {
- const shouldInstall = await needsInstall(dir)
- if (shouldInstall) await installDependencies(dir)
- }),
- )
+ const dep = iife(async () => {
+ const stale = await needsInstall(dir)
+ if (stale) await installDependencies(dir)
+ })
+ void dep.catch((err) => {
+ log.warn("background dependency install failed", { dir, error: err })
+ })
+ deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts
index f9068e3f0..b126d3c96 100644
--- a/packages/opencode/src/config/tui-schema.ts
+++ b/packages/opencode/src/config/tui-schema.ts
@@ -29,6 +29,8 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
+ plugin: Config.PluginSpec.array().optional(),
+ plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
.strict()
diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts
index f0964f63b..857b67396 100644
--- a/packages/opencode/src/config/tui.ts
+++ b/packages/opencode/src/config/tui.ts
@@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
+import { isRecord } from "@/util/record"
import { Global } from "@/global"
+import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
- export type Info = z.output<typeof Info>
+ export type PluginMeta = {
+ scope: "global" | "local"
+ source: string
+ }
+
+ type PluginEntry = {
+ item: Config.PluginSpec
+ meta: PluginMeta
+ }
+
+ type Acc = {
+ result: Info
+ entries: PluginEntry[]
+ }
+
+ export type Info = z.output<typeof Info> & {
+ plugin_meta?: Record<string, PluginMeta>
+ }
+
+ function pluginScope(file: string): PluginMeta["scope"] {
+ if (Instance.containsPath(file)) return "local"
+ return "global"
+ }
+
+ function dedupePlugins(list: PluginEntry[]) {
+ const seen = new Set<string>()
+ const result: PluginEntry[] = []
+ for (const item of list.toReversed()) {
+ const spec = Config.pluginSpecifier(item.item)
+ const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
+ if (seen.has(name)) continue
+ seen.add(name)
+ result.push(item)
+ }
+ return result.toReversed()
+ }
function mergeInfo(target: Info, source: Info): Info {
- return mergeDeep(target, source)
+ const merged = mergeDeep(target, source)
+ if (target.plugin && source.plugin) {
+ merged.plugin = [...target.plugin, ...source.plugin]
+ }
+ return merged
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
+ function normalize(raw: Record<string, unknown>) {
+ const data = { ...raw }
+ if (!("tui" in data)) return data
+ if (!isRecord(data.tui)) {
+ delete data.tui
+ return data
+ }
+
+ const tui = data.tui
+ delete data.tui
+ return {
+ ...tui,
+ ...data,
+ }
+ }
+
+ function installDeps(dir: string): Promise<void> {
+ return Config.installDependencies(dir)
+ }
+
+ async function mergeFile(acc: Acc, file: string) {
+ const data = await loadFile(file)
+ acc.result = mergeInfo(acc.result, data)
+ if (!data.plugin?.length) return
+
+ const scope = pluginScope(file)
+ for (const item of data.plugin) {
+ acc.entries.push({
+ item,
+ meta: {
+ scope,
+ source: file,
+ },
+ })
+ }
+ }
+
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
@@ -38,38 +116,55 @@ export namespace TuiConfig {
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
- let result: Info = {}
+ const acc: Acc = {
+ result: {},
+ entries: [],
+ }
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
- result = mergeInfo(result, await loadFile(file))
+ await mergeFile(acc, file)
}
if (custom) {
- result = mergeInfo(result, await loadFile(custom))
+ await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
- result = mergeInfo(result, await loadFile(file))
+ await mergeFile(acc, file)
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
- result = mergeInfo(result, await loadFile(file))
+ await mergeFile(acc, file)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
- result = mergeInfo(result, await loadFile(file))
+ await mergeFile(acc, file)
}
}
- result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
+ const merged = dedupePlugins(acc.entries)
+ acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
+ acc.result.plugin = merged.map((item) => item.item)
+ acc.result.plugin_meta = merged.length
+ ? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
+ : undefined
+
+ const deps: Promise<void>[] = []
+ if (acc.result.plugin?.length) {
+ for (const dir of unique(directories)) {
+ if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+ deps.push(installDeps(dir))
+ }
+ }
return {
- config: result,
+ config: acc.result,
+ deps,
}
})
@@ -77,6 +172,11 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
+ export async function waitForDependencies() {
+ const deps = await state().then((x) => x.deps)
+ await Promise.all(deps)
+ }
+
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -87,25 +187,12 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
- const data = await ConfigPaths.parseText(text, configFilepath, "empty")
- if (!data || typeof data !== "object" || Array.isArray(data)) return {}
+ const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
+ if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
- const normalized = (() => {
- const copy = { ...(data as Record<string, unknown>) }
- if (!("tui" in copy)) return copy
- if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
- delete copy.tui
- return copy
- }
- const tui = copy.tui as Record<string, unknown>
- delete copy.tui
- return {
- ...tui,
- ...copy,
- }
- })()
+ const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
@@ -113,6 +200,13 @@ export namespace TuiConfig {
return {}
}
- return parsed.data
+ const data = parsed.data
+ if (data.plugin) {
+ for (let i = 0; i < data.plugin.length; i++) {
+ data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
+ }
+ }
+
+ return data
}
}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index b35f84c8e..27190f2eb 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -14,13 +14,16 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
+ export declare const OPENCODE_PURE: boolean
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
+ export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
+ export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false,
})
+// Dynamic getter for OPENCODE_PURE
+// This must be evaluated at access time, not module load time,
+// because the CLI can set this flag at runtime
+Object.defineProperty(Flag, "OPENCODE_PURE", {
+ get() {
+ return truthy("OPENCODE_PURE")
+ },
+ enumerable: true,
+ configurable: false,
+})
+
+// Dynamic getter for OPENCODE_PLUGIN_META_FILE
+// This must be evaluated at access time, not module load time,
+// because tests and external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
+ get() {
+ return process.env["OPENCODE_PLUGIN_META_FILE"]
+ },
+ enumerable: true,
+ configurable: false,
+})
+
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index e27471068..2da35ace1 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -33,16 +33,18 @@ import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
+import { errorMessage } from "./util/error"
+import { PluginCommand } from "./cli/cmd/plug"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
- e: e instanceof Error ? e.message : e,
+ e: errorMessage(e),
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
- e: e instanceof Error ? e.message : e,
+ e: errorMessage(e),
})
})
@@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv))
type: "string",
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
})
+ .option("pure", {
+ describe: "run without external plugins",
+ type: "boolean",
+ })
.middleware(async (opts) => {
+ if (opts.pure) {
+ process.env.OPENCODE_PURE = "1"
+ }
+
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
@@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
+ .command(PluginCommand)
.command(DbCommand)
.fail((msg, err) => {
if (
@@ -194,7 +205,7 @@ try {
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
- process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
+ process.stderr.write(errorMessage(e) + EOL)
}
process.exitCode = 1
} finally {
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index df644c42a..e7bb2a91d 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -1,9 +1,8 @@
-import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
+import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
-import { BunProc } from "../bun"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
@@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
+import { errorMessage } from "@/util/error"
+import { Installation } from "@/installation"
+import {
+ checkPluginCompatibility,
+ getDefaultPlugin,
+ isDeprecatedPlugin,
+ parsePluginSpecifier,
+ pluginSource,
+ resolvePluginEntrypoint,
+ resolvePluginTarget,
+} from "./shared"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -22,6 +32,12 @@ export namespace Plugin {
hooks: Hooks[]
}
+ type Loaded = {
+ item: Config.PluginSpec
+ spec: string
+ mod: Record<string, unknown>
+ }
+
// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -46,8 +62,115 @@ export namespace Plugin {
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
- // Old npm package names for plugins that are now built-in — skip if users still have them in config
- const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
+ function isServerPlugin(value: unknown): value is PluginInstance {
+ return typeof value === "function"
+ }
+
+ function getServerPlugin(value: unknown) {
+ if (isServerPlugin(value)) return value
+ if (!value || typeof value !== "object" || !("server" in value)) return
+ if (!isServerPlugin(value.server)) return
+ return value.server
+ }
+
+ function getLegacyPlugins(mod: Record<string, unknown>) {
+ const seen = new Set<unknown>()
+ const result: PluginInstance[] = []
+
+ for (const entry of Object.values(mod)) {
+ if (seen.has(entry)) continue
+ seen.add(entry)
+ const plugin = getServerPlugin(entry)
+ if (!plugin) throw new TypeError("Plugin export is not a function")
+ result.push(plugin)
+ }
+
+ return result
+ }
+
+ async function resolvePlugin(spec: string) {
+ const parsed = parsePluginSpecifier(spec)
+ const target = await resolvePluginTarget(spec, parsed).catch((err) => {
+ const cause = err instanceof Error ? err.cause : err
+ const detail = errorMessage(cause ?? err)
+ log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
+ }).toObject(),
+ })
+ return ""
+ })
+ if (!target) return
+ return target
+ }
+
+ async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
+ const spec = Config.pluginSpecifier(item)
+ if (isDeprecatedPlugin(spec)) return
+ log.info("loading plugin", { path: spec })
+ const resolved = await resolvePlugin(spec)
+ if (!resolved) return
+
+ if (pluginSource(spec) === "npm") {
+ const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
+ .then(() => false)
+ .catch((err) => {
+ const message = errorMessage(err)
+ log.warn("plugin incompatible", { path: spec, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Plugin ${spec} skipped: ${message}`,
+ }).toObject(),
+ })
+ return true
+ })
+ if (incompatible) return
+ }
+
+ const target = resolved
+ const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
+ const message = errorMessage(err)
+ log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to load plugin ${spec}: ${message}`,
+ }).toObject(),
+ })
+ return
+ })
+ if (!entry) return
+
+ const mod = await import(entry).catch((err) => {
+ const message = errorMessage(err)
+ log.error("failed to load plugin", { path: spec, target: entry, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to load plugin ${spec}: ${message}`,
+ }).toObject(),
+ })
+ return
+ })
+ if (!mod) return
+
+ return {
+ item,
+ spec,
+ mod,
+ }
+ }
+
+ async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
+ const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
+ if (plugin?.server) {
+ hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
+ return
+ }
+
+ for (const server of getLegacyPlugins(load.mod)) {
+ hooks.push(await server(input, Config.pluginOptions(load.item)))
+ }
+ }
export const layer = Layer.effect(
Service,
@@ -91,51 +214,27 @@ export namespace Plugin {
if (init) hooks.push(init)
}
- let plugins = cfg.plugin ?? []
+ const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
+ if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
+ log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
+ }
if (plugins.length) await Config.waitForDependencies()
- for (let plugin of plugins) {
- if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
- log.info("loading plugin", { path: plugin })
- if (!plugin.startsWith("file://")) {
- const idx = plugin.lastIndexOf("@")
- const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
- const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
- plugin = await BunProc.install(pkg, version).catch((err) => {
- const cause = err instanceof Error ? err.cause : err
- const detail = cause instanceof Error ? cause.message : String(cause ?? err)
- log.error("failed to install plugin", { pkg, version, error: detail })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
- }).toObject(),
- })
- return ""
- })
- if (!plugin) continue
- }
+ const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
+ for (const load of loaded) {
+ if (!load) continue
- // Prevent duplicate initialization when plugins export the same function
- // as both a named export and default export (e.g., `export const X` and `export default X`).
- // Object.entries(mod) would return both entries pointing to the same function reference.
- await import(plugin)
- .then(async (mod) => {
- const seen = new Set<PluginInstance>()
- for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
- if (seen.has(fn)) continue
- seen.add(fn)
- hooks.push(await fn(input))
- }
- })
- .catch((err) => {
- const message = err instanceof Error ? err.message : String(err)
- log.error("failed to load plugin", { path: plugin, error: message })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Failed to load plugin ${plugin}: ${message}`,
- }).toObject(),
- })
+ // Keep plugin execution sequential so hook registration and execution
+ // order remains deterministic across plugin runs.
+ await applyPlugin(load, input, hooks).catch((err) => {
+ const message = errorMessage(err)
+ log.error("failed to load plugin", { path: load.spec, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to load plugin ${load.spec}: ${message}`,
+ }).toObject(),
})
+ })
}
// Notify plugins of current config
diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts
new file mode 100644
index 000000000..9640a662b
--- /dev/null
+++ b/packages/opencode/src/plugin/install.ts
@@ -0,0 +1,351 @@
+import path from "path"
+import {
+ type ParseError as JsoncParseError,
+ applyEdits,
+ modify,
+ parse as parseJsonc,
+ printParseErrorCode,
+} from "jsonc-parser"
+
+import { ConfigPaths } from "@/config/paths"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Flock } from "@/util/flock"
+
+import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
+
+type Mode = "noop" | "add" | "replace"
+type Kind = "server" | "tui"
+
+export type Target = {
+ kind: Kind
+ opts?: Record<string, unknown>
+}
+
+export type InstallDeps = {
+ resolve: (spec: string) => Promise<string>
+}
+
+export type PatchDeps = {
+ readText: (file: string) => Promise<string>
+ write: (file: string, text: string) => Promise<void>
+ exists: (file: string) => Promise<boolean>
+ files: (dir: string, name: "opencode" | "tui") => string[]
+}
+
+export type PatchInput = {
+ spec: string
+ targets: Target[]
+ force?: boolean
+ global?: boolean
+ vcs?: string
+ worktree: string
+ directory: string
+ config?: string
+}
+
+type Ok<T> = {
+ ok: true
+} & T
+
+type Err<C extends string, T> = {
+ ok: false
+ code: C
+} & T
+
+export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }>
+
+export type ManifestResult =
+ | Ok<{ targets: Target[] }>
+ | Err<"manifest_read_failed", { file: string; error: unknown }>
+ | Err<"manifest_no_targets", { file: string }>
+
+export type PatchItem = {
+ kind: Kind
+ mode: Mode
+ file: string
+}
+
+type PatchErr =
+ | Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }>
+ | Err<"patch_failed", { kind: Kind; error: unknown }>
+
+type PatchOne = Ok<{ item: PatchItem }> | PatchErr
+
+export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })
+
+const defaultInstallDeps: InstallDeps = {
+ resolve: (spec) => resolvePluginTarget(spec),
+}
+
+const defaultPatchDeps: PatchDeps = {
+ readText: (file) => Filesystem.readText(file),
+ write: async (file, text) => {
+ await Filesystem.write(file, text)
+ },
+ exists: (file) => Filesystem.exists(file),
+ files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
+}
+
+function pluginSpec(item: unknown) {
+ if (typeof item === "string") return item
+ if (!Array.isArray(item)) return
+ if (typeof item[0] !== "string") return
+ return item[0]
+}
+
+function parseTarget(item: unknown): Target | undefined {
+ if (item === "server" || item === "tui") return { kind: item }
+ if (!Array.isArray(item)) return
+ if (item[0] !== "server" && item[0] !== "tui") return
+ if (item.length < 2) return { kind: item[0] }
+ const opt = item[1]
+ if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
+ return {
+ kind: item[0],
+ opts: opt,
+ }
+}
+
+function parseTargets(raw: unknown) {
+ if (!Array.isArray(raw)) return []
+ const map = new Map<Kind, Target>()
+ for (const item of raw) {
+ const hit = parseTarget(item)
+ if (!hit) continue
+ map.set(hit.kind, hit)
+ }
+ return [...map.values()]
+}
+
+function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
+ const pkg = parsePluginSpecifier(spec).pkg
+ const rows = list.map((item, i) => ({
+ item,
+ i,
+ spec: pluginSpec(item),
+ }))
+ const dup = rows.filter((item) => {
+ if (!item.spec) return false
+ if (item.spec === spec) return true
+ if (item.spec.startsWith("file://")) return false
+ return parsePluginSpecifier(item.spec).pkg === pkg
+ })
+
+ if (!dup.length) {
+ return {
+ mode: "add",
+ list: [...list, next],
+ }
+ }
+
+ if (!force) {
+ return {
+ mode: "noop",
+ list,
+ }
+ }
+
+ const keep = dup[0]
+ if (!keep) {
+ return {
+ mode: "noop",
+ list,
+ }
+ }
+
+ if (dup.length === 1 && keep.spec === spec) {
+ return {
+ mode: "noop",
+ list,
+ }
+ }
+
+ const idx = new Set(dup.map((item) => item.i))
+ return {
+ mode: "replace",
+ list: rows.flatMap((row) => {
+ if (!idx.has(row.i)) return [row.item]
+ if (row.i !== keep.i) return []
+ if (typeof row.item === "string") return [next]
+ if (Array.isArray(row.item) && typeof row.item[0] === "string") {
+ return [[spec, ...row.item.slice(1)]]
+ }
+ return [row.item]
+ }),
+ }
+}
+
+export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
+ const target = await dep.resolve(spec).then(
+ (item) => ({
+ ok: true as const,
+ item,
+ }),
+ (error: unknown) => ({
+ ok: false as const,
+ error,
+ }),
+ )
+ if (!target.ok) {
+ return {
+ ok: false,
+ code: "install_failed",
+ error: target.error,
+ }
+ }
+ return {
+ ok: true,
+ target: target.item,
+ }
+}
+
+export async function readPluginManifest(target: string): Promise<ManifestResult> {
+ const pkg = await readPluginPackage(target).then(
+ (item) => ({
+ ok: true as const,
+ item,
+ }),
+ (error: unknown) => ({
+ ok: false as const,
+ error,
+ }),
+ )
+ if (!pkg.ok) {
+ return {
+ ok: false,
+ code: "manifest_read_failed",
+ file: target,
+ error: pkg.error,
+ }
+ }
+
+ const targets = parseTargets(pkg.item.json["oc-plugin"])
+ if (!targets.length) {
+ return {
+ ok: false,
+ code: "manifest_no_targets",
+ file: pkg.item.pkg,
+ }
+ }
+
+ return {
+ ok: true,
+ targets,
+ }
+}
+
+function patchDir(input: PatchInput) {
+ if (input.global) return input.config ?? Global.Path.config
+ const git = input.vcs === "git" && input.worktree !== "/"
+ const root = git ? input.worktree : input.directory
+ return path.join(root, ".opencode")
+}
+
+function patchName(kind: Kind): "opencode" | "tui" {
+ if (kind === "server") return "opencode"
+ return "tui"
+}
+
+async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
+ const name = patchName(target.kind)
+ await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
+
+ const files = dep.files(dir, name)
+ let cfg = files[0]
+ for (const file of files) {
+ if (!(await dep.exists(file))) continue
+ cfg = file
+ break
+ }
+
+ const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
+ if (err.code === "ENOENT") return "{}"
+ return err
+ })
+ if (src instanceof Error) {
+ return {
+ ok: false,
+ code: "patch_failed",
+ kind: target.kind,
+ error: src,
+ }
+ }
+ const text = src.trim() ? src : "{}"
+
+ const errs: JsoncParseError[] = []
+ const data = parseJsonc(text, errs, { allowTrailingComma: true })
+ if (errs.length) {
+ const err = errs[0]
+ const lines = text.substring(0, err.offset).split("\n")
+ return {
+ ok: false,
+ code: "invalid_json",
+ kind: target.kind,
+ file: cfg,
+ line: lines.length,
+ col: lines[lines.length - 1].length + 1,
+ parse: printParseErrorCode(err.error),
+ }
+ }
+
+ const list: unknown[] =
+ data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
+ const item = target.opts ? [spec, target.opts] : spec
+ const out = patchPluginList(list, spec, item, force)
+ if (out.mode === "noop") {
+ return {
+ ok: true,
+ item: {
+ kind: target.kind,
+ mode: out.mode,
+ file: cfg,
+ },
+ }
+ }
+
+ const edits = modify(text, ["plugin"], out.list, {
+ formattingOptions: {
+ tabSize: 2,
+ insertSpaces: true,
+ },
+ })
+ const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
+ if (write instanceof Error) {
+ return {
+ ok: false,
+ code: "patch_failed",
+ kind: target.kind,
+ error: write,
+ }
+ }
+
+ return {
+ ok: true,
+ item: {
+ kind: target.kind,
+ mode: out.mode,
+ file: cfg,
+ },
+ }
+}
+
+export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
+ const dir = patchDir(input)
+ const items: PatchItem[] = []
+ for (const target of input.targets) {
+ const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep)
+ if (!hit.ok) {
+ return {
+ ...hit,
+ dir,
+ }
+ }
+ items.push(hit.item)
+ }
+ return {
+ ok: true,
+ dir,
+ items,
+ }
+}
diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts
new file mode 100644
index 000000000..bf93870cb
--- /dev/null
+++ b/packages/opencode/src/plugin/meta.ts
@@ -0,0 +1,165 @@
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { Flock } from "@/util/flock"
+
+import { parsePluginSpecifier, pluginSource } from "./shared"
+
+export namespace PluginMeta {
+ type Source = "file" | "npm"
+
+ export type Entry = {
+ id: string
+ source: Source
+ spec: string
+ target: string
+ requested?: string
+ version?: string
+ modified?: number
+ first_time: number
+ last_time: number
+ time_changed: number
+ load_count: number
+ fingerprint: string
+ }
+
+ export type State = "first" | "updated" | "same"
+
+ export type Touch = {
+ spec: string
+ target: string
+ id: string
+ }
+
+ type Store = Record<string, Entry>
+ type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
+ type Row = Touch & { core: Core }
+
+ function storePath() {
+ return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
+ }
+
+ function lock(file: string) {
+ return `plugin-meta:${file}`
+ }
+
+ function fileTarget(spec: string, target: string) {
+ if (spec.startsWith("file://")) return fileURLToPath(spec)
+ if (target.startsWith("file://")) return fileURLToPath(target)
+ return
+ }
+
+ function modifiedAt(file: string) {
+ const stat = Filesystem.stat(file)
+ if (!stat) return
+ const value = stat.mtimeMs
+ return Math.floor(typeof value === "bigint" ? Number(value) : value)
+ }
+
+ function resolvedTarget(target: string) {
+ if (target.startsWith("file://")) return fileURLToPath(target)
+ return target
+ }
+
+ async function npmVersion(target: string) {
+ const resolved = resolvedTarget(target)
+ const stat = Filesystem.stat(resolved)
+ const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
+ return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
+ .then((item) => item.version)
+ .catch(() => undefined)
+ }
+
+ async function entryCore(item: Touch): Promise<Core> {
+ const spec = item.spec
+ const target = item.target
+ const source = pluginSource(spec)
+ if (source === "file") {
+ const file = fileTarget(spec, target)
+ return {
+ id: item.id,
+ source,
+ spec,
+ target,
+ modified: file ? modifiedAt(file) : undefined,
+ }
+ }
+
+ return {
+ id: item.id,
+ source,
+ spec,
+ target,
+ requested: parsePluginSpecifier(spec).version,
+ version: await npmVersion(target),
+ }
+ }
+
+ function fingerprint(value: Core) {
+ if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
+ return [value.target, value.requested ?? "", value.version ?? ""].join("|")
+ }
+
+ async function read(file: string): Promise<Store> {
+ return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
+ }
+
+ async function row(item: Touch): Promise<Row> {
+ return {
+ ...item,
+ core: await entryCore(item),
+ }
+ }
+
+ function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
+ const entry: Entry = {
+ ...core,
+ first_time: prev?.first_time ?? now,
+ last_time: now,
+ time_changed: prev?.time_changed ?? now,
+ load_count: (prev?.load_count ?? 0) + 1,
+ fingerprint: fingerprint(core),
+ }
+ const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
+ if (state === "updated") entry.time_changed = now
+ return {
+ state,
+ entry,
+ }
+ }
+
+ export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
+ if (!items.length) return []
+ const file = storePath()
+ const rows = await Promise.all(items.map((item) => row(item)))
+
+ return Flock.withLock(lock(file), async () => {
+ const store = await read(file)
+ const now = Date.now()
+ const out: Array<{ state: State; entry: Entry }> = []
+ for (const item of rows) {
+ const hit = next(store[item.id], item.core, now)
+ store[item.id] = hit.entry
+ out.push(hit)
+ }
+ await Filesystem.writeJson(file, store)
+ return out
+ })
+ }
+
+ export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
+ return touchMany([{ spec, target, id }]).then((item) => {
+ const hit = item[0]
+ if (hit) return hit
+ throw new Error("Failed to touch plugin metadata.")
+ })
+ }
+
+ export async function list(): Promise<Store> {
+ const file = storePath()
+ return Flock.withLock(lock(file), async () => read(file))
+ }
+}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
new file mode 100644
index 000000000..ee2ee6dd7
--- /dev/null
+++ b/packages/opencode/src/plugin/shared.ts
@@ -0,0 +1,149 @@
+import path from "path"
+import { fileURLToPath, pathToFileURL } from "url"
+import semver from "semver"
+import { BunProc } from "@/bun"
+import { Filesystem } from "@/util/filesystem"
+import { isRecord } from "@/util/record"
+
+// Old npm package names for plugins that are now built-in
+export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
+
+export function isDeprecatedPlugin(spec: string) {
+ return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
+}
+
+export function parsePluginSpecifier(spec: string) {
+ const lastAt = spec.lastIndexOf("@")
+ const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
+ const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
+ return { pkg, version }
+}
+
+export type PluginSource = "file" | "npm"
+export type PluginKind = "server" | "tui"
+
+export function pluginSource(spec: string): PluginSource {
+ return spec.startsWith("file://") ? "file" : "npm"
+}
+
+function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
+ if (!isRecord(json.exports)) return false
+ return `./${kind}` in json.exports
+}
+
+function resolveExportPath(raw: string, dir: string) {
+ if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
+ if (raw.startsWith("file://")) return fileURLToPath(raw)
+ return raw
+}
+
+function extractExportValue(value: unknown): string | undefined {
+ if (typeof value === "string") return value
+ if (!isRecord(value)) return undefined
+ for (const key of ["import", "default"]) {
+ const nested = value[key]
+ if (typeof nested === "string") return nested
+ }
+ return undefined
+}
+
+export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
+ const pkg = await readPluginPackage(target).catch(() => undefined)
+ if (!pkg) return target
+ if (!hasEntrypoint(pkg.json, kind)) return target
+
+ const exports = pkg.json.exports
+ if (!isRecord(exports)) return target
+ const raw = extractExportValue(exports[`./${kind}`])
+ if (!raw) return target
+
+ const resolved = resolveExportPath(raw, pkg.dir)
+ const root = Filesystem.resolve(pkg.dir)
+ const next = Filesystem.resolve(resolved)
+ if (!Filesystem.contains(root, next)) {
+ throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
+ }
+
+ return pathToFileURL(next).href
+}
+
+export function isPathPluginSpec(spec: string) {
+ return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
+}
+
+export async function resolvePathPluginTarget(spec: string) {
+ const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
+ const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
+ const stat = await Filesystem.stat(file)
+ if (!stat?.isDirectory()) {
+ if (spec.startsWith("file://")) return spec
+ return pathToFileURL(file).href
+ }
+
+ const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
+ if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
+ if (typeof pkg.main !== "string" || !pkg.main.trim()) {
+ throw new Error(`Plugin directory ${file} must define package.json main`)
+ }
+ return pathToFileURL(path.resolve(file, pkg.main)).href
+}
+
+export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
+ if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
+ const pkg = await readPluginPackage(target).catch(() => undefined)
+ if (!pkg) return
+ const engines = pkg.json.engines
+ if (!isRecord(engines)) return
+ const range = engines.opencode
+ if (typeof range !== "string") return
+ if (!semver.satisfies(opencodeVersion, range)) {
+ throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`)
+ }
+}
+
+export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
+ if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
+ return BunProc.install(parsed.pkg, parsed.version)
+}
+
+export async function readPluginPackage(target: string) {
+ const file = target.startsWith("file://") ? fileURLToPath(target) : target
+ const stat = await Filesystem.stat(file)
+ const dir = stat?.isDirectory() ? file : path.dirname(file)
+ const pkg = path.join(dir, "package.json")
+ const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
+ return { dir, pkg, json }
+}
+
+export function readPluginId(id: unknown, spec: string) {
+ if (id === undefined) return
+ if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
+ const value = id.trim()
+ if (!value) throw new TypeError(`Plugin ${spec} has an empty id`)
+ return value
+}
+
+export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
+ if (source === "file") {
+ if (id) return id
+ throw new TypeError(`Path plugin ${spec} must export id`)
+ }
+ if (id) return id
+ const pkg = await readPluginPackage(target)
+ if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
+ throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
+ }
+ return pkg.json.name.trim()
+}
+
+export function getDefaultPlugin(mod: Record<string, unknown>) {
+ // A single default object keeps v1 detection explicit and avoids scanning exports.
+ const value = mod.default
+ if (!isRecord(value)) return
+ const server = "server" in value ? value.server : undefined
+ const tui = "tui" in value ? value.tui : undefined
+ if (server !== undefined && typeof server !== "function") return
+ if (tui !== undefined && typeof tui !== "function") return
+ if (server === undefined && tui === undefined) return
+ return value
+}
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
index fc4cc5e6b..0b39a06a6 100644
--- a/packages/opencode/src/provider/auth.ts
+++ b/packages/opencode/src/provider/auth.ts
@@ -1,4 +1,4 @@
-import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
+import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
interface State {
hooks: Record<ProviderID, Hook>
- pending: Map<ProviderID, AuthOuathResult>
+ pending: Map<ProviderID, AuthOAuthResult>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
@@ -127,7 +127,7 @@ export namespace ProviderAuth {
: Result.failVoid,
),
),
- pending: new Map<ProviderID, AuthOuathResult>(),
+ pending: new Map<ProviderID, AuthOAuthResult>(),
}
}),
),
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 86e431565..37cbebc9c 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
+import { errorMessage } from "@/util/error"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -990,7 +991,7 @@ export namespace MessageV2 {
{ cause: e },
).toObject()
case e instanceof Error:
- return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
+ return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
default:
try {
const parsed = ProviderError.parseStreamError(e)
diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts
index 00c22bfe6..c79a530f7 100644
--- a/packages/opencode/src/tool/batch.ts
+++ b/packages/opencode/src/tool/batch.ts
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import { ProviderID, ModelID } from "../provider/schema"
+import { errorMessage } from "../util/error"
import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
@@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
state: {
status: "error",
input: call.parameters,
- error: error instanceof Error ? error.message : String(error),
+ error: errorMessage(error),
time: {
start: callStartTime,
end: Date.now(),
diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts
new file mode 100644
index 000000000..ea1c79178
--- /dev/null
+++ b/packages/opencode/src/util/error.ts
@@ -0,0 +1,77 @@
+import { isRecord } from "./record"
+
+export function errorFormat(error: unknown): string {
+ if (error instanceof Error) {
+ return error.stack ?? `${error.name}: ${error.message}`
+ }
+
+ if (typeof error === "object" && error !== null) {
+ try {
+ return JSON.stringify(error, null, 2)
+ } catch {
+ return "Unexpected error (unserializable)"
+ }
+ }
+
+ return String(error)
+}
+
+export function errorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ if (error.message) return error.message
+ if (error.name) return error.name
+ }
+
+ if (isRecord(error) && typeof error.message === "string" && error.message) {
+ return error.message
+ }
+
+ const text = String(error)
+ if (text && text !== "[object Object]") return text
+
+ const formatted = errorFormat(error)
+ if (formatted && formatted !== "{}") return formatted
+ return "unknown error"
+}
+
+export function errorData(error: unknown) {
+ if (error instanceof Error) {
+ return {
+ type: error.name,
+ message: errorMessage(error),
+ stack: error.stack,
+ cause: error.cause === undefined ? undefined : errorFormat(error.cause),
+ formatted: errorFormatted(error),
+ }
+ }
+
+ if (!isRecord(error)) {
+ return {
+ type: typeof error,
+ message: errorMessage(error),
+ formatted: errorFormatted(error),
+ }
+ }
+
+ const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
+ const value = error[key]
+ if (value === undefined) return acc
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+ acc[key] = value
+ return acc
+ }
+ acc[key] = value instanceof Error ? value.message : String(value)
+ return acc
+ }, {})
+
+ if (typeof data.message !== "string") data.message = errorMessage(error)
+ if (typeof data.type !== "string") data.type = error.constructor?.name
+ data.formatted = errorFormatted(error)
+ return data
+}
+
+function errorFormatted(error: unknown) {
+ const formatted = errorFormat(error)
+ if (formatted !== "{}") return formatted
+ return String(error)
+}
diff --git a/packages/opencode/src/util/flock.ts b/packages/opencode/src/util/flock.ts
new file mode 100644
index 000000000..74c7905eb
--- /dev/null
+++ b/packages/opencode/src/util/flock.ts
@@ -0,0 +1,333 @@
+import path from "path"
+import os from "os"
+import { randomBytes, randomUUID } from "crypto"
+import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
+import { Global } from "@/global"
+import { Hash } from "@/util/hash"
+
+export namespace Flock {
+ const root = path.join(Global.Path.state, "locks")
+ // Defaults for callers that do not provide timing options.
+ const defaultOpts = {
+ staleMs: 60_000,
+ timeoutMs: 5 * 60_000,
+ baseDelayMs: 100,
+ maxDelayMs: 2_000,
+ }
+
+ export interface WaitEvent {
+ key: string
+ attempt: number
+ delay: number
+ waited: number
+ }
+
+ export type Wait = (input: WaitEvent) => void | Promise<void>
+
+ export interface Options {
+ dir?: string
+ signal?: AbortSignal
+ staleMs?: number
+ timeoutMs?: number
+ baseDelayMs?: number
+ maxDelayMs?: number
+ onWait?: Wait
+ }
+
+ type Opts = {
+ staleMs: number
+ timeoutMs: number
+ baseDelayMs: number
+ maxDelayMs: number
+ }
+
+ type Owned = {
+ acquired: true
+ startHeartbeat: (intervalMs?: number) => void
+ release: () => Promise<void>
+ }
+
+ export interface Lease {
+ release: () => Promise<void>
+ [Symbol.asyncDispose]: () => Promise<void>
+ }
+
+ function code(err: unknown) {
+ if (typeof err !== "object" || err === null || !("code" in err)) return
+ const value = err.code
+ if (typeof value !== "string") return
+ return value
+ }
+
+ function sleep(ms: number, signal?: AbortSignal) {
+ return new Promise<void>((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(signal.reason ?? new Error("Aborted"))
+ return
+ }
+
+ let timer: NodeJS.Timeout | undefined
+
+ const done = () => {
+ signal?.removeEventListener("abort", abort)
+ resolve()
+ }
+
+ const abort = () => {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ signal?.removeEventListener("abort", abort)
+ reject(signal?.reason ?? new Error("Aborted"))
+ }
+
+ signal?.addEventListener("abort", abort, { once: true })
+ timer = setTimeout(done, ms)
+ })
+ }
+
+ function jitter(ms: number) {
+ const j = Math.floor(ms * 0.3)
+ const d = Math.floor(Math.random() * (2 * j + 1)) - j
+ return Math.max(0, ms + d)
+ }
+
+ function mono() {
+ return performance.now()
+ }
+
+ function wall() {
+ return performance.timeOrigin + mono()
+ }
+
+ async function stats(file: string) {
+ try {
+ return await stat(file)
+ } catch (err) {
+ const errCode = code(err)
+ if (errCode === "ENOENT" || errCode === "ENOTDIR") return
+ throw err
+ }
+ }
+
+ async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
+ // Stale detection allows automatic recovery after crashed owners.
+ const now = wall()
+ const heartbeat = await stats(heartbeatPath)
+ if (heartbeat) {
+ return now - heartbeat.mtimeMs > staleMs
+ }
+
+ const meta = await stats(metaPath)
+ if (meta) {
+ return now - meta.mtimeMs > staleMs
+ }
+
+ const dir = await stats(lockDir)
+ if (!dir) {
+ return false
+ }
+
+ return now - dir.mtimeMs > staleMs
+ }
+
+ async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
+ const token = randomUUID?.() ?? randomBytes(16).toString("hex")
+ const metaPath = path.join(lockDir, "meta.json")
+ const heartbeatPath = path.join(lockDir, "heartbeat")
+
+ try {
+ await mkdir(lockDir, { mode: 0o700 })
+ } catch (err) {
+ if (code(err) !== "EEXIST") {
+ throw err
+ }
+
+ if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
+ return { acquired: false }
+ }
+
+ const breakerPath = lockDir + ".breaker"
+ try {
+ await mkdir(breakerPath, { mode: 0o700 })
+ } catch (claimErr) {
+ const errCode = code(claimErr)
+ if (errCode === "EEXIST") {
+ const breaker = await stats(breakerPath)
+ if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
+ await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
+ }
+ return { acquired: false }
+ }
+
+ if (errCode === "ENOENT" || errCode === "ENOTDIR") {
+ return { acquired: false }
+ }
+
+ throw claimErr
+ }
+
+ try {
+ // Breaker ownership ensures only one contender performs stale cleanup.
+ if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
+ return { acquired: false }
+ }
+
+ await rm(lockDir, { recursive: true, force: true })
+
+ try {
+ await mkdir(lockDir, { mode: 0o700 })
+ } catch (retryErr) {
+ const errCode = code(retryErr)
+ if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
+ return { acquired: false }
+ }
+ throw retryErr
+ }
+ } finally {
+ await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
+ }
+ }
+
+ const meta = {
+ token,
+ pid: process.pid,
+ hostname: os.hostname(),
+ createdAt: new Date().toISOString(),
+ }
+
+ await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
+ await rm(lockDir, { recursive: true, force: true })
+ throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
+ })
+
+ await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
+ await rm(lockDir, { recursive: true, force: true })
+ throw new Error("Lock acquired but meta.json already existed (possible compromise).")
+ })
+
+ let timer: NodeJS.Timeout | undefined
+
+ const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
+ if (timer) return
+ // Heartbeat prevents long critical sections from being evicted as stale.
+ timer = setInterval(() => {
+ const t = new Date()
+ void utimes(heartbeatPath, t, t).catch(() => undefined)
+ }, intervalMs)
+ timer.unref?.()
+ }
+
+ const release = async () => {
+ if (timer) {
+ clearInterval(timer)
+ timer = undefined
+ }
+
+ const current = await readFile(metaPath, "utf8")
+ .then((raw) => {
+ const parsed = JSON.parse(raw)
+ if (!parsed || typeof parsed !== "object") return {}
+ return {
+ token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
+ }
+ })
+ .catch((err) => {
+ const errCode = code(err)
+ if (errCode === "ENOENT" || errCode === "ENOTDIR") {
+ throw new Error("Refusing to release: lock is compromised (metadata missing).")
+ }
+ if (err instanceof SyntaxError) {
+ throw new Error("Refusing to release: lock is compromised (metadata invalid).")
+ }
+ throw err
+ })
+ // Token check prevents deleting a lock that was re-acquired by another process.
+ if (current.token !== token) {
+ throw new Error("Refusing to release: lock token mismatch (not the owner).")
+ }
+
+ await rm(lockDir, { recursive: true, force: true })
+ }
+
+ return {
+ acquired: true,
+ startHeartbeat,
+ release,
+ }
+ }
+
+ async function acquireLockDir(
+ lockDir: string,
+ input: { key: string; onWait?: Wait; signal?: AbortSignal },
+ opts: Opts,
+ ) {
+ const stop = mono() + opts.timeoutMs
+ let attempt = 0
+ let waited = 0
+ let delay = opts.baseDelayMs
+
+ while (true) {
+ input.signal?.throwIfAborted()
+
+ const res = await tryAcquireLockDir(lockDir, opts)
+ if (res.acquired) {
+ return res
+ }
+
+ if (mono() > stop) {
+ throw new Error(`Timed out waiting for lock: ${input.key}`)
+ }
+
+ attempt += 1
+ const ms = jitter(delay)
+ await input.onWait?.({
+ key: input.key,
+ attempt,
+ delay: ms,
+ waited,
+ })
+ await sleep(ms, input.signal)
+ waited += ms
+ delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
+ }
+ }
+
+ export async function acquire(key: string, input: Options = {}): Promise<Lease> {
+ input.signal?.throwIfAborted()
+ const cfg: Opts = {
+ staleMs: input.staleMs ?? defaultOpts.staleMs,
+ timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
+ baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
+ maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
+ }
+ const dir = input.dir ?? root
+
+ await mkdir(dir, { recursive: true })
+ const lockfile = path.join(dir, Hash.fast(key) + ".lock")
+ const lock = await acquireLockDir(
+ lockfile,
+ {
+ key,
+ onWait: input.onWait,
+ signal: input.signal,
+ },
+ cfg,
+ )
+ lock.startHeartbeat()
+
+ const release = () => lock.release()
+ return {
+ release,
+ [Symbol.asyncDispose]() {
+ return release()
+ },
+ }
+ }
+
+ export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
+ await using _ = await acquire(key, input)
+ input.signal?.throwIfAborted()
+ return await fn()
+ }
+}
diff --git a/packages/opencode/src/util/proxied.ts b/packages/opencode/src/util/network.ts
index 440a9ccce..69e5d1758 100644
--- a/packages/opencode/src/util/proxied.ts
+++ b/packages/opencode/src/util/network.ts
@@ -1,3 +1,9 @@
+export function online() {
+ const nav = globalThis.navigator
+ if (!nav || typeof nav.onLine !== "boolean") return true
+ return nav.onLine
+}
+
export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
}
diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts
index 22dce37cb..1230ed323 100644
--- a/packages/opencode/src/util/process.ts
+++ b/packages/opencode/src/util/process.ts
@@ -1,6 +1,7 @@
import { type ChildProcess } from "child_process"
import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
+import { errorMessage } from "./error"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
@@ -136,7 +137,7 @@ export namespace Process {
return {
code: 1,
stdout: Buffer.alloc(0),
- stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+ stderr: Buffer.from(errorMessage(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
diff --git a/packages/opencode/src/util/record.ts b/packages/opencode/src/util/record.ts
new file mode 100644
index 000000000..495927463
--- /dev/null
+++ b/packages/opencode/src/util/record.ts
@@ -0,0 +1,3 @@
+export function isRecord(value: unknown): value is Record<string, unknown> {
+ return !!value && typeof value === "object" && !Array.isArray(value)
+}
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 0a8ce5ea2..7087ac262 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import { Log } from "../util/log"
import { Slug } from "@opencode-ai/util/slug"
+import { errorMessage } from "../util/error"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
@@ -260,7 +261,7 @@ export namespace Worktree {
})
.then(() => true)
.catch((error) => {
- const message = error instanceof Error ? error.message : String(error)
+ const message = errorMessage(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
@@ -344,9 +345,12 @@ export namespace Worktree {
function cleanDirectory(target: string) {
return Effect.promise(() =>
- import("fs/promises").then((fsp) =>
- fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
- ),
+ import("fs/promises")
+ .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
+ .catch((error) => {
+ const message = errorMessage(error)
+ throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
+ }),
)
}
diff --git a/packages/opencode/test/cli/tui/keybind-plugin.test.ts b/packages/opencode/test/cli/tui/keybind-plugin.test.ts
new file mode 100644
index 000000000..7cd4c87a7
--- /dev/null
+++ b/packages/opencode/test/cli/tui/keybind-plugin.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, test } from "bun:test"
+import type { ParsedKey } from "@opentui/core"
+import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
+
+describe("createPluginKeybind", () => {
+ const defaults = {
+ open: "ctrl+o",
+ close: "escape",
+ }
+
+ test("uses defaults when overrides are missing", () => {
+ const api = {
+ match: () => false,
+ print: (key: string) => key,
+ }
+ const bind = createPluginKeybind(api, defaults)
+
+ expect(bind.all).toEqual(defaults)
+ expect(bind.get("open")).toBe("ctrl+o")
+ expect(bind.get("close")).toBe("escape")
+ })
+
+ test("applies valid overrides", () => {
+ const api = {
+ match: () => false,
+ print: (key: string) => key,
+ }
+ const bind = createPluginKeybind(api, defaults, {
+ open: "ctrl+alt+o",
+ close: "q",
+ })
+
+ expect(bind.all).toEqual({
+ open: "ctrl+alt+o",
+ close: "q",
+ })
+ })
+
+ test("ignores invalid overrides", () => {
+ const api = {
+ match: () => false,
+ print: (key: string) => key,
+ }
+ const bind = createPluginKeybind(api, defaults, {
+ open: " ",
+ close: 1,
+ extra: "ctrl+x",
+ })
+
+ expect(bind.all).toEqual(defaults)
+ expect(bind.get("extra")).toBe("extra")
+ })
+
+ test("resolves names for match", () => {
+ const list: string[] = []
+ const api = {
+ match: (key: string) => {
+ list.push(key)
+ return true
+ },
+ print: (key: string) => key,
+ }
+ const bind = createPluginKeybind(api, defaults, {
+ open: "ctrl+shift+o",
+ })
+
+ bind.match("open", { name: "x" } as ParsedKey)
+ bind.match("ctrl+k", { name: "x" } as ParsedKey)
+
+ expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
+ })
+
+ test("resolves names for print", () => {
+ const list: string[] = []
+ const api = {
+ match: () => false,
+ print: (key: string) => {
+ list.push(key)
+ return `print:${key}`
+ },
+ }
+ const bind = createPluginKeybind(api, defaults, {
+ close: "q",
+ })
+
+ expect(bind.print("close")).toBe("print:q")
+ expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
+ expect(list).toEqual(["q", "ctrl+p"])
+ })
+})
diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts
new file mode 100644
index 000000000..d6ff4fc6c
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-add.test.ts
@@ -0,0 +1,61 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("adds tui plugin at runtime from spec", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "add-plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "add.txt")
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.add",
+ tui: async () => {
+ await Bun.write(${JSON.stringify(marker)}, "called")
+ },
+}
+`,
+ )
+
+ return { spec, marker }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [],
+ plugin_meta: undefined,
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+
+ await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+ expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
+ id: "demo.add",
+ source: "file",
+ spec: tmp.extra.spec,
+ target: tmp.extra.spec,
+ enabled: true,
+ active: true,
+ })
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts
new file mode 100644
index 000000000..a2477cc79
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-install.test.ts
@@ -0,0 +1,95 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("installs plugin without loading it", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "install-plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "install.txt")
+
+ await Bun.write(
+ path.join(dir, "package.json"),
+ JSON.stringify(
+ {
+ name: "demo-install-plugin",
+ type: "module",
+ main: "./install-plugin.ts",
+ "oc-plugin": [["tui", { marker }]],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.install",
+ tui: async (_api, options) => {
+ if (!options?.marker) return
+ await Bun.write(options.marker, "loaded")
+ },
+}
+`,
+ )
+
+ return { spec, marker }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
+ plugin: [],
+ plugin_meta: undefined,
+ }
+ const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const api = createTuiPluginApi({
+ state: {
+ path: {
+ state: path.join(tmp.path, "state.json"),
+ config: path.join(tmp.path, "tui.json"),
+ worktree: tmp.path,
+ directory: tmp.path,
+ },
+ },
+ })
+
+ try {
+ await TuiPluginRuntime.init(api)
+ cfg = {
+ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+ plugin_meta: {
+ [tmp.extra.spec]: {
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ },
+ }
+
+ const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
+ expect(out).toMatchObject({
+ ok: true,
+ tui: true,
+ })
+
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+ await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("loaded")
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
new file mode 100644
index 000000000..9c868a4c9
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
@@ -0,0 +1,225 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { mockTuiRuntime } from "../../fixture/tui-runtime"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "marker.txt")
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.lifecycle",
+ tui: async (api, options) => {
+ api.event.on("event.test", () => {})
+ api.route.register([{ name: "lifecycle.route", render: () => null }])
+ api.lifecycle.onDispose(async () => {
+ const prev = await Bun.file(options.marker).text().catch(() => "")
+ await Bun.write(options.marker, prev + "custom\\n")
+ })
+ api.lifecycle.onDispose(async () => {
+ const prev = await Bun.file(options.marker).text().catch(() => "")
+ await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
+ })
+ },
+}
+`,
+ )
+
+ return { spec, marker }
+ },
+ })
+
+ const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.dispose()
+
+ const marker = await fs.readFile(tmp.extra.marker, "utf8")
+ expect(marker).toContain("custom")
+ expect(marker).toContain("aborted:true")
+
+ // second dispose is a no-op
+ await TuiPluginRuntime.dispose()
+ const after = await fs.readFile(tmp.extra.marker, "utf8")
+ expect(after).toBe(marker)
+ } finally {
+ await TuiPluginRuntime.dispose()
+ restore()
+ }
+})
+
+test("rolls back failed plugin and continues loading next", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const bad = path.join(dir, "bad-plugin.ts")
+ const good = path.join(dir, "good-plugin.ts")
+ const badSpec = pathToFileURL(bad).href
+ const goodSpec = pathToFileURL(good).href
+ const badMarker = path.join(dir, "bad-cleanup.txt")
+ const goodMarker = path.join(dir, "good-called.txt")
+
+ await Bun.write(
+ bad,
+ `export default {
+ id: "demo.bad",
+ tui: async (api, options) => {
+ api.route.register([{ name: "bad.route", render: () => null }])
+ api.lifecycle.onDispose(async () => {
+ await Bun.write(options.bad_marker, "cleaned")
+ })
+ throw new Error("bad plugin")
+ },
+}
+`,
+ )
+
+ await Bun.write(
+ good,
+ `export default {
+ id: "demo.good",
+ tui: async (_api, options) => {
+ await Bun.write(options.good_marker, "called")
+ },
+}
+`,
+ )
+
+ return { badSpec, goodSpec, badMarker, goodMarker }
+ },
+ })
+
+ const restore = mockTuiRuntime(tmp.path, [
+ [tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
+ [tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
+ ])
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ // bad plugin's onDispose ran during rollback
+ await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
+ // good plugin still loaded
+ await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
+ } finally {
+ await TuiPluginRuntime.dispose()
+ restore()
+ }
+})
+
+test("assigns sequential slot ids scoped to plugin", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "slot-plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "slot-setup.txt")
+
+ await Bun.write(
+ file,
+ `import fs from "fs"
+
+const mark = (label) => {
+ fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
+}
+
+export default {
+ id: "demo.slot",
+ tui: async (api) => {
+ const one = api.slots.register({
+ id: 1,
+ setup: () => { mark("one") },
+ slots: { home_logo() { return null } },
+ })
+ const two = api.slots.register({
+ id: 2,
+ setup: () => { mark("two") },
+ slots: { home_bottom() { return null } },
+ })
+ mark("id:" + one)
+ mark("id:" + two)
+ },
+}
+`,
+ )
+
+ return { spec, marker }
+ },
+ })
+
+ const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+ const err = spyOn(console, "error").mockImplementation(() => {})
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+
+ const marker = await fs.readFile(tmp.extra.marker, "utf8")
+ expect(marker).toContain("one")
+ expect(marker).toContain("two")
+ expect(marker).toContain("id:demo.slot")
+ expect(marker).toContain("id:demo.slot:1")
+
+ // no initialization failures
+ const hit = err.mock.calls.find(
+ (item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"),
+ )
+ expect(hit).toBeUndefined()
+ } finally {
+ await TuiPluginRuntime.dispose()
+ err.mockRestore()
+ restore()
+ }
+})
+
+test(
+ "times out hanging plugin cleanup on dispose",
+ async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "timeout-plugin.ts")
+ const spec = pathToFileURL(file).href
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.timeout",
+ tui: async (api) => {
+ api.lifecycle.onDispose(() => new Promise(() => {}))
+ },
+}
+`,
+ )
+
+ return { spec }
+ },
+ })
+
+ const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+
+ const done = await new Promise<string>((resolve) => {
+ const timer = setTimeout(() => resolve("timeout"), 7000)
+ TuiPluginRuntime.dispose().then(() => {
+ clearTimeout(timer)
+ resolve("done")
+ })
+ })
+ expect(done).toBe("done")
+ } finally {
+ await TuiPluginRuntime.dispose()
+ restore()
+ }
+ },
+ { timeout: 15000 },
+)
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
new file mode 100644
index 000000000..e9b1135f0
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -0,0 +1,132 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+import { BunProc } from "../../../src/bun"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("loads npm tui plugin from package ./tui export", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const marker = path.join(dir, "tui-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({
+ name: "acme-plugin",
+ type: "module",
+ exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
+ }),
+ )
+ await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
+ await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
+ await Bun.write(path.join(mod, "server.js"), "export default {}\n")
+ await Bun.write(
+ path.join(mod, "tui.js"),
+ `export default {
+ id: "demo.tui.export",
+ tui: async (_api, options) => {
+ if (!options?.marker) return
+ await Bun.write(${JSON.stringify(marker)}, "called")
+ },
+}
+`,
+ )
+
+ return { mod, marker, spec: "[email protected]" }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+ plugin_meta: {
+ [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+ const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
+ expect(hit?.enabled).toBe(true)
+ expect(hit?.active).toBe(true)
+ expect(hit?.source).toBe("npm")
+ } finally {
+ await TuiPluginRuntime.dispose()
+ install.mockRestore()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
+
+test("rejects npm tui export that resolves outside plugin directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const outside = path.join(dir, "outside")
+ const marker = path.join(dir, "outside-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+ await fs.mkdir(outside, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({
+ name: "acme-plugin",
+ type: "module",
+ exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
+ }),
+ )
+ await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+ await Bun.write(
+ path.join(outside, "tui.js"),
+ `export default {
+ id: "demo.outside",
+ tui: async () => {
+ await Bun.write(${JSON.stringify(marker)}, "outside")
+ },
+}
+`,
+ )
+ await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
+
+ return { mod, marker, spec: "[email protected]" }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [tmp.extra.spec],
+ plugin_meta: {
+ [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ // plugin code never ran
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+ // plugin not listed
+ expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+ } finally {
+ await TuiPluginRuntime.dispose()
+ install.mockRestore()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
new file mode 100644
index 000000000..ef8f05c08
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
@@ -0,0 +1,71 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("skips external tui plugins in pure mode", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "called.txt")
+ const meta = path.join(dir, "plugin-meta.json")
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.pure",
+ tui: async (_api, options) => {
+ if (!options?.marker) return
+ await Bun.write(options.marker, "called")
+ },
+}
+`,
+ )
+
+ return { spec, marker, meta }
+ },
+ })
+
+ const pure = process.env.OPENCODE_PURE
+ const meta = process.env.OPENCODE_PLUGIN_META_FILE
+ process.env.OPENCODE_PURE = "1"
+ process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
+
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+ plugin_meta: {
+ [tmp.extra.spec]: {
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ if (pure === undefined) {
+ delete process.env.OPENCODE_PURE
+ } else {
+ process.env.OPENCODE_PURE = pure
+ }
+ if (meta === undefined) {
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ } else {
+ process.env.OPENCODE_PLUGIN_META_FILE = meta
+ }
+ }
+})
diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts
new file mode 100644
index 000000000..9e7275497
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts
@@ -0,0 +1,563 @@
+import { beforeAll, describe, expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { Global } from "../../../src/global"
+import { TuiConfig } from "../../../src/config/tui"
+import { Config } from "../../../src/config/config"
+import { Filesystem } from "../../../src/util/filesystem"
+
+const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+type Row = Record<string, unknown>
+
+type Data = {
+ local: Row
+ global: Row
+ invalid: Row
+ preloaded: Row
+ fn_called: boolean
+ local_installed: string
+ global_installed: string
+ preloaded_installed: string
+ leaked_local_to_global: boolean
+ leaked_global_to_local: boolean
+ local_theme: string
+ global_theme: string
+}
+
+async function row(file: string): Promise<Row> {
+ return Filesystem.readJson<Row>(file)
+}
+
+async function load(): Promise<Data> {
+ const stamp = Date.now()
+ const globalConfigPath = path.join(Global.Path.config, "tui.json")
+ const backup = await Bun.file(globalConfigPath)
+ .text()
+ .catch(() => undefined)
+
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const localPluginPath = path.join(dir, "local-plugin.ts")
+ const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
+ const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
+ const globalPluginPath = path.join(dir, "global-plugin.ts")
+ const localSpec = pathToFileURL(localPluginPath).href
+ const invalidSpec = pathToFileURL(invalidPluginPath).href
+ const preloadedSpec = pathToFileURL(preloadedPluginPath).href
+ const globalSpec = pathToFileURL(globalPluginPath).href
+ const localThemeFile = `local-theme-${stamp}.json`
+ const invalidThemeFile = `invalid-theme-${stamp}.json`
+ const globalThemeFile = `global-theme-${stamp}.json`
+ const preloadedThemeFile = `preloaded-theme-${stamp}.json`
+ const localThemeName = localThemeFile.replace(/\.json$/, "")
+ const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
+ const globalThemeName = globalThemeFile.replace(/\.json$/, "")
+ const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
+ const localThemePath = path.join(dir, localThemeFile)
+ const invalidThemePath = path.join(dir, invalidThemeFile)
+ const globalThemePath = path.join(dir, globalThemeFile)
+ const preloadedThemePath = path.join(dir, preloadedThemeFile)
+ const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
+ const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
+ const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
+ const fnMarker = path.join(dir, "function-called.txt")
+ const localMarker = path.join(dir, "local-called.json")
+ const invalidMarker = path.join(dir, "invalid-called.json")
+ const globalMarker = path.join(dir, "global-called.json")
+ const preloadedMarker = path.join(dir, "preloaded-called.json")
+ const localConfigPath = path.join(dir, "tui.json")
+
+ await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
+ await Bun.write(invalidThemePath, "{ invalid json }")
+ await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
+ await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
+ await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
+
+ await Bun.write(
+ localPluginPath,
+ `export const ignored = async (_input, options) => {
+ if (!options?.fn_marker) return
+ await Bun.write(options.fn_marker, "called")
+}
+
+export default {
+ id: "demo.local",
+ tui: async (api, options) => {
+ if (!options?.marker) return
+ const cfg_theme = api.tuiConfig.theme
+ const cfg_diff = api.tuiConfig.diff_style
+ const cfg_speed = api.tuiConfig.scroll_speed
+ const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
+ const cfg_submit = api.tuiConfig.keybinds?.input_submit
+ const key = api.keybind.create(
+ { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
+ options.keybinds,
+ )
+ const kv_before = api.kv.get(options.kv_key, "missing")
+ api.kv.set(options.kv_key, "stored")
+ const kv_after = api.kv.get(options.kv_key, "missing")
+ const diff = api.state.session.diff(options.session_id)
+ const todo = api.state.session.todo(options.session_id)
+ const lsp = api.state.lsp()
+ const mcp = api.state.mcp()
+ const depth_before = api.ui.dialog.depth
+ const open_before = api.ui.dialog.open
+ const size_before = api.ui.dialog.size
+ api.ui.dialog.setSize("large")
+ const size_after = api.ui.dialog.size
+ api.ui.dialog.replace(() => null)
+ const depth_after = api.ui.dialog.depth
+ const open_after = api.ui.dialog.open
+ api.ui.dialog.clear()
+ const open_clear = api.ui.dialog.open
+ const before = api.theme.has(options.theme_name)
+ const set_missing = api.theme.set(options.theme_name)
+ await api.theme.install(options.theme_path)
+ const after = api.theme.has(options.theme_name)
+ const set_installed = api.theme.set(options.theme_name)
+ const first = await Bun.file(options.dest).text()
+ await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
+ await api.theme.install(options.theme_path)
+ const second = await Bun.file(options.dest).text()
+ await Bun.write(
+ options.marker,
+ JSON.stringify({
+ before,
+ set_missing,
+ after,
+ set_installed,
+ selected: api.theme.selected,
+ same: first === second,
+ key_modal: key.get("modal"),
+ key_close: key.get("close"),
+ key_unknown: key.get("ctrl+k"),
+ key_print: key.print("modal"),
+ kv_before,
+ kv_after,
+ kv_ready: api.kv.ready,
+ diff_count: diff.length,
+ diff_file: diff[0]?.file,
+ todo_count: todo.length,
+ todo_first: todo[0]?.content,
+ lsp_count: lsp.length,
+ mcp_count: mcp.length,
+ mcp_first: mcp[0]?.name,
+ depth_before,
+ open_before,
+ size_before,
+ size_after,
+ depth_after,
+ open_after,
+ open_clear,
+ cfg_theme,
+ cfg_diff,
+ cfg_speed,
+ cfg_accel,
+ cfg_submit,
+ }),
+ )
+ },
+}
+`,
+ )
+
+ await Bun.write(
+ invalidPluginPath,
+ `export default {
+ id: "demo.invalid",
+ tui: async (api, options) => {
+ if (!options?.marker) return
+ const before = api.theme.has(options.theme_name)
+ const set_missing = api.theme.set(options.theme_name)
+ await api.theme.install(options.theme_path)
+ const after = api.theme.has(options.theme_name)
+ const set_installed = api.theme.set(options.theme_name)
+ await Bun.write(
+ options.marker,
+ JSON.stringify({
+ before,
+ set_missing,
+ after,
+ set_installed,
+ }),
+ )
+ },
+}
+`,
+ )
+
+ await Bun.write(
+ preloadedPluginPath,
+ `export default {
+ id: "demo.preloaded",
+ tui: async (api, options) => {
+ if (!options?.marker) return
+ const before = api.theme.has(options.theme_name)
+ await api.theme.install(options.theme_path)
+ const after = api.theme.has(options.theme_name)
+ const text = await Bun.file(options.dest).text()
+ await Bun.write(
+ options.marker,
+ JSON.stringify({
+ before,
+ after,
+ text,
+ }),
+ )
+ },
+}
+`,
+ )
+
+ await Bun.write(
+ globalPluginPath,
+ `export default {
+ id: "demo.global",
+ tui: async (api, options) => {
+ if (!options?.marker) return
+ await api.theme.install(options.theme_path)
+ const has = api.theme.has(options.theme_name)
+ const set_installed = api.theme.set(options.theme_name)
+ await Bun.write(
+ options.marker,
+ JSON.stringify({
+ has,
+ set_installed,
+ selected: api.theme.selected,
+ }),
+ )
+ },
+}
+`,
+ )
+
+ await Bun.write(
+ globalConfigPath,
+ JSON.stringify(
+ {
+ plugin: [
+ [globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
+ ],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ await Bun.write(
+ localConfigPath,
+ JSON.stringify(
+ {
+ plugin: [
+ [
+ localSpec,
+ {
+ fn_marker: fnMarker,
+ marker: localMarker,
+ source: localThemePath,
+ dest: localDest,
+ theme_path: `./${localThemeFile}`,
+ theme_name: localThemeName,
+ kv_key: "plugin_state_key",
+ session_id: "ses_test",
+ keybinds: {
+ modal: "ctrl+alt+m",
+ close: "q",
+ },
+ },
+ ],
+ [
+ invalidSpec,
+ {
+ marker: invalidMarker,
+ theme_path: `./${invalidThemeFile}`,
+ theme_name: invalidThemeName,
+ },
+ ],
+ [
+ preloadedSpec,
+ {
+ marker: preloadedMarker,
+ dest: preloadedDest,
+ theme_path: `./${preloadedThemeFile}`,
+ theme_name: preloadedThemeName,
+ },
+ ],
+ ],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ return {
+ localThemeFile,
+ invalidThemeFile,
+ globalThemeFile,
+ preloadedThemeFile,
+ localThemeName,
+ invalidThemeName,
+ globalThemeName,
+ preloadedThemeName,
+ localDest,
+ globalDest,
+ preloadedDest,
+ localPluginPath,
+ invalidPluginPath,
+ globalPluginPath,
+ preloadedPluginPath,
+ localSpec,
+ invalidSpec,
+ globalSpec,
+ preloadedSpec,
+ fnMarker,
+ localMarker,
+ invalidMarker,
+ globalMarker,
+ preloadedMarker,
+ }
+ },
+ })
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const install = spyOn(Config, "installDependencies").mockResolvedValue()
+
+ try {
+ expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
+
+ await TuiPluginRuntime.init(
+ createTuiPluginApi({
+ tuiConfig: {
+ theme: "smoke",
+ diff_style: "stacked",
+ scroll_speed: 1.5,
+ scroll_acceleration: { enabled: true },
+ keybinds: {
+ input_submit: "ctrl+enter",
+ },
+ },
+ keybind: {
+ print: (key) => `print:${key}`,
+ },
+ state: {
+ session: {
+ diff(sessionID) {
+ if (sessionID !== "ses_test") return []
+ return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
+ },
+ todo(sessionID) {
+ if (sessionID !== "ses_test") return []
+ return [{ content: "ship it", status: "pending" }]
+ },
+ },
+ lsp() {
+ return [{ id: "ts", root: "/tmp/project", status: "connected" }]
+ },
+ mcp() {
+ return [{ name: "github", status: "connected" }]
+ },
+ },
+ theme: {
+ has(name) {
+ return allThemes()[name] !== undefined
+ },
+ },
+ }),
+ )
+ const local = await row(tmp.extra.localMarker)
+ const global = await row(tmp.extra.globalMarker)
+ const invalid = await row(tmp.extra.invalidMarker)
+ const preloaded = await row(tmp.extra.preloadedMarker)
+ const fn_called = await fs
+ .readFile(tmp.extra.fnMarker, "utf8")
+ .then(() => true)
+ .catch(() => false)
+ const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
+ const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
+ const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
+ const leaked_local_to_global = await fs
+ .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
+ .then(() => true)
+ .catch(() => false)
+ const leaked_global_to_local = await fs
+ .stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
+ .then(() => true)
+ .catch(() => false)
+
+ return {
+ local,
+ global,
+ invalid,
+ preloaded,
+ fn_called,
+ local_installed,
+ global_installed,
+ preloaded_installed,
+ leaked_local_to_global,
+ leaked_global_to_local,
+ local_theme: tmp.extra.localThemeName,
+ global_theme: tmp.extra.globalThemeName,
+ }
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ wait.mockRestore()
+ install.mockRestore()
+ if (backup === undefined) {
+ await fs.rm(globalConfigPath, { force: true })
+ } else {
+ await Bun.write(globalConfigPath, backup)
+ }
+ await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
+ }
+}
+
+test("continues loading when a plugin is missing config metadata", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const bad = path.join(dir, "missing-meta-plugin.ts")
+ const good = path.join(dir, "next-plugin.ts")
+ const bare = path.join(dir, "plain-plugin.ts")
+ const badSpec = pathToFileURL(bad).href
+ const goodSpec = pathToFileURL(good).href
+ const bareSpec = pathToFileURL(bare).href
+ const goodMarker = path.join(dir, "next-called.txt")
+ const bareMarker = path.join(dir, "plain-called.txt")
+
+ for (const [file, id] of [
+ [bad, "demo.missing-meta"],
+ [good, "demo.next"],
+ ] as const) {
+ await Bun.write(
+ file,
+ `export default {
+ id: "${id}",
+ tui: async (_api, options) => {
+ if (!options?.marker) return
+ await Bun.write(options.marker, "called")
+ },
+}
+`,
+ )
+ }
+
+ await Bun.write(
+ bare,
+ `export default {
+ id: "demo.plain",
+ tui: async (_api, options) => {
+ await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
+ },
+}
+`,
+ )
+
+ return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [
+ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
+ [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+ tmp.extra.bareSpec,
+ ],
+ plugin_meta: {
+ [tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+ [tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ // bad plugin was skipped (no metadata entry)
+ await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
+ // good plugin loaded fine
+ await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
+ // bare string spec gets undefined options
+ await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
+
+describe("tui.plugin.loader", () => {
+ let data: Data
+
+ beforeAll(async () => {
+ data = await load()
+ })
+
+ test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
+ expect(data.local.key_modal).toBe("ctrl+alt+m")
+ expect(data.local.key_close).toBe("q")
+ expect(data.local.key_unknown).toBe("ctrl+k")
+ expect(data.local.key_print).toBe("print:ctrl+alt+m")
+ expect(data.local.kv_before).toBe("missing")
+ expect(data.local.kv_after).toBe("stored")
+ expect(data.local.kv_ready).toBe(true)
+ expect(data.local.diff_count).toBe(1)
+ expect(data.local.diff_file).toBe("src/app.ts")
+ expect(data.local.todo_count).toBe(1)
+ expect(data.local.todo_first).toBe("ship it")
+ expect(data.local.lsp_count).toBe(1)
+ expect(data.local.mcp_count).toBe(1)
+ expect(data.local.mcp_first).toBe("github")
+ expect(data.local.depth_before).toBe(0)
+ expect(data.local.open_before).toBe(false)
+ expect(data.local.size_before).toBe("medium")
+ expect(data.local.size_after).toBe("large")
+ expect(data.local.depth_after).toBe(1)
+ expect(data.local.open_after).toBe(true)
+ expect(data.local.open_clear).toBe(false)
+ expect(data.local.cfg_theme).toBe("smoke")
+ expect(data.local.cfg_diff).toBe("stacked")
+ expect(data.local.cfg_speed).toBe(1.5)
+ expect(data.local.cfg_accel).toBe(true)
+ expect(data.local.cfg_submit).toBe("ctrl+enter")
+ })
+
+ test("installs themes in the correct scope and remains resilient", () => {
+ expect(data.local.before).toBe(false)
+ expect(data.local.set_missing).toBe(false)
+ expect(data.local.after).toBe(true)
+ expect(data.local.set_installed).toBe(true)
+ expect(data.local.selected).toBe(data.local_theme)
+ expect(data.local.same).toBe(true)
+
+ expect(data.global.has).toBe(true)
+ expect(data.global.set_installed).toBe(true)
+ expect(data.global.selected).toBe(data.global_theme)
+
+ expect(data.invalid.before).toBe(false)
+ expect(data.invalid.set_missing).toBe(false)
+ expect(data.invalid.after).toBe(false)
+ expect(data.invalid.set_installed).toBe(false)
+
+ expect(data.preloaded.before).toBe(true)
+ expect(data.preloaded.after).toBe(true)
+ expect(data.preloaded.text).toContain("#303030")
+ expect(data.preloaded.text).not.toContain("#f0f0f0")
+
+ expect(data.fn_called).toBe(false)
+ expect(data.local_installed).toContain("#101010")
+ expect(data.local_installed).not.toContain("#fefefe")
+ expect(data.global_installed).toContain("#202020")
+ expect(data.preloaded_installed).toContain("#303030")
+ expect(data.preloaded_installed).not.toContain("#f0f0f0")
+ expect(data.leaked_local_to_global).toBe(false)
+ expect(data.leaked_global_to_local).toBe(false)
+ })
+})
diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
new file mode 100644
index 000000000..c407d1117
--- /dev/null
+++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
@@ -0,0 +1,157 @@
+import { expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { TuiConfig } from "../../../src/config/tui"
+
+const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+
+test("toggles plugin runtime state by exported id", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "toggle-plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "toggle.txt")
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.toggle",
+ tui: async (api, options) => {
+ const text = await Bun.file(options.marker).text().catch(() => "")
+ await Bun.write(options.marker, text + "start\\n")
+ api.lifecycle.onDispose(async () => {
+ const next = await Bun.file(options.marker).text().catch(() => "")
+ await Bun.write(options.marker, next + "stop\\n")
+ })
+ },
+}
+`,
+ )
+
+ return {
+ spec,
+ marker,
+ }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+ plugin_enabled: {
+ "demo.toggle": false,
+ },
+ plugin_meta: {
+ [tmp.extra.spec]: {
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const api = createTuiPluginApi()
+
+ try {
+ await TuiPluginRuntime.init(api)
+
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+ expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
+ id: "demo.toggle",
+ source: "file",
+ spec: tmp.extra.spec,
+ target: tmp.extra.spec,
+ enabled: false,
+ active: false,
+ })
+
+ await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
+ expect(api.kv.get("plugin_enabled", {})).toEqual({
+ "demo.toggle": true,
+ })
+
+ await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
+ expect(api.kv.get("plugin_enabled", {})).toEqual({
+ "demo.toggle": false,
+ })
+
+ await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
+
+test("kv plugin_enabled overrides tui config on startup", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "startup-plugin.ts")
+ const spec = pathToFileURL(file).href
+ const marker = path.join(dir, "startup.txt")
+
+ await Bun.write(
+ file,
+ `export default {
+ id: "demo.startup",
+ tui: async (_api, options) => {
+ await Bun.write(options.marker, "on")
+ },
+}
+`,
+ )
+
+ return {
+ spec,
+ marker,
+ }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
+ plugin_enabled: {
+ "demo.startup": false,
+ },
+ plugin_meta: {
+ [tmp.extra.spec]: {
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ },
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+ const api = createTuiPluginApi()
+ api.kv.set("plugin_enabled", {
+ "demo.startup": true,
+ })
+
+ try {
+ await TuiPluginRuntime.init(api)
+
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
+ expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
+ id: "demo.startup",
+ source: "file",
+ spec: tmp.extra.spec,
+ target: tmp.extra.spec,
+ enabled: true,
+ active: true,
+ })
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
diff --git a/packages/opencode/test/cli/tui/theme-store.test.ts b/packages/opencode/test/cli/tui/theme-store.test.ts
new file mode 100644
index 000000000..23dcfb71c
--- /dev/null
+++ b/packages/opencode/test/cli/tui/theme-store.test.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "bun:test"
+
+const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } =
+ await import("../../../src/cli/cmd/tui/context/theme")
+
+test("addTheme writes into module theme store", () => {
+ const name = `plugin-theme-${Date.now()}`
+ expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
+
+ expect(allThemes()[name]).toBeDefined()
+})
+
+test("addTheme keeps first theme for duplicate names", () => {
+ const name = `plugin-theme-keep-${Date.now()}`
+ const one = structuredClone(DEFAULT_THEMES.opencode)
+ const two = structuredClone(DEFAULT_THEMES.opencode)
+ one.theme.primary = "#101010"
+ two.theme.primary = "#fefefe"
+
+ expect(addTheme(name, one)).toBe(true)
+ expect(addTheme(name, two)).toBe(false)
+
+ expect(allThemes()[name]).toBeDefined()
+ expect(allThemes()[name]!.theme.primary).toBe("#101010")
+})
+
+test("addTheme ignores entries without a theme object", () => {
+ const name = `plugin-theme-invalid-${Date.now()}`
+ expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
+ expect(allThemes()[name]).toBeUndefined()
+})
+
+test("hasTheme checks theme presence", () => {
+ const name = `plugin-theme-has-${Date.now()}`
+ expect(hasTheme(name)).toBe(false)
+ expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
+ expect(hasTheme(name)).toBe(true)
+})
+
+test("resolveTheme rejects circular color refs", () => {
+ const item = structuredClone(DEFAULT_THEMES.opencode)
+ item.defs = {
+ ...(item.defs ?? {}),
+ one: "two",
+ two: "one",
+ }
+ item.theme.primary = "one"
+
+ expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
+})
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 33f700ebf..aa49aa4bd 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -20,6 +20,7 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util/filesystem"
+import * as Network from "../../src/util/network"
import { BunProc } from "../../src/bun"
const emptyAccount = Layer.mock(Account.Service)({
@@ -765,6 +766,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
+ const online = spyOn(Network, "online").mockReturnValue(false)
+ const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
+ const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ await fs.mkdir(mod, { recursive: true })
+ await Filesystem.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+ )
+ return {
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ }
+ })
try {
await Instance.provide({
@@ -778,25 +793,43 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
} finally {
+ online.mockRestore()
+ run.mockRestore()
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev
}
})
-test("serializes concurrent config dependency installs", async () => {
+test("dedupes concurrent config dependency installs for the same dir", async () => {
await using tmp = await tmpdir()
- const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
- await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
+ const dir = path.join(tmp.path, "a")
+ await fs.mkdir(dir, { recursive: true })
- const seen: string[] = []
- let active = 0
- let max = 0
+ const ticks: number[] = []
+ let calls = 0
+ let start = () => {}
+ let done = () => {}
+ let blocked = () => {}
+ const ready = new Promise<void>((resolve) => {
+ start = resolve
+ })
+ const gate = new Promise<void>((resolve) => {
+ done = resolve
+ })
+ const waiting = new Promise<void>((resolve) => {
+ blocked = resolve
+ })
+ const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
- active++
- max = Math.max(max, active)
- seen.push(opts?.cwd ?? "")
- await new Promise((resolve) => setTimeout(resolve, 25))
- active--
+ calls += 1
+ start()
+ await gate
+ const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ await fs.mkdir(mod, { recursive: true })
+ await Filesystem.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+ )
return {
code: 0,
stdout: Buffer.alloc(0),
@@ -805,15 +838,85 @@ test("serializes concurrent config dependency installs", async () => {
})
try {
- await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
+ const first = Config.installDependencies(dir)
+ await ready
+ const second = Config.installDependencies(dir, {
+ waitTick: (tick) => {
+ ticks.push(tick.attempt)
+ blocked()
+ blocked = () => {}
+ },
+ })
+ await waiting
+ done()
+ await Promise.all([first, second])
} finally {
+ online.mockRestore()
run.mockRestore()
}
- expect(max).toBe(1)
- expect(seen.toSorted()).toEqual(dirs.toSorted())
- expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
- expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
+ expect(calls).toBe(1)
+ expect(ticks.length).toBeGreaterThan(0)
+ expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
+})
+
+test("serializes config dependency installs across dirs", async () => {
+ if (process.platform !== "win32") return
+
+ await using tmp = await tmpdir()
+ const a = path.join(tmp.path, "a")
+ const b = path.join(tmp.path, "b")
+ await fs.mkdir(a, { recursive: true })
+ await fs.mkdir(b, { recursive: true })
+
+ let calls = 0
+ let open = 0
+ let peak = 0
+ let start = () => {}
+ let done = () => {}
+ const ready = new Promise<void>((resolve) => {
+ start = resolve
+ })
+ const gate = new Promise<void>((resolve) => {
+ done = resolve
+ })
+
+ const online = spyOn(Network, "online").mockReturnValue(false)
+ const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
+ calls += 1
+ open += 1
+ peak = Math.max(peak, open)
+ if (calls === 1) {
+ start()
+ await gate
+ }
+ const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ await fs.mkdir(mod, { recursive: true })
+ await Filesystem.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
+ )
+ open -= 1
+ return {
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ }
+ })
+
+ try {
+ const first = Config.installDependencies(a)
+ await ready
+ const second = Config.installDependencies(b)
+ done()
+ await Promise.all([first, second])
+ } finally {
+ online.mockRestore()
+ run.mockRestore()
+ }
+
+ expect(calls).toBe(2)
+ expect(peak).toBe(1)
})
test("resolves scoped npm plugins in config", async () => {
@@ -855,15 +958,7 @@ test("resolves scoped npm plugins in config", async () => {
fn: async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []
-
- const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
- const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href
-
- expect(pluginEntries.includes(expected)).toBe(true)
-
- const scopedEntry = pluginEntries.find((entry) => entry === expected)
- expect(scopedEntry).toBeDefined()
- expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
+ expect(pluginEntries).toContain("@scope/plugin")
},
})
})
@@ -1710,27 +1805,43 @@ test("wellknown URL with trailing slash is normalized", async () => {
}
})
-describe("getPluginName", () => {
- test("extracts name from file:// URL", () => {
- expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
- expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
- expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
+describe("resolvePluginSpec", () => {
+ test("keeps package specs unchanged", async () => {
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "opencode.json")
+ expect(await Config.resolvePluginSpec("[email protected]", file)).toBe("[email protected]")
+ expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
})
- test("extracts name from npm package with version", () => {
- expect(Config.getPluginName("[email protected]")).toBe("oh-my-opencode")
- expect(Config.getPluginName("[email protected]")).toBe("some-plugin")
- expect(Config.getPluginName("plugin@latest")).toBe("plugin")
- })
+ test("resolves relative file plugin paths to file urls", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
+ },
+ })
- test("extracts name from scoped npm package", () => {
- expect(Config.getPluginName("@scope/[email protected]")).toBe("@scope/pkg")
- expect(Config.getPluginName("@opencode/[email protected]")).toBe("@opencode/plugin")
+ const file = path.join(tmp.path, "opencode.json")
+ const hit = await Config.resolvePluginSpec("./plugin.ts", file)
+ expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
})
- test("returns full string for package without version", () => {
- expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
- expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
+ test("resolves plugin directory paths to package main files", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const plugin = path.join(dir, "plugin")
+ await fs.mkdir(plugin, { recursive: true })
+ await Filesystem.writeJson(path.join(plugin, "package.json"), {
+ name: "demo-plugin",
+ type: "module",
+ main: "./index.ts",
+ })
+ await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+ },
+ })
+
+ const file = path.join(tmp.path, "opencode.json")
+ const hit = await Config.resolvePluginSpec("./plugin", file)
+ expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
})
})
@@ -1747,13 +1858,20 @@ describe("deduplicatePlugins", () => {
expect(result.length).toBe(3)
})
- test("prefers local file over npm package with same name", () => {
+ test("keeps path plugins separate from package plugins", () => {
const plugins = ["[email protected]", "file:///project/.opencode/plugin/oh-my-opencode.js"]
const result = Config.deduplicatePlugins(plugins)
- expect(result.length).toBe(1)
- expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
+ expect(result).toEqual(plugins)
+ })
+
+ test("deduplicates direct path plugins by exact spec", () => {
+ const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
+
+ const result = Config.deduplicatePlugins(plugins)
+
+ expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
})
test("preserves order of remaining plugins", () => {
@@ -1764,7 +1882,7 @@ describe("deduplicatePlugins", () => {
expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
})
- test("local plugin directory overrides global opencode.json plugin", async () => {
+ test("loads auto-discovered local plugins as file urls", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
@@ -1790,9 +1908,8 @@ describe("deduplicatePlugins", () => {
const config = await Config.get()
const plugins = config.plugin ?? []
- const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
- expect(myPlugins.length).toBe(1)
- expect(myPlugins[0].startsWith("file://")).toBe(true)
+ expect(plugins.some((p) => Config.pluginSpecifier(p) === "[email protected]")).toBe(true)
+ expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
},
})
})
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index f9de5b041..14f02fe30 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
- await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({ theme: "project-theme", plugin: ["[email protected]"] }, null, 2),
+ )
await fs.mkdir(managedConfigDir, { recursive: true })
- await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
+ await Bun.write(
+ path.join(managedConfigDir, "tui.json"),
+ JSON.stringify({ theme: "managed-theme", plugin: ["[email protected]"] }, null, 2),
+ )
},
})
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
+ expect(config.plugin).toEqual(["[email protected]"])
+ expect(config.plugin_meta).toEqual({
+ scope: "global",
+ source: path.join(managedConfigDir, "tui.json"),
+ },
+ })
},
})
})
@@ -508,3 +521,147 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
},
})
})
+
+test("supports tuple plugin specs with options in tui.json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ plugin: [["[email protected]", { enabled: true, label: "demo" }]],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
+ expect(config.plugin_meta).toEqual({
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ })
+ },
+ })
+})
+
+test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(Global.Path.config, "tui.json"),
+ JSON.stringify({
+ plugin: [["[email protected]", { source: "global" }]],
+ }),
+ )
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ plugin: [
+ ["[email protected]", { source: "project" }],
+ ["[email protected]", { source: "project" }],
+ ],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.plugin).toEqual([
+ ["[email protected]", { source: "project" }],
+ ["[email protected]", { source: "project" }],
+ ])
+ expect(config.plugin_meta).toEqual({
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ })
+ },
+ })
+})
+
+test("tracks global and local plugin metadata in merged tui config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(Global.Path.config, "tui.json"),
+ JSON.stringify({
+ plugin: ["[email protected]"],
+ }),
+ )
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ plugin: ["[email protected]"],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
+ expect(config.plugin_meta).toEqual({
+ scope: "global",
+ source: path.join(Global.Path.config, "tui.json"),
+ },
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ })
+ },
+ })
+})
+
+test("merges plugin_enabled flags across config layers", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(Global.Path.config, "tui.json"),
+ JSON.stringify({
+ plugin_enabled: {
+ "internal:sidebar-context": false,
+ "demo.plugin": true,
+ },
+ }),
+ )
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ plugin_enabled: {
+ "demo.plugin": false,
+ "local.plugin": true,
+ },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.plugin_enabled).toEqual({
+ "internal:sidebar-context": false,
+ "demo.plugin": false,
+ "local.plugin": true,
+ })
+ },
+ })
+})
diff --git a/packages/opencode/test/fixture/flock-worker.ts b/packages/opencode/test/fixture/flock-worker.ts
new file mode 100644
index 000000000..ac05fe810
--- /dev/null
+++ b/packages/opencode/test/fixture/flock-worker.ts
@@ -0,0 +1,72 @@
+import fs from "fs/promises"
+import { Flock } from "../../src/util/flock"
+
+type Msg = {
+ key: string
+ dir: string
+ staleMs?: number
+ timeoutMs?: number
+ baseDelayMs?: number
+ maxDelayMs?: number
+ holdMs?: number
+ ready?: string
+ active?: string
+ done?: string
+}
+
+function sleep(ms: number) {
+ return new Promise<void>((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+function input() {
+ const raw = process.argv[2]
+ if (!raw) {
+ throw new Error("Missing flock worker input")
+ }
+
+ return JSON.parse(raw) as Msg
+}
+
+async function job(input: Msg) {
+ if (input.ready) {
+ await fs.writeFile(input.ready, String(process.pid))
+ }
+
+ if (input.active) {
+ await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
+ }
+
+ try {
+ if (input.holdMs && input.holdMs > 0) {
+ await sleep(input.holdMs)
+ }
+
+ if (input.done) {
+ await fs.appendFile(input.done, "1\n")
+ }
+ } finally {
+ if (input.active) {
+ await fs.rm(input.active, { force: true })
+ }
+ }
+}
+
+async function main() {
+ const msg = input()
+
+ await Flock.withLock(msg.key, () => job(msg), {
+ dir: msg.dir,
+ staleMs: msg.staleMs,
+ timeoutMs: msg.timeoutMs,
+ baseDelayMs: msg.baseDelayMs,
+ maxDelayMs: msg.maxDelayMs,
+ })
+}
+
+await main().catch((err) => {
+ const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
+ process.stderr.write(text)
+ process.exit(1)
+})
diff --git a/packages/opencode/test/fixture/plug-worker.ts b/packages/opencode/test/fixture/plug-worker.ts
new file mode 100644
index 000000000..e4b80c5dc
--- /dev/null
+++ b/packages/opencode/test/fixture/plug-worker.ts
@@ -0,0 +1,93 @@
+import path from "path"
+
+import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
+import { Filesystem } from "../../src/util/filesystem"
+
+type Msg = {
+ dir: string
+ target: string
+ mod: string
+ global?: boolean
+ force?: boolean
+ globalDir?: string
+ vcs?: string
+ worktree?: string
+ directory?: string
+ holdMs?: number
+}
+
+function sleep(ms: number) {
+ return new Promise<void>((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+function input() {
+ const raw = process.argv[2]
+ if (!raw) {
+ throw new Error("Missing plug worker input")
+ }
+
+ const msg = JSON.parse(raw) as Partial<Msg>
+ if (!msg.dir || !msg.target || !msg.mod) {
+ throw new Error("Invalid plug worker input")
+ }
+
+ return msg as Msg
+}
+
+function deps(msg: Msg): PlugDeps {
+ return {
+ spinner: () => ({
+ start() {},
+ stop() {},
+ }),
+ log: {
+ error() {},
+ info() {},
+ success() {},
+ },
+ resolve: async () => msg.target,
+ readText: (file) => Filesystem.readText(file),
+ write: async (file, text) => {
+ if (msg.holdMs && msg.holdMs > 0) {
+ await sleep(msg.holdMs)
+ }
+ await Filesystem.write(file, text)
+ },
+ exists: (file) => Filesystem.exists(file),
+ files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
+ global: msg.globalDir ?? path.join(msg.dir, ".global"),
+ }
+}
+
+function ctx(msg: Msg): PlugCtx {
+ return {
+ vcs: msg.vcs ?? "git",
+ worktree: msg.worktree ?? msg.dir,
+ directory: msg.directory ?? msg.dir,
+ }
+}
+
+async function main() {
+ const msg = input()
+ const run = createPlugTask(
+ {
+ mod: msg.mod,
+ global: msg.global,
+ force: msg.force,
+ },
+ deps(msg),
+ )
+
+ const ok = await run(ctx(msg))
+ if (!ok) {
+ throw new Error("Plug task failed")
+ }
+}
+
+await main().catch((err) => {
+ const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
+ process.stderr.write(text)
+ process.exit(1)
+})
diff --git a/packages/opencode/test/fixture/plugin-meta-worker.ts b/packages/opencode/test/fixture/plugin-meta-worker.ts
new file mode 100644
index 000000000..86284b4c7
--- /dev/null
+++ b/packages/opencode/test/fixture/plugin-meta-worker.ts
@@ -0,0 +1,26 @@
+type Msg = {
+ file: string
+ spec: string
+ target: string
+ id: string
+}
+
+const raw = process.argv[2]
+if (!raw) throw new Error("Missing worker payload")
+
+const value = JSON.parse(raw)
+if (!value || typeof value !== "object") {
+ throw new Error("Invalid worker payload")
+}
+
+const msg = Object.fromEntries(Object.entries(value))
+if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
+ throw new Error("Invalid worker payload")
+}
+if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
+
+process.env.OPENCODE_PLUGIN_META_FILE = msg.file
+
+const { PluginMeta } = await import("../../src/plugin/meta")
+
+await PluginMeta.touch(msg.spec, msg.target, msg.id)
diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts
new file mode 100644
index 000000000..c982d129f
--- /dev/null
+++ b/packages/opencode/test/fixture/tui-plugin.ts
@@ -0,0 +1,334 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { RGBA, type CliRenderer } from "@opentui/core"
+import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
+import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
+
+type Count = {
+ event_add: number
+ event_drop: number
+ route_add: number
+ route_drop: number
+ command_add: number
+ command_drop: number
+}
+
+function themeCurrent(): HostPluginApi["theme"]["current"] {
+ const a = RGBA.fromInts(0, 120, 240)
+ const b = RGBA.fromInts(120, 120, 120)
+ const c = RGBA.fromInts(230, 230, 230)
+ const d = RGBA.fromInts(120, 30, 30)
+ const e = RGBA.fromInts(140, 100, 40)
+ const f = RGBA.fromInts(20, 140, 80)
+ const g = RGBA.fromInts(20, 80, 160)
+ const h = RGBA.fromInts(40, 40, 40)
+ const i = RGBA.fromInts(60, 60, 60)
+ const j = RGBA.fromInts(80, 80, 80)
+ return {
+ primary: a,
+ secondary: b,
+ accent: a,
+ error: d,
+ warning: e,
+ success: f,
+ info: g,
+ text: c,
+ textMuted: b,
+ selectedListItemText: h,
+ background: h,
+ backgroundPanel: h,
+ backgroundElement: i,
+ backgroundMenu: i,
+ border: j,
+ borderActive: c,
+ borderSubtle: i,
+ diffAdded: f,
+ diffRemoved: d,
+ diffContext: b,
+ diffHunkHeader: b,
+ diffHighlightAdded: f,
+ diffHighlightRemoved: d,
+ diffAddedBg: h,
+ diffRemovedBg: h,
+ diffContextBg: h,
+ diffLineNumber: b,
+ diffAddedLineNumberBg: h,
+ diffRemovedLineNumberBg: h,
+ markdownText: c,
+ markdownHeading: c,
+ markdownLink: a,
+ markdownLinkText: g,
+ markdownCode: f,
+ markdownBlockQuote: e,
+ markdownEmph: e,
+ markdownStrong: c,
+ markdownHorizontalRule: b,
+ markdownListItem: a,
+ markdownListEnumeration: g,
+ markdownImage: a,
+ markdownImageText: g,
+ markdownCodeBlock: c,
+ syntaxComment: b,
+ syntaxKeyword: a,
+ syntaxFunction: g,
+ syntaxVariable: c,
+ syntaxString: f,
+ syntaxNumber: e,
+ syntaxType: a,
+ syntaxOperator: a,
+ syntaxPunctuation: c,
+ thinkingOpacity: 0.6,
+ }
+}
+
+type Opts = {
+ client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
+ scopedClient?: HostPluginApi["scopedClient"]
+ workspace?: Partial<HostPluginApi["workspace"]>
+ renderer?: HostPluginApi["renderer"]
+ count?: Count
+ keybind?: Partial<HostPluginApi["keybind"]>
+ tuiConfig?: HostPluginApi["tuiConfig"]
+ app?: Partial<HostPluginApi["app"]>
+ state?: {
+ ready?: HostPluginApi["state"]["ready"]
+ config?: HostPluginApi["state"]["config"]
+ provider?: HostPluginApi["state"]["provider"]
+ path?: HostPluginApi["state"]["path"]
+ vcs?: HostPluginApi["state"]["vcs"]
+ workspace?: Partial<HostPluginApi["state"]["workspace"]>
+ session?: Partial<HostPluginApi["state"]["session"]>
+ part?: HostPluginApi["state"]["part"]
+ lsp?: HostPluginApi["state"]["lsp"]
+ mcp?: HostPluginApi["state"]["mcp"]
+ }
+ theme?: {
+ selected?: string
+ has?: HostPluginApi["theme"]["has"]
+ set?: HostPluginApi["theme"]["set"]
+ install?: HostPluginApi["theme"]["install"]
+ mode?: HostPluginApi["theme"]["mode"]
+ ready?: boolean
+ current?: HostPluginApi["theme"]["current"]
+ }
+}
+
+export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
+ const kv: Record<string, unknown> = {}
+ const count = opts.count
+ const ctrl = new AbortController()
+ const own = createOpencodeClient({
+ baseUrl: "http://localhost:4096",
+ })
+ const fallback = () => own
+ const read =
+ typeof opts.client === "function"
+ ? opts.client
+ : opts.client
+ ? () => opts.client as HostPluginApi["client"]
+ : fallback
+ const client = () => read()
+ const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
+ const workspace: HostPluginApi["workspace"] = {
+ current: opts.workspace?.current ?? (() => undefined),
+ set: opts.workspace?.set ?? (() => {}),
+ }
+ let depth = 0
+ let size: "medium" | "large" | "xlarge" = "medium"
+ const has = opts.theme?.has ?? (() => false)
+ let selected = opts.theme?.selected ?? "opencode"
+ const key = {
+ match: opts.keybind?.match ?? (() => false),
+ print: opts.keybind?.print ?? ((name: string) => name),
+ }
+ const set =
+ opts.theme?.set ??
+ ((name: string) => {
+ if (!has(name)) return false
+ selected = name
+ return true
+ })
+ const renderer: CliRenderer = opts.renderer ?? {
+ ...Object.create(null),
+ once(this: CliRenderer) {
+ return this
+ },
+ }
+
+ function kvGet(name: string): unknown
+ function kvGet<Value>(name: string, fallback: Value): Value
+ function kvGet(name: string, fallback?: unknown) {
+ const value = kv[name]
+ if (value === undefined) return fallback
+ return value
+ }
+
+ return {
+ app: {
+ get version() {
+ return opts.app?.version ?? "0.0.0-test"
+ },
+ },
+ get client() {
+ return client()
+ },
+ scopedClient,
+ workspace,
+ event: {
+ on: () => {
+ if (count) count.event_add += 1
+ return () => {
+ if (!count) return
+ count.event_drop += 1
+ }
+ },
+ },
+ renderer,
+ slots: {
+ register: () => "fixture-slot",
+ },
+ plugins: {
+ list: () => [],
+ activate: async () => false,
+ deactivate: async () => false,
+ add: async () => false,
+ install: async () => ({
+ ok: false,
+ message: "not implemented in fixture",
+ }),
+ },
+ lifecycle: {
+ signal: ctrl.signal,
+ onDispose() {
+ return () => {}
+ },
+ },
+ command: {
+ register: () => {
+ if (count) count.command_add += 1
+ return () => {
+ if (!count) return
+ count.command_drop += 1
+ }
+ },
+ trigger: () => {},
+ },
+ route: {
+ register: () => {
+ if (count) count.route_add += 1
+ return () => {
+ if (!count) return
+ count.route_drop += 1
+ }
+ },
+ navigate: () => {},
+ get current() {
+ return { name: "home" }
+ },
+ },
+ ui: {
+ Dialog: () => null,
+ DialogAlert: () => null,
+ DialogConfirm: () => null,
+ DialogPrompt: () => null,
+ DialogSelect: () => null,
+ toast: () => {},
+ dialog: {
+ replace: () => {
+ depth = 1
+ },
+ clear: () => {
+ depth = 0
+ size = "medium"
+ },
+ setSize: (next) => {
+ size = next
+ },
+ get size() {
+ return size
+ },
+ get depth() {
+ return depth
+ },
+ get open() {
+ return depth > 0
+ },
+ },
+ },
+ keybind: {
+ ...key,
+ create:
+ opts.keybind?.create ??
+ ((defaults, over) => {
+ return createPluginKeybind(key, defaults, over)
+ }),
+ },
+ tuiConfig: opts.tuiConfig ?? {},
+ kv: {
+ get: kvGet,
+ set(name, value) {
+ kv[name] = value
+ },
+ get ready() {
+ return true
+ },
+ },
+ state: {
+ get ready() {
+ return opts.state?.ready ?? true
+ },
+ get config() {
+ return opts.state?.config ?? {}
+ },
+ get provider() {
+ return opts.state?.provider ?? []
+ },
+ get path() {
+ return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
+ },
+ get vcs() {
+ return opts.state?.vcs
+ },
+ workspace: {
+ list: opts.state?.workspace?.list ?? (() => []),
+ get: opts.state?.workspace?.get ?? (() => undefined),
+ },
+ session: {
+ count: opts.state?.session?.count ?? (() => 0),
+ diff: opts.state?.session?.diff ?? (() => []),
+ todo: opts.state?.session?.todo ?? (() => []),
+ messages: opts.state?.session?.messages ?? (() => []),
+ status: opts.state?.session?.status ?? (() => undefined),
+ permission: opts.state?.session?.permission ?? (() => []),
+ question: opts.state?.session?.question ?? (() => []),
+ },
+ part: opts.state?.part ?? (() => []),
+ lsp: opts.state?.lsp ?? (() => []),
+ mcp: opts.state?.mcp ?? (() => []),
+ },
+ theme: {
+ get current() {
+ return opts.theme?.current ?? themeCurrent()
+ },
+ get selected() {
+ return selected
+ },
+ has(name) {
+ return has(name)
+ },
+ set(name) {
+ return set(name)
+ },
+ async install(file) {
+ if (opts.theme?.install) return opts.theme.install(file)
+ throw new Error("base theme.install should not run")
+ },
+ mode() {
+ if (opts.theme?.mode) return opts.theme.mode()
+ return "dark"
+ },
+ get ready() {
+ return opts.theme?.ready ?? true
+ },
+ },
+ }
+}
diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts
new file mode 100644
index 000000000..67ea4b9a4
--- /dev/null
+++ b/packages/opencode/test/fixture/tui-runtime.ts
@@ -0,0 +1,34 @@
+import { spyOn } from "bun:test"
+import path from "path"
+import { TuiConfig } from "../../src/config/tui"
+
+type PluginSpec = string | [string, Record<string, unknown>]
+
+export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
+ const meta = Object.fromEntries(
+ plugin.map((item) => {
+ const spec = Array.isArray(item) ? item[0] : item
+ return [
+ spec,
+ {
+ scope: "local" as const,
+ source: path.join(dir, "tui.json"),
+ },
+ ]
+ }),
+ )
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin,
+ plugin_meta: meta,
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+ const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
+
+ return () => {
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+}
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
index 667b7ba9a..c25984be6 100644
--- a/packages/opencode/test/plugin/auth-override.test.ts
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -16,15 +16,18 @@ describe("plugin.auth-override", () => {
await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
- "export default async () => ({",
- " auth: {",
- ' provider: "github-copilot",',
- " methods: [",
- ' { type: "api", label: "Test Override Auth" },',
- " ],",
- " loader: async () => ({ access: 'test-token' }),",
- " },",
- "})",
+ "export default {",
+ ' id: "demo.custom-copilot-auth",',
+ " server: async () => ({",
+ " auth: {",
+ ' provider: "github-copilot",',
+ " methods: [",
+ ' { type: "api", label: "Test Override Auth" },',
+ " ],",
+ " loader: async () => ({ access: 'test-token' }),",
+ " },",
+ " }),",
+ "}",
"",
].join("\n"),
)
diff --git a/packages/opencode/test/plugin/install-concurrency.test.ts b/packages/opencode/test/plugin/install-concurrency.test.ts
new file mode 100644
index 000000000..d21d7ca35
--- /dev/null
+++ b/packages/opencode/test/plugin/install-concurrency.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts")
+
+type Msg = {
+ dir: string
+ target: string
+ mod: string
+ holdMs?: number
+}
+
+function run(msg: Msg) {
+ return Process.run([process.execPath, worker, JSON.stringify(msg)], {
+ cwd: root,
+ nothrow: true,
+ })
+}
+
+async function plugin(dir: string, kinds: Array<"server" | "tui">) {
+ const p = path.join(dir, "plugin")
+ await fs.mkdir(p, { recursive: true })
+ await Bun.write(
+ path.join(p, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme",
+ version: "1.0.0",
+ "oc-plugin": kinds,
+ },
+ null,
+ 2,
+ ),
+ )
+ return p
+}
+
+async function read(file: string) {
+ return Filesystem.readJson<{ plugin?: unknown[] }>(file)
+}
+
+function mods(prefix: string, n: number) {
+ return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`)
+}
+
+function expectPlugins(list: unknown[] | undefined, expectMods: string[]) {
+ expect(Array.isArray(list)).toBe(true)
+ const hit = (list ?? []).filter((item): item is string => typeof item === "string")
+ expect(hit.length).toBe(expectMods.length)
+ expect(new Set(hit)).toEqual(new Set(expectMods))
+}
+
+describe("plugin.install.concurrent", () => {
+ test("serializes concurrent server config updates across processes", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const all = mods("mod-server", 12)
+
+ const out = await Promise.all(
+ all.map((mod) =>
+ run({
+ dir: tmp.path,
+ target,
+ mod,
+ holdMs: 30,
+ }),
+ ),
+ )
+
+ expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
+ expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+ const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+ expectPlugins(cfg.plugin, all)
+ }, 25_000)
+
+ test("serializes concurrent server+tui config updates across processes", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server", "tui"])
+ const all = mods("mod-both", 10)
+
+ const out = await Promise.all(
+ all.map((mod) =>
+ run({
+ dir: tmp.path,
+ target,
+ mod,
+ holdMs: 30,
+ }),
+ ),
+ )
+
+ expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
+ expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+ const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+ const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+ expectPlugins(server.plugin, all)
+ expectPlugins(tui.plugin, all)
+ }, 25_000)
+
+ test("preserves updates when existing config uses .json", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ await Bun.write(cfg, JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+
+ const next = mods("mod-json", 8)
+ const out = await Promise.all(
+ next.map((mod) =>
+ run({
+ dir: tmp.path,
+ target,
+ mod,
+ holdMs: 30,
+ }),
+ ),
+ )
+
+ expect(out.map((x) => x.code)).toEqual(Array.from({ length: next.length }, () => 0))
+ expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+ const json = await read(cfg)
+ expectPlugins(json.plugin, ["[email protected]", ...next])
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ }, 25_000)
+})
diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts
new file mode 100644
index 000000000..e7d39bf87
--- /dev/null
+++ b/packages/opencode/test/plugin/install.test.ts
@@ -0,0 +1,410 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Filesystem } from "../../src/util/filesystem"
+import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
+import { tmpdir } from "../fixture/fixture"
+
+function deps(global: string, target: string | Error): PlugDeps {
+ return {
+ spinner: () => ({
+ start() {},
+ stop() {},
+ }),
+ log: {
+ error() {},
+ info() {},
+ success() {},
+ },
+ resolve: async () => {
+ if (target instanceof Error) throw target
+ return target
+ },
+ readText: (file) => Filesystem.readText(file),
+ write: async (file, text) => {
+ await Filesystem.write(file, text)
+ },
+ exists: (file) => Filesystem.exists(file),
+ files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
+ global,
+ }
+}
+
+function ctx(dir: string): PlugCtx {
+ return {
+ vcs: "git",
+ worktree: dir,
+ directory: dir,
+ }
+}
+
+function ctxDir(dir: string, worktree: string): PlugCtx {
+ return {
+ vcs: "none",
+ worktree,
+ directory: dir,
+ }
+}
+
+function ctxRoot(dir: string): PlugCtx {
+ return {
+ vcs: "git",
+ worktree: "/",
+ directory: dir,
+ }
+}
+
+async function plugin(dir: string, kinds?: unknown) {
+ const p = path.join(dir, "plugin")
+ await fs.mkdir(p, { recursive: true })
+ await Bun.write(
+ path.join(p, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme",
+ version: "1.0.0",
+ ...(kinds === undefined ? {} : { "oc-plugin": kinds }),
+ },
+ null,
+ 2,
+ ),
+ )
+ return p
+}
+
+async function read(file: string) {
+ return Filesystem.readJson<{
+ plugin?: unknown[]
+ }>(file)
+}
+
+describe("plugin.install.task", () => {
+ test("writes both server and tui config entries", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server", "tui"])
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+
+ const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+ const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+ expect(server.plugin).toEqual(["[email protected]"])
+ expect(tui.plugin).toEqual(["[email protected]"])
+ })
+
+ test("writes default options from tuple manifest targets", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, [
+ ["server", { custom: true, other: false }],
+ ["tui", { compact: true }],
+ ])
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+
+ const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+ const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+ expect(server.plugin).toEqual([["[email protected]", { custom: true, other: false }]])
+ expect(tui.plugin).toEqual([["[email protected]", { compact: true }]])
+ })
+
+ test("supports resolver target pointing to a file", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const file = path.join(target, "index.js")
+ await Bun.write(file, "export {}")
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), file),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
+ expect(server.plugin).toEqual(["[email protected]"])
+ })
+
+ test("does not change configured package version without force", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ await Bun.write(cfg, JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const json = await read(cfg)
+ expect(json.plugin).toEqual(["[email protected]"])
+ })
+
+ test("does not change scoped package version without force", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/[email protected]"] }, null, 2))
+
+ const run = createPlugTask(
+ {
+ mod: "@scope/[email protected]",
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const json = await read(cfg)
+ expect(json.plugin).toEqual(["@scope/[email protected]"])
+ })
+
+ test("keeps file plugin entries and still adds npm plugin", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
+
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const json = await read(cfg)
+ expect(json.plugin).toEqual(["file:///tmp/acme.ts", "[email protected]"])
+ })
+
+ test("force replaces configured package version and keeps tuple options", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.json")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ await Bun.write(
+ cfg,
+ JSON.stringify(
+ {
+ plugin: [["[email protected]", { mode: "safe" }], "[email protected]", "[email protected]"],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ const run = createPlugTask(
+ {
+ force: true,
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const json = await read(cfg)
+ expect(json.plugin).toEqual([["[email protected]", { mode: "safe" }], "[email protected]"])
+ })
+
+ test("writes to global scope when global flag is set", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const global = path.join(tmp.path, "global")
+ const run = createPlugTask(
+ {
+ global: true,
+ },
+ deps(global, target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+
+ expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+
+ test("writes local scope under directory when vcs is not git", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const directory = path.join(tmp.path, "dir")
+ const worktree = path.join(tmp.path, "worktree")
+ await fs.mkdir(directory, { recursive: true })
+ await fs.mkdir(worktree, { recursive: true })
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctxDir(directory, worktree))
+ expect(ok).toBe(true)
+ expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
+ expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+
+ test("writes local scope under directory when worktree is root slash", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const directory = path.join(tmp.path, "dir")
+ await fs.mkdir(directory, { recursive: true })
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctxRoot(directory))
+ expect(ok).toBe(true)
+ expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
+ })
+
+ test("writes tui local scope under directory when worktree is root slash", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["tui"])
+ const directory = path.join(tmp.path, "dir")
+ await fs.mkdir(directory, { recursive: true })
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctxRoot(directory))
+ expect(ok).toBe(true)
+ expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true)
+ })
+
+ test("writes only tui config for tui-only plugins", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["tui"])
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+
+ test("force replaces version in both server and tui configs", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server", "tui"])
+ const server = path.join(tmp.path, ".opencode", "opencode.json")
+ const tui = path.join(tmp.path, ".opencode", "tui.json")
+ await fs.mkdir(path.dirname(server), { recursive: true })
+ await Bun.write(server, JSON.stringify({ plugin: ["[email protected]", "[email protected]"] }, null, 2))
+ await Bun.write(tui, JSON.stringify({ plugin: [["[email protected]", { mode: "safe" }], "[email protected]"] }, null, 2))
+
+ const run = createPlugTask(
+ {
+ force: true,
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ const serverJson = await read(server)
+ const tuiJson = await read(tui)
+ expect(serverJson.plugin).toEqual(["[email protected]", "[email protected]"])
+ expect(tuiJson.plugin).toEqual([["[email protected]", { mode: "safe" }], "[email protected]"])
+ })
+
+ test("returns false and keeps config unchanged for invalid JSONC", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, ["server"])
+ const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
+ await fs.mkdir(path.dirname(cfg), { recursive: true })
+ const bad = '{"plugin": ["[email protected]",}'
+ await Bun.write(cfg, bad)
+
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(false)
+ expect(await fs.readFile(cfg, "utf8")).toBe(bad)
+ })
+
+ test("returns false when manifest declares no supported targets", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path)
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
+ })
+
+ test("returns false when manifest cannot be read", async () => {
+ await using tmp = await tmpdir()
+ const target = path.join(tmp.path, "plugin")
+ await fs.mkdir(target, { recursive: true })
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+
+ test("returns false when install fails", async () => {
+ await using tmp = await tmpdir()
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), new Error("boom")),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+})
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
new file mode 100644
index 000000000..572f790fa
--- /dev/null
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -0,0 +1,548 @@
+import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../fixture/fixture"
+import { Filesystem } from "../../src/util/filesystem"
+
+const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
+
+const { Plugin } = await import("../../src/plugin/index")
+const { Instance } = await import("../../src/project/instance")
+const { BunProc } = await import("../../src/bun")
+const { Bus } = await import("../../src/bus")
+const { Session } = await import("../../src/session")
+
+afterAll(() => {
+ if (disableDefault === undefined) {
+ delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+ return
+ }
+ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
+})
+
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+async function load(dir: string) {
+ return Instance.provide({
+ directory: dir,
+ fn: async () => {
+ await Plugin.list()
+ },
+ })
+}
+
+async function errs(dir: string) {
+ return Instance.provide({
+ directory: dir,
+ fn: async () => {
+ const errors: string[] = []
+ const off = Bus.subscribe(Session.Event.Error, (evt) => {
+ const error = evt.properties.error
+ if (!error || typeof error !== "object") return
+ if (!("data" in error)) return
+ if (!error.data || typeof error.data !== "object") return
+ if (!("message" in error.data)) return
+ if (typeof error.data.message !== "string") return
+ errors.push(error.data.message)
+ })
+ await Plugin.list()
+ off()
+ return errors
+ },
+ })
+}
+
+describe("plugin.loader.shared", () => {
+ test("loads a file:// plugin function export", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const mark = path.join(dir, "called.txt")
+ await Bun.write(
+ file,
+ [
+ "export default async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+ " return {}",
+ "}",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ await load(tmp.path)
+ expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
+ })
+
+ test("deduplicates same function exported as default and named", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const mark = path.join(dir, "count.txt")
+ await Bun.write(mark, "")
+ await Bun.write(
+ file,
+ [
+ "const run = async () => {",
+ ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
+ ` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
+ " return {}",
+ "}",
+ "export default run",
+ "export const named = run",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ await load(tmp.path)
+ expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
+ })
+
+ test("uses only default v1 server plugin when present", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const mark = path.join(dir, "count.txt")
+ await Bun.write(
+ file,
+ [
+ "export default {",
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, "default")`,
+ " return {}",
+ " },",
+ "}",
+ "export const named = async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, "named")`,
+ " return {}",
+ "}",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ await load(tmp.path)
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
+ })
+
+ test("resolves npm plugin specs with explicit and default versions", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const acme = path.join(dir, "node_modules", "acme-plugin")
+ const scope = path.join(dir, "node_modules", "scope-plugin")
+ await fs.mkdir(acme, { recursive: true })
+ await fs.mkdir(scope, { recursive: true })
+ await Bun.write(
+ path.join(acme, "package.json"),
+ JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
+ )
+ await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
+ await Bun.write(
+ path.join(scope, "package.json"),
+ JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
+ )
+ await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: ["acme-plugin", "[email protected]"] }, null, 2),
+ )
+
+ return { acme, scope }
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
+ if (pkg === "acme-plugin") return tmp.extra.acme
+ return tmp.extra.scope
+ })
+
+ try {
+ await load(tmp.path)
+
+ expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
+ expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("loads npm server plugin from package ./server export", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const mark = path.join(dir, "server-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme-plugin",
+ type: "module",
+ exports: {
+ ".": "./index.js",
+ "./server": "./server.js",
+ "./tui": "./tui.js",
+ },
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
+ await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
+ await Bun.write(
+ path.join(mod, "server.js"),
+ [
+ "export default {",
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, "called")`,
+ " return {}",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+ await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+
+ return {
+ mod,
+ mark,
+ }
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+ try {
+ await load(tmp.path)
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("rejects npm server export that resolves outside plugin directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const outside = path.join(dir, "outside")
+ const mark = path.join(dir, "outside-server.txt")
+ await fs.mkdir(mod, { recursive: true })
+ await fs.mkdir(outside, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme-plugin",
+ type: "module",
+ exports: {
+ ".": "./index.js",
+ "./server": "./escape/server.js",
+ },
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+ await Bun.write(
+ path.join(outside, "server.js"),
+ [
+ "export default {",
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, "outside")`,
+ " return {}",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+ await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
+
+ return {
+ mod,
+ mark,
+ }
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+ try {
+ const errors = await errs(tmp.path)
+ const called = await Bun.file(tmp.extra.mark)
+ .text()
+ .then(() => true)
+ .catch(() => false)
+ expect(called).toBe(false)
+ expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("skips legacy codex and copilot auth plugin specs", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify(
+ {
+ },
+ null,
+ 2,
+ ),
+ )
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockResolvedValue("")
+
+ try {
+ await load(tmp.path)
+
+ const pkgs = install.mock.calls.map((call) => call[0])
+ expect(pkgs).toContain("regular-plugin")
+ expect(pkgs).not.toContain("opencode-openai-codex-auth")
+ expect(pkgs).not.toContain("opencode-copilot-auth")
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("publishes session.error when install fails", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
+
+ try {
+ const errors = await errs(tmp.path)
+
+ expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
+ true,
+ )
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("publishes session.error when plugin init throws", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = pathToFileURL(path.join(dir, "throws.ts")).href
+ await Bun.write(
+ path.join(dir, "throws.ts"),
+ [
+ "export default {",
+ ' id: "demo.throws",',
+ " server: async () => {",
+ ' throw new Error("explode")',
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+
+ return { file }
+ },
+ })
+
+ const errors = await errs(tmp.path)
+
+ expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
+ })
+
+ test("publishes session.error when plugin module has invalid export", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = pathToFileURL(path.join(dir, "invalid.ts")).href
+ await Bun.write(
+ path.join(dir, "invalid.ts"),
+ ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
+ )
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+
+ return { file }
+ },
+ })
+
+ const errors = await errs(tmp.path)
+
+ expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
+ })
+
+ test("publishes session.error when plugin import fails", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
+
+ return { missing }
+ },
+ })
+
+ const errors = await errs(tmp.path)
+
+ expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
+ })
+
+ test("loads object plugin via plugin.server", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "object-plugin.ts")
+ const mark = path.join(dir, "object-called.txt")
+ await Bun.write(
+ file,
+ [
+ "const plugin = {",
+ ' id: "demo.object",',
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+ " return {}",
+ " },",
+ "}",
+ "export default plugin",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ await load(tmp.path)
+ expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
+ })
+
+ test("passes tuple plugin options into server plugin", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "options-plugin.ts")
+ const mark = path.join(dir, "options.json")
+ await Bun.write(
+ file,
+ [
+ "const plugin = {",
+ ' id: "demo.options",',
+ " server: async (_input, options) => {",
+ ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
+ " return {}",
+ " },",
+ "}",
+ "export default plugin",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ await load(tmp.path)
+ expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
+ source: "tuple",
+ enabled: true,
+ })
+ })
+
+ test("skips external plugins in pure mode", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const mark = path.join(dir, "called.txt")
+ await Bun.write(
+ file,
+ [
+ "export default {",
+ ' id: "demo.pure",',
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
+ " return {}",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+ )
+
+ return { mark }
+ },
+ })
+
+ const pure = process.env.OPENCODE_PURE
+ process.env.OPENCODE_PURE = "1"
+
+ try {
+ await load(tmp.path)
+ const called = await fs
+ .readFile(tmp.extra.mark, "utf8")
+ .then(() => true)
+ .catch(() => false)
+ expect(called).toBe(false)
+ } finally {
+ if (pure === undefined) {
+ delete process.env.OPENCODE_PURE
+ } else {
+ process.env.OPENCODE_PURE = pure
+ }
+ }
+ })
+})
diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts
new file mode 100644
index 000000000..057174066
--- /dev/null
+++ b/packages/opencode/test/plugin/meta.test.ts
@@ -0,0 +1,137 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { pathToFileURL } from "url"
+
+import { tmpdir } from "../fixture/fixture"
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+
+const { PluginMeta } = await import("../../src/plugin/meta")
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
+
+function run(input: { file: string; spec: string; target: string; id: string }) {
+ return Process.run([process.execPath, worker, JSON.stringify(input)], {
+ cwd: root,
+ nothrow: true,
+ })
+}
+
+async function map<Value>(file: string): Promise<Record<string, Value>> {
+ return Filesystem.readJson<Record<string, Value>>(file)
+}
+
+afterEach(() => {
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+})
+
+describe("plugin.meta", () => {
+ test("tracks file plugin loads and changes", async () => {
+ await using tmp = await tmpdir<{ file: string }>({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ await Bun.write(file, "export default async () => ({})\n")
+ return { file }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+ const file = process.env.OPENCODE_PLUGIN_META_FILE!
+ const spec = pathToFileURL(tmp.extra.file).href
+
+ const one = await PluginMeta.touch(spec, spec, "demo.file")
+ expect(one.state).toBe("first")
+ expect(one.entry.source).toBe("file")
+ expect(one.entry.id).toBe("demo.file")
+ expect(one.entry.modified).toBeDefined()
+
+ const two = await PluginMeta.touch(spec, spec, "demo.file")
+ expect(two.state).toBe("same")
+ expect(two.entry.load_count).toBe(2)
+
+ await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
+ const stamp = new Date(Date.now() + 10_000)
+ await fs.utimes(tmp.extra.file, stamp, stamp)
+
+ const three = await PluginMeta.touch(spec, spec, "demo.file")
+ expect(three.state).toBe("updated")
+ expect(three.entry.load_count).toBe(3)
+ expect((three.entry.modified ?? 0) > (one.entry.modified ?? 0)).toBe(true)
+
+ const all = await PluginMeta.list()
+ expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
+ const saved = await map<{ spec: string; load_count: number }>(file)
+ expect(saved["demo.file"]?.spec).toBe(spec)
+ expect(saved["demo.file"]?.load_count).toBe(3)
+ })
+
+ test("tracks npm plugin versions", async () => {
+ await using tmp = await tmpdir<{ mod: string; pkg: string }>({
+ init: async (dir) => {
+ const mod = path.join(dir, "node_modules", "acme-plugin")
+ const pkg = path.join(mod, "package.json")
+ await fs.mkdir(mod, { recursive: true })
+ await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
+ return { mod, pkg }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+ const file = process.env.OPENCODE_PLUGIN_META_FILE!
+
+ const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
+ expect(one.state).toBe("first")
+ expect(one.entry.source).toBe("npm")
+ expect(one.entry.requested).toBe("latest")
+ expect(one.entry.version).toBe("1.0.0")
+
+ await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
+
+ const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
+ expect(two.state).toBe("updated")
+ expect(two.entry.version).toBe("1.1.0")
+ expect(two.entry.load_count).toBe(2)
+
+ const all = await PluginMeta.list()
+ expect(Object.values(all).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
+ const saved = await map<{ id: string; version?: string }>(file)
+ expect(Object.values(saved).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
+ })
+
+ test("serializes concurrent metadata updates across processes", async () => {
+ await using tmp = await tmpdir<{ file: string }>({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ await Bun.write(file, "export default async () => ({})\n")
+ return { file }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
+ const file = process.env.OPENCODE_PLUGIN_META_FILE!
+ const spec = pathToFileURL(tmp.extra.file).href
+ const n = 12
+
+ const out = await Promise.all(
+ Array.from({ length: n }, () =>
+ run({
+ file,
+ spec,
+ target: spec,
+ id: "demo.file",
+ }),
+ ),
+ )
+
+ expect(out.map((item) => item.code)).toEqual(Array.from({ length: n }, () => 0))
+ expect(out.map((item) => item.stderr.toString()).filter(Boolean)).toEqual([])
+
+ const all = await PluginMeta.list()
+ const hit = Object.values(all).find((item) => item.spec === spec)
+ expect(hit?.load_count).toBe(n)
+
+ const saved = await map<{ spec: string; load_count: number }>(file)
+ expect(Object.values(saved).find((item) => item.spec === spec)?.load_count).toBe(n)
+ }, 20_000)
+})
diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts
new file mode 100644
index 000000000..e536f3c4e
--- /dev/null
+++ b/packages/opencode/test/util/error.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test"
+import { errorData, errorFormat, errorMessage } from "../../src/util/error"
+
+describe("util.error", () => {
+ test("formats native Error instances", () => {
+ const err = new Error("boom")
+ expect(errorMessage(err)).toBe("boom")
+ expect(errorFormat(err)).toContain("boom")
+
+ const data = errorData(err)
+ expect(data.type).toBe("Error")
+ expect(data.message).toBe("boom")
+ expect(String(data.formatted)).toContain("boom")
+ })
+
+ test("extracts message from record-like values", () => {
+ const err = { message: "bad input", code: "E_BAD" }
+ expect(errorMessage(err)).toBe("bad input")
+
+ const data = errorData(err)
+ expect(data.message).toBe("bad input")
+ expect(data.code).toBe("E_BAD")
+ })
+
+ test("handles opaque throwables with custom toString", () => {
+ const err = {
+ toString() {
+ return "ResolveMessage: Cannot resolve module"
+ },
+ }
+
+ expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
+
+ const data = errorData(err)
+ expect(data.message).toBe("ResolveMessage: Cannot resolve module")
+ expect(String(data.formatted)).toContain("ResolveMessage")
+ })
+})
diff --git a/packages/opencode/test/util/flock.test.ts b/packages/opencode/test/util/flock.test.ts
new file mode 100644
index 000000000..fedbfb069
--- /dev/null
+++ b/packages/opencode/test/util/flock.test.ts
@@ -0,0 +1,383 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Flock } from "../../src/util/flock"
+import { Hash } from "../../src/util/hash"
+import { Process } from "../../src/util/process"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+const root = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
+
+type Msg = {
+ key: string
+ dir: string
+ staleMs?: number
+ timeoutMs?: number
+ baseDelayMs?: number
+ maxDelayMs?: number
+ holdMs?: number
+ ready?: string
+ active?: string
+ done?: string
+}
+
+function lock(dir: string, key: string) {
+ return path.join(dir, Hash.fast(key) + ".lock")
+}
+
+function sleep(ms: number) {
+ return new Promise<void>((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+async function exists(file: string) {
+ return fs
+ .stat(file)
+ .then(() => true)
+ .catch(() => false)
+}
+
+async function wait(file: string, timeout = 3_000) {
+ const stop = Date.now() + timeout
+ while (Date.now() < stop) {
+ if (await exists(file)) return
+ await sleep(20)
+ }
+
+ throw new Error(`Timed out waiting for file: ${file}`)
+}
+
+function run(msg: Msg) {
+ return Process.run([process.execPath, worker, JSON.stringify(msg)], {
+ cwd: root,
+ nothrow: true,
+ })
+}
+
+function spawn(msg: Msg) {
+ return Process.spawn([process.execPath, worker, JSON.stringify(msg)], {
+ cwd: root,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+}
+
+describe("util.flock", () => {
+ test("enforces mutual exclusion under process contention", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const done = path.join(tmp.path, "done.log")
+ const active = path.join(tmp.path, "active")
+ const key = "flock:stress"
+ const n = 16
+
+ const out = await Promise.all(
+ Array.from({ length: n }, () =>
+ run({
+ key,
+ dir,
+ done,
+ active,
+ holdMs: 30,
+ staleMs: 1_000,
+ timeoutMs: 15_000,
+ }),
+ ),
+ )
+
+ expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
+ expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
+
+ const lines = (await fs.readFile(done, "utf8"))
+ .split("\n")
+ .map((x) => x.trim())
+ .filter(Boolean)
+ expect(lines.length).toBe(n)
+ }, 20_000)
+
+ test("times out while waiting when lock is still healthy", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:timeout"
+ const ready = path.join(tmp.path, "ready")
+ const proc = spawn({
+ key,
+ dir,
+ ready,
+ holdMs: 20_000,
+ staleMs: 10_000,
+ timeoutMs: 30_000,
+ })
+
+ try {
+ await wait(ready, 5_000)
+ const seen: string[] = []
+ const err = await Flock.withLock(key, async () => {}, {
+ dir,
+ staleMs: 10_000,
+ timeoutMs: 1_000,
+ onWait: (tick) => {
+ seen.push(tick.key)
+ },
+ }).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Error)
+ if (!(err instanceof Error)) throw err
+ expect(err.message).toContain("Timed out waiting for lock")
+ expect(seen.length).toBeGreaterThan(0)
+ expect(seen.every((x) => x === key)).toBe(true)
+ } finally {
+ await Process.stop(proc).catch(() => undefined)
+ await proc.exited.catch(() => undefined)
+ }
+ }, 15_000)
+
+ test("recovers after a crashed lock owner", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:crash"
+ const ready = path.join(tmp.path, "ready")
+ const proc = spawn({
+ key,
+ dir,
+ ready,
+ holdMs: 20_000,
+ staleMs: 500,
+ timeoutMs: 30_000,
+ })
+
+ await wait(ready, 5_000)
+ await Process.stop(proc)
+ await proc.exited.catch(() => undefined)
+
+ let hit = false
+ await Flock.withLock(
+ key,
+ async () => {
+ hit = true
+ },
+ {
+ dir,
+ staleMs: 500,
+ timeoutMs: 8_000,
+ },
+ )
+
+ expect(hit).toBe(true)
+ }, 20_000)
+
+ test("breaks stale lock dirs when heartbeat is missing", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:missing-heartbeat"
+ const lockDir = lock(dir, key)
+
+ await fs.mkdir(lockDir, { recursive: true })
+ const old = new Date(Date.now() - 2_000)
+ await fs.utimes(lockDir, old, old)
+
+ let hit = false
+ await Flock.withLock(
+ key,
+ async () => {
+ hit = true
+ },
+ {
+ dir,
+ staleMs: 200,
+ timeoutMs: 3_000,
+ },
+ )
+
+ expect(hit).toBe(true)
+ })
+
+ test("recovers when a stale breaker claim was left behind", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:stale-breaker"
+ const lockDir = lock(dir, key)
+ const breaker = lockDir + ".breaker"
+
+ await fs.mkdir(lockDir, { recursive: true })
+ await fs.mkdir(breaker)
+
+ const old = new Date(Date.now() - 2_000)
+ await fs.utimes(lockDir, old, old)
+ await fs.utimes(breaker, old, old)
+
+ let hit = false
+ await Flock.withLock(
+ key,
+ async () => {
+ hit = true
+ },
+ {
+ dir,
+ staleMs: 200,
+ timeoutMs: 3_000,
+ },
+ )
+
+ expect(hit).toBe(true)
+ expect(await exists(breaker)).toBe(false)
+ })
+
+ test("fails clearly if lock dir is removed while held", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:compromised"
+ const lockDir = lock(dir, key)
+
+ const err = await Flock.withLock(
+ key,
+ async () => {
+ await fs.rm(lockDir, {
+ recursive: true,
+ force: true,
+ })
+ },
+ {
+ dir,
+ staleMs: 1_000,
+ timeoutMs: 3_000,
+ },
+ ).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Error)
+ if (!(err instanceof Error)) throw err
+ expect(err.message).toContain("compromised")
+
+ let hit = false
+ await Flock.withLock(
+ key,
+ async () => {
+ hit = true
+ },
+ {
+ dir,
+ staleMs: 200,
+ timeoutMs: 3_000,
+ },
+ )
+ expect(hit).toBe(true)
+ })
+
+ test("writes owner metadata while lock is held", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:meta"
+ const file = path.join(lock(dir, key), "meta.json")
+
+ await Flock.withLock(
+ key,
+ async () => {
+ const json = await Filesystem.readJson<{
+ token?: unknown
+ pid?: unknown
+ hostname?: unknown
+ createdAt?: unknown
+ }>(file)
+
+ expect(typeof json.token).toBe("string")
+ expect(typeof json.pid).toBe("number")
+ expect(typeof json.hostname).toBe("string")
+ expect(typeof json.createdAt).toBe("string")
+ },
+ {
+ dir,
+ staleMs: 1_000,
+ timeoutMs: 3_000,
+ },
+ )
+ })
+
+ test("supports acquire with await using", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:acquire"
+ const lockDir = lock(dir, key)
+
+ {
+ await using _ = await Flock.acquire(key, {
+ dir,
+ staleMs: 1_000,
+ timeoutMs: 3_000,
+ })
+ expect(await exists(lockDir)).toBe(true)
+ }
+
+ expect(await exists(lockDir)).toBe(false)
+ })
+
+ test("refuses token mismatch release and recovers from stale", async () => {
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:token"
+ const lockDir = lock(dir, key)
+ const meta = path.join(lockDir, "meta.json")
+
+ const err = await Flock.withLock(
+ key,
+ async () => {
+ const json = await Filesystem.readJson<{ token?: string }>(meta)
+ json.token = "tampered"
+ await fs.writeFile(meta, JSON.stringify(json, null, 2))
+ },
+ {
+ dir,
+ staleMs: 500,
+ timeoutMs: 3_000,
+ },
+ ).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Error)
+ if (!(err instanceof Error)) throw err
+ expect(err.message).toContain("token mismatch")
+ expect(await exists(lockDir)).toBe(true)
+
+ let hit = false
+ await Flock.withLock(
+ key,
+ async () => {
+ hit = true
+ },
+ {
+ dir,
+ staleMs: 500,
+ timeoutMs: 6_000,
+ },
+ )
+ expect(hit).toBe(true)
+ })
+
+ test("fails clearly on unwritable lock roots", async () => {
+ if (process.platform === "win32") return
+
+ await using tmp = await tmpdir()
+ const dir = path.join(tmp.path, "locks")
+ const key = "flock:perm"
+
+ await fs.mkdir(dir, { recursive: true })
+ await fs.chmod(dir, 0o500)
+
+ try {
+ const err = await Flock.withLock(key, async () => {}, {
+ dir,
+ staleMs: 100,
+ timeoutMs: 500,
+ }).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Error)
+ if (!(err instanceof Error)) throw err
+ const text = err.message
+ expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true)
+ } finally {
+ await fs.chmod(dir, 0o700)
+ }
+ })
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 5004a3ee2..c0565a7a2 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -10,7 +10,8 @@
},
"exports": {
".": "./src/index.ts",
- "./tool": "./src/tool.ts"
+ "./tool": "./src/tool.ts",
+ "./tui": "./src/tui.ts"
},
"files": [
"dist"
@@ -19,7 +20,21 @@
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:"
},
+ "peerDependencies": {
+ "@opentui/core": ">=0.1.90",
+ "@opentui/solid": ">=0.1.90"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
+ },
"devDependencies": {
+ "@opentui/core": "0.1.90",
+ "@opentui/solid": "0.1.90",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 8bdb51a2a..d289689b9 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -9,7 +9,7 @@ import type {
Message,
Part,
Auth,
- Config,
+ Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { BunShell } from "./shell.js"
@@ -32,7 +32,18 @@ export type PluginInput = {
$: BunShell
}
-export type Plugin = (input: PluginInput) => Promise<Hooks>
+export type PluginOptions = Record<string, unknown>
+
+export type Config = Omit<SDKConfig, "plugin"> & {
+ plugin?: Array<string | [string, PluginOptions]>
+}
+
+export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
+
+export type PluginModule = {
+ id?: string
+ server?: Plugin
+}
type Rule = {
key: string
@@ -72,7 +83,7 @@ export type AuthHook = {
when?: Rule
}
>
- authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
+ authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
}
| {
type: "api"
@@ -116,7 +127,7 @@ export type AuthHook = {
)[]
}
-export type AuthOuathResult = { url: string; instructions: string } & (
+export type AuthOAuthResult = { url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
@@ -161,6 +172,9 @@ export type AuthOuathResult = { url: string; instructions: string } & (
}
)
+/** @deprecated Use AuthOAuthResult instead. */
+export type AuthOuathResult = AuthOAuthResult
+
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts
new file mode 100644
index 000000000..62747884f
--- /dev/null
+++ b/packages/plugin/src/tui.ts
@@ -0,0 +1,419 @@
+import type {
+ OpencodeClient,
+ Event,
+ LspStatus,
+ McpStatus,
+ Todo,
+ Message,
+ Part,
+ Provider,
+ PermissionRequest,
+ QuestionRequest,
+ SessionStatus,
+ Workspace,
+ Config as SdkConfig,
+} from "@opencode-ai/sdk/v2"
+import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
+import type { JSX, SolidPlugin } from "@opentui/solid"
+import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
+
+export type { CliRenderer, SlotMode } from "@opentui/core"
+
+export type TuiRouteCurrent =
+ | {
+ name: "home"
+ }
+ | {
+ name: "session"
+ params: {
+ sessionID: string
+ initialPrompt?: unknown
+ }
+ }
+ | {
+ name: string
+ params?: Record<string, unknown>
+ }
+
+export type TuiRouteDefinition = {
+ name: string
+ render: (input: { params?: Record<string, unknown> }) => JSX.Element
+}
+
+export type TuiCommand = {
+ title: string
+ value: string
+ description?: string
+ category?: string
+ keybind?: string
+ suggested?: boolean
+ hidden?: boolean
+ enabled?: boolean
+ slash?: {
+ name: string
+ aliases?: string[]
+ }
+ onSelect?: () => void
+}
+
+export type TuiKeybind = {
+ name: string
+ ctrl: boolean
+ meta: boolean
+ shift: boolean
+ super?: boolean
+ leader: boolean
+}
+
+export type TuiKeybindMap = Record<string, string>
+
+export type TuiKeybindSet = {
+ readonly all: TuiKeybindMap
+ get: (name: string) => string
+ match: (name: string, evt: ParsedKey) => boolean
+ print: (name: string) => string
+}
+
+export type TuiDialogProps = {
+ size?: "medium" | "large" | "xlarge"
+ onClose: () => void
+ children?: JSX.Element
+}
+
+export type TuiDialogStack = {
+ replace: (render: () => JSX.Element, onClose?: () => void) => void
+ clear: () => void
+ setSize: (size: "medium" | "large" | "xlarge") => void
+ readonly size: "medium" | "large" | "xlarge"
+ readonly depth: number
+ readonly open: boolean
+}
+
+export type TuiDialogAlertProps = {
+ title: string
+ message: string
+ onConfirm?: () => void
+}
+
+export type TuiDialogConfirmProps = {
+ title: string
+ message: string
+ onConfirm?: () => void
+ onCancel?: () => void
+}
+
+export type TuiDialogPromptProps = {
+ title: string
+ description?: () => JSX.Element
+ placeholder?: string
+ value?: string
+ onConfirm?: (value: string) => void
+ onCancel?: () => void
+}
+
+export type TuiDialogSelectOption<Value = unknown> = {
+ title: string
+ value: Value
+ description?: string
+ footer?: JSX.Element | string
+ category?: string
+ disabled?: boolean
+ onSelect?: () => void
+}
+
+export type TuiDialogSelectProps<Value = unknown> = {
+ title: string
+ placeholder?: string
+ options: TuiDialogSelectOption<Value>[]
+ flat?: boolean
+ onMove?: (option: TuiDialogSelectOption<Value>) => void
+ onFilter?: (query: string) => void
+ onSelect?: (option: TuiDialogSelectOption<Value>) => void
+ skipFilter?: boolean
+ current?: Value
+}
+
+export type TuiToast = {
+ variant?: "info" | "success" | "warning" | "error"
+ title?: string
+ message: string
+ duration?: number
+}
+
+export type TuiThemeCurrent = {
+ readonly primary: RGBA
+ readonly secondary: RGBA
+ readonly accent: RGBA
+ readonly error: RGBA
+ readonly warning: RGBA
+ readonly success: RGBA
+ readonly info: RGBA
+ readonly text: RGBA
+ readonly textMuted: RGBA
+ readonly selectedListItemText: RGBA
+ readonly background: RGBA
+ readonly backgroundPanel: RGBA
+ readonly backgroundElement: RGBA
+ readonly backgroundMenu: RGBA
+ readonly border: RGBA
+ readonly borderActive: RGBA
+ readonly borderSubtle: RGBA
+ readonly diffAdded: RGBA
+ readonly diffRemoved: RGBA
+ readonly diffContext: RGBA
+ readonly diffHunkHeader: RGBA
+ readonly diffHighlightAdded: RGBA
+ readonly diffHighlightRemoved: RGBA
+ readonly diffAddedBg: RGBA
+ readonly diffRemovedBg: RGBA
+ readonly diffContextBg: RGBA
+ readonly diffLineNumber: RGBA
+ readonly diffAddedLineNumberBg: RGBA
+ readonly diffRemovedLineNumberBg: RGBA
+ readonly markdownText: RGBA
+ readonly markdownHeading: RGBA
+ readonly markdownLink: RGBA
+ readonly markdownLinkText: RGBA
+ readonly markdownCode: RGBA
+ readonly markdownBlockQuote: RGBA
+ readonly markdownEmph: RGBA
+ readonly markdownStrong: RGBA
+ readonly markdownHorizontalRule: RGBA
+ readonly markdownListItem: RGBA
+ readonly markdownListEnumeration: RGBA
+ readonly markdownImage: RGBA
+ readonly markdownImageText: RGBA
+ readonly markdownCodeBlock: RGBA
+ readonly syntaxComment: RGBA
+ readonly syntaxKeyword: RGBA
+ readonly syntaxFunction: RGBA
+ readonly syntaxVariable: RGBA
+ readonly syntaxString: RGBA
+ readonly syntaxNumber: RGBA
+ readonly syntaxType: RGBA
+ readonly syntaxOperator: RGBA
+ readonly syntaxPunctuation: RGBA
+ readonly thinkingOpacity: number
+}
+
+export type TuiTheme = {
+ readonly current: TuiThemeCurrent
+ readonly selected: string
+ has: (name: string) => boolean
+ set: (name: string) => boolean
+ install: (jsonPath: string) => Promise<void>
+ mode: () => "dark" | "light"
+ readonly ready: boolean
+}
+
+export type TuiKV = {
+ get: <Value = unknown>(key: string, fallback?: Value) => Value
+ set: (key: string, value: unknown) => void
+ readonly ready: boolean
+}
+
+export type TuiState = {
+ readonly ready: boolean
+ readonly config: SdkConfig
+ readonly provider: ReadonlyArray<Provider>
+ readonly path: {
+ state: string
+ config: string
+ worktree: string
+ directory: string
+ }
+ readonly vcs: { branch?: string } | undefined
+ readonly workspace: {
+ list: () => ReadonlyArray<Workspace>
+ get: (workspaceID: string) => Workspace | undefined
+ }
+ session: {
+ count: () => number
+ diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
+ todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
+ messages: (sessionID: string) => ReadonlyArray<Message>
+ status: (sessionID: string) => SessionStatus | undefined
+ permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
+ question: (sessionID: string) => ReadonlyArray<QuestionRequest>
+ }
+ part: (messageID: string) => ReadonlyArray<Part>
+ lsp: () => ReadonlyArray<TuiSidebarLspItem>
+ mcp: () => ReadonlyArray<TuiSidebarMcpItem>
+}
+
+type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
+ NonNullable<PluginConfig["tui"]> & {
+ plugin_enabled?: Record<string, boolean>
+ }
+
+export type TuiApp = {
+ readonly version: string
+}
+
+type Frozen<Value> = Value extends (...args: never[]) => unknown
+ ? Value
+ : Value extends ReadonlyArray<infer Item>
+ ? ReadonlyArray<Frozen<Item>>
+ : Value extends object
+ ? { readonly [Key in keyof Value]: Frozen<Value[Key]> }
+ : Value
+
+export type TuiSidebarMcpItem = {
+ name: string
+ status: McpStatus["status"]
+ error?: string
+}
+
+export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
+
+export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
+
+export type TuiSidebarFileItem = {
+ file: string
+ additions: number
+ deletions: number
+}
+
+export type TuiSlotMap = {
+ app: {}
+ home_logo: {}
+ home_bottom: {}
+ sidebar_title: {
+ session_id: string
+ title: string
+ share_url?: string
+ }
+ sidebar_content: {
+ session_id: string
+ }
+ sidebar_footer: {
+ session_id: string
+ }
+}
+
+export type TuiSlotContext = {
+ theme: TuiTheme
+}
+
+type SlotCore = SolidPlugin<TuiSlotMap, TuiSlotContext>
+
+export type TuiSlotPlugin = Omit<SlotCore, "id"> & {
+ id?: never
+}
+
+export type TuiSlots = {
+ register: (plugin: TuiSlotPlugin) => string
+}
+
+export type TuiEventBus = {
+ on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
+}
+
+export type TuiDispose = () => void | Promise<void>
+
+export type TuiLifecycle = {
+ readonly signal: AbortSignal
+ onDispose: (fn: TuiDispose) => () => void
+}
+
+export type TuiPluginState = "first" | "updated" | "same"
+
+export type TuiPluginEntry = {
+ id: string
+ source: "file" | "npm" | "internal"
+ spec: string
+ target: string
+ requested?: string
+ version?: string
+ modified?: number
+ first_time: number
+ last_time: number
+ time_changed: number
+ load_count: number
+ fingerprint: string
+}
+
+export type TuiPluginMeta = TuiPluginEntry & {
+ state: TuiPluginState
+}
+
+export type TuiPluginStatus = {
+ id: string
+ source: TuiPluginEntry["source"]
+ spec: string
+ target: string
+ enabled: boolean
+ active: boolean
+}
+
+export type TuiPluginInstallOptions = {
+ global?: boolean
+}
+
+export type TuiPluginInstallResult =
+ | {
+ ok: true
+ dir: string
+ tui: boolean
+ }
+ | {
+ ok: false
+ message: string
+ missing?: boolean
+ }
+
+export type TuiWorkspace = {
+ current: () => string | undefined
+ set: (workspaceID?: string) => void
+}
+
+export type TuiPluginApi = {
+ app: TuiApp
+ command: {
+ register: (cb: () => TuiCommand[]) => () => void
+ trigger: (value: string) => void
+ }
+ route: {
+ register: (routes: TuiRouteDefinition[]) => () => void
+ navigate: (name: string, params?: Record<string, unknown>) => void
+ readonly current: TuiRouteCurrent
+ }
+ ui: {
+ Dialog: (props: TuiDialogProps) => JSX.Element
+ DialogAlert: (props: TuiDialogAlertProps) => JSX.Element
+ DialogConfirm: (props: TuiDialogConfirmProps) => JSX.Element
+ DialogPrompt: (props: TuiDialogPromptProps) => JSX.Element
+ DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value>) => JSX.Element
+ toast: (input: TuiToast) => void
+ dialog: TuiDialogStack
+ }
+ keybind: {
+ match: (key: string, evt: ParsedKey) => boolean
+ print: (key: string) => string
+ create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
+ }
+ readonly tuiConfig: Frozen<TuiConfigView>
+ kv: TuiKV
+ state: TuiState
+ theme: TuiTheme
+ client: OpencodeClient
+ scopedClient: (workspaceID?: string) => OpencodeClient
+ workspace: TuiWorkspace
+ event: TuiEventBus
+ renderer: CliRenderer
+ slots: TuiSlots
+ plugins: {
+ list: () => ReadonlyArray<TuiPluginStatus>
+ activate: (id: string) => Promise<boolean>
+ deactivate: (id: string) => Promise<boolean>
+ add: (spec: string) => Promise<boolean>
+ install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
+ }
+ lifecycle: TuiLifecycle
+}
+
+export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
+
+export type TuiPluginModule = PluginModule & {
+ tui?: TuiPlugin
+}
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 4a2ae9591..ce5a47f84 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1447,7 +1447,15 @@ export type Config = {
watcher?: {
ignore?: Array<string>
}
- plugin?: Array<string>
+ plugin?: Array<
+ | string
+ | [
+ string,
+ {
+ [key: string]: unknown
+ },
+ ]
+ >
/**
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
*/