tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions

View File

@@ -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"
}
}
}

View File

@@ -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,
}

1
.opencode/themes/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
smoke-theme.json

19
.opencode/tui.json Normal file
View File

@@ -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"
}
}
]
]
}

View File

@@ -428,11 +428,21 @@
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": {
"@opentui/core": ">=0.1.90",
"@opentui/solid": ">=0.1.90",
},
"optionalPeers": [
"@opentui/core",
"@opentui/solid",
],
}, },
"packages/script": { "packages/script": {
"name": "@opencode-ai/script", "name": "@opencode-ai/script",
@@ -3837,7 +3847,7 @@
"pagefind": ["pagefind@1.4.0", "", { "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=="], "pagefind": ["pagefind@1.4.0", "", { "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": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -5677,12 +5687,12 @@
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "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=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "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/expect@4.0.18", "", { "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=="], "vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "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=="],

View File

@@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? []) const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) 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 pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))

View File

@@ -1,7 +1,7 @@
preload = ["@opentui/solid/preload"] preload = ["@opentui/solid/preload"]
[test] [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) # timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead # using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789 # https://github.com/oven-sh/bun/issues/7789

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { fileURLToPath } from "url" 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 __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
@@ -63,6 +63,7 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single") const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline") const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install") const skipInstall = process.argv.includes("--skip-install")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const createEmbeddedWebUIBundle = async () => { const createEmbeddedWebUIBundle = async () => {
@@ -207,7 +208,7 @@ for (const item of targets) {
await Bun.build({ await Bun.build({
conditions: ["browser"], conditions: ["browser"],
tsconfig: "./tsconfig.json", tsconfig: "./tsconfig.json",
plugins: [solidPlugin], plugins: [plugin],
compile: { compile: {
autoloadBunfig: false, autoloadBunfig: false,
autoloadDotenv: false, autoloadDotenv: false,

View File

@@ -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/opencode-plugin@1.2.3", ["./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`

View File

@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry" import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied" import { online, proxied } from "@/util/network"
import { Process } from "../util/process" import { Process } from "../util/process"
export namespace BunProc { export namespace BunProc {
@@ -68,12 +68,13 @@ export namespace BunProc {
if (!modExists || !cachedVersion) { if (!modExists || !cachedVersion) {
// continue to install // continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") { } else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) if (!online()) return mod
if (!isOutdated) 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 }) log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
} else if (cachedVersion === version) {
return mod
} }
// Build command arguments // Build command arguments

View File

@@ -1,6 +1,7 @@
import semver from "semver" import semver from "semver"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process" import { Process } from "../util/process"
import { online } from "@/util/network"
export namespace PackageRegistry { export namespace PackageRegistry {
const log = Log.create({ service: "bun" }) 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> { 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], { const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
cwd, cwd,
env: { env: {

View File

@@ -6,6 +6,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { JsonMigration } from "../../storage/json-migration" import { JsonMigration } from "../../storage/json-migration"
import { EOL } from "os" import { EOL } from "os"
import { errorMessage } from "../../util/error"
const QueryCommand = cmd({ const QueryCommand = cmd({
command: "$0 [query]", command: "$0 [query]",
@@ -39,7 +40,7 @@ const QueryCommand = cmd({
} }
} }
} catch (err) { } catch (err) {
UI.error(err instanceof Error ? err.message : String(err)) UI.error(errorMessage(err))
process.exit(1) process.exit(1)
} }
db.close() db.close()
@@ -100,7 +101,7 @@ const MigrateCommand = cmd({
} }
} catch (err) { } catch (err) {
if (tty) process.stderr.write("\x1b[?25h") 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) process.exit(1)
} finally { } finally {
sqlite.close() sqlite.close()

View File

@@ -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
},
})

View File

@@ -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 { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection" 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 { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import {
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" 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 { Flag } from "@/flag/flag"
import semver from "semver" import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" 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 { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local" import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model" 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 { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-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 { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home" import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session" import { Session } from "@tui/routes/session"
@@ -40,8 +55,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" 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 { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY // 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 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: { export function tui(input: {
url: string url: string
@@ -132,77 +184,68 @@ export function tui(input: {
resolve() resolve()
} }
render( const onBeforeExit = async () => {
() => { await TuiPluginRuntime.dispose()
return ( }
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />} const renderer = await createCliRenderer(rendererConfig(input.config))
>
<ArgsProvider {...input.args}> await render(() => {
<ExitProvider onExit={onExit}> return (
<KVProvider> <ErrorBoundary
<ToastProvider> fallback={(error, reset) => (
<RouteProvider> <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
<TuiConfigProvider config={input.config}> )}
<SDKProvider >
url={input.url} <ArgsProvider {...input.args}>
directory={input.directory} <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
fetch={input.fetch} <KVProvider>
headers={input.headers} <ToastProvider>
events={input.events} <RouteProvider>
> <TuiConfigProvider config={input.config}>
<SyncProvider> <SDKProvider
<ThemeProvider mode={mode}> url={input.url}
<LocalProvider> directory={input.directory}
<KeybindProvider> fetch={input.fetch}
<PromptStashProvider> headers={input.headers}
<DialogProvider> events={input.events}
<CommandProvider> >
<FrecencyProvider> <SyncProvider>
<PromptHistoryProvider> <ThemeProvider mode={mode}>
<PromptRefProvider> <LocalProvider>
<App onSnapshot={input.onSnapshot} /> <KeybindProvider>
</PromptRefProvider> <PromptStashProvider>
</PromptHistoryProvider> <DialogProvider>
</FrecencyProvider> <CommandProvider>
</CommandProvider> <FrecencyProvider>
</DialogProvider> <PromptHistoryProvider>
</PromptStashProvider> <PromptRefProvider>
</KeybindProvider> <App onSnapshot={input.onSnapshot} />
</LocalProvider> </PromptRefProvider>
</ThemeProvider> </PromptHistoryProvider>
</SyncProvider> </FrecencyProvider>
</SDKProvider> </CommandProvider>
</TuiConfigProvider> </DialogProvider>
</RouteProvider> </PromptStashProvider>
</ToastProvider> </KeybindProvider>
</KVProvider> </LocalProvider>
</ExitProvider> </ThemeProvider>
</ArgsProvider> </SyncProvider>
</ErrorBoundary> </SDKProvider>
) </TuiConfigProvider>
}, </RouteProvider>
{ </ToastProvider>
targetFps: 60, </KVProvider>
gatherStats: false, </ExitProvider>
exitOnCtrlC: false, </ArgsProvider>
useKittyKeyboard: { events: process.platform === "win32" }, </ErrorBoundary>
autoFocus: false, )
openConsoleOnError: false, }, renderer)
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 App(props: { onSnapshot?: () => Promise<string[]> }) { function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig()
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
@@ -211,12 +254,47 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const local = useLocal() const local = useLocal()
const kv = useKV() const kv = useKV()
const command = useCommandDialog() const command = useCommandDialog()
const keybind = useKeybind()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() 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 sync = useSync()
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() 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) => { useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return 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)) 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 // Update terminal window title based on current route and session
createEffect(() => { createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@@ -279,9 +353,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return return
} }
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${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) => { sdk.event.on("session.error", (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => { const message = errorMessage(error)
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)
})()
toast.show({ toast.show({
variant: "error", variant: "error",
@@ -789,6 +857,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
exit() 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 ( return (
<box <box
width={dimensions().width} 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)} onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
> >
<Switch> <Show when={Flag.OPENCODE_SHOW_TTFD}>
<Match when={route.data.type === "home"}> <TimeToFirstDraw />
<Home /> </Show>
</Match> <Show when={ready()}>
<Match when={route.data.type === "session"}> <Switch>
<Session /> <Match when={route.data.type === "home"}>
</Match> <Home />
</Switch> </Match>
</box> <Match when={route.data.type === "session"}>
) <Session />
} </Match>
</Switch>
function ErrorComponent(props: { </Show>
error: Error {plugin()}
reset: () => void <TuiPluginRuntime.Slot name="app" />
onExit: () => Promise<void> <StartupLoading ready={ready} />
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>
</box> </box>
) )
} }

View File

@@ -4,13 +4,15 @@ import {
createContext, createContext,
createMemo, createMemo,
createSignal, createSignal,
getOwner,
onCleanup, onCleanup,
runWithOwner,
useContext, useContext,
type Accessor, type Accessor,
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { type KeybindKey, useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init> type Context = ReturnType<typeof init>
const ctx = createContext<Context>() const ctx = createContext<Context>()
@@ -21,7 +23,7 @@ export type Slash = {
} }
export type CommandOption = DialogSelectOption<string> & { export type CommandOption = DialogSelectOption<string> & {
keybind?: KeybindKey keybind?: string
suggested?: boolean suggested?: boolean
slash?: Slash slash?: Slash
hidden?: boolean hidden?: boolean
@@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
} }
function init() { function init() {
const root = getOwner()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0) const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog() const dialog = useDialog()
@@ -100,11 +103,32 @@ function init() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />) dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
}, },
register(cb: () => CommandOption[]) { register(cb: () => CommandOption[]) {
const results = createMemo(cb) const owner = getOwner() ?? root
setRegistrations((arr) => [results, ...arr]) if (!owner) return () => {}
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results)) 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 return result

View File

@@ -16,7 +16,8 @@ export function DialogStatus() {
const plugins = createMemo(() => { const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? [] 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://")) { if (value.startsWith("file://")) {
const path = fileURLToPath(value) const path = fileURLToPath(value)
const parts = path.split("/") const parts = path.split("/")

View File

@@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js" 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 { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list" import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises" 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: { async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute> route: ReturnType<typeof useRoute>
@@ -29,12 +37,7 @@ async function openWorkspace(input: {
) )
} }
const client = createOpencodeClient({ const client = scoped(input.sdk, input.sync, input.workspaceID)
baseUrl: input.sdk.url,
fetch: input.sdk.fetch,
directory: input.sync.data.path.directory || input.sdk.directory,
experimental_workspaceID: input.workspaceID,
})
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0] const session = listed?.data?.[0]
if (session?.id) { if (session?.id) {
@@ -187,12 +190,7 @@ export function DialogWorkspaceList() {
await open(workspaceID) await open(workspaceID)
return return
} }
const client = createOpencodeClient({ const client = scoped(sdk, sync, workspaceID)
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) { if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />) dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
@@ -223,12 +221,7 @@ export function DialogWorkspaceList() {
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all( void Promise.all(
workspaces.map(async (workspace) => { workspaces.map(async (workspace) => {
const client = createOpencodeClient({ const client = scoped(sdk, sync, workspace.id)
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspace.id,
})
const result = await client.session.list({ roots: true }).catch(() => undefined) const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}), }),

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
export const { use: useExit, provider: ExitProvider } = createSimpleContext({ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit", name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => { init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer() const renderer = useRenderer()
let message: string | undefined let message: string | undefined
let task: Promise<void> | undefined let task: Promise<void> | undefined
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
(reason?: unknown) => { (reason?: unknown) => {
if (task) return task if (task) return task
task = (async () => { task = (async () => {
await input.onBeforeExit?.()
// Reset window title before destroying renderer // Reset window title before destroying renderer
renderer.setTerminalTitle("") renderer.setTerminalTitle("")
renderer.destroy() renderer.destroy()

View File

@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
} }
return Keybind.fromParsedKey(evt, store.leader) return Keybind.fromParsedKey(evt, store.leader)
}, },
match(key: KeybindKey, evt: ParsedKey) { match(key: string, evt: ParsedKey) {
const keybind = keybinds()[key] const list = keybinds()[key] ?? Keybind.parse(key)
if (!keybind) return false if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt) const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) { for (const item of list) {
if (Keybind.match(key, parsed)) { if (Keybind.match(item, parsed)) {
return true return true
} }
} }
return false
}, },
print(key: KeybindKey) { print(key: string) {
const first = keybinds()[key]?.at(0) const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return "" if (!first) return ""
const result = Keybind.toString(first) const text = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!)) const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
}, },
} }
return result return result

View File

@@ -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)),
}
}

View File

@@ -14,7 +14,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo 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({ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route", name: "Route",
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store return store
}, },
navigate(route: Route) { navigate(route: Route) {
console.log("navigate", route)
setStore(route) setStore(route)
}, },
} }

View File

@@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() { get client() {
return sdk return sdk
}, },
get workspaceID() {
return workspaceID
},
directory: props.directory, directory: props.directory,
event: emitter, event: emitter,
fetch: props.fetch ?? fetch, fetch: props.fetch ?? fetch,

View File

@@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config" import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
type ThemeColors = { type Theme = TuiThemeCurrent & {
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 & {
_hasSelectedListItemText: boolean _hasSelectedListItemText: boolean
thinkingOpacity: number
} }
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it // If theme explicitly defines selectedListItemText, use it
@@ -128,10 +75,10 @@ type Variant = {
light: HexColor | RefName light: HexColor | RefName
} }
type ColorValue = HexColor | RefName | Variant | RGBA type ColorValue = HexColor | RefName | Variant | RGBA
type ThemeJson = { export type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & { theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue selectedListItemText?: ColorValue
backgroundMenu?: ColorValue backgroundMenu?: ColorValue
thinkingOpacity?: number thinkingOpacity?: number
@@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox, 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 ?? {} const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA { function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
if (c instanceof RGBA) return c if (c instanceof RGBA) return c
if (typeof c === "string") { if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0) if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c) if (c.startsWith("#")) return RGBA.fromHex(c)
if (defs[c] != null) { if (chain.includes(c)) {
return resolveColor(defs[c]) throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
} else if (theme.theme[c as keyof ThemeColors] !== undefined) { }
return resolveColor(theme.theme[c as keyof ThemeColors]!)
} else { const next = defs[c] ?? theme.theme[c as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${c}" not found in defs or theme`) throw new Error(`Color reference "${c}" not found in defs or theme`)
} }
return resolveColor(next, [...chain, c])
} }
if (typeof c === "number") { if (typeof c === "number") {
return ansiToRgba(c) return ansiToRgba(c)
} }
return resolveColor(c[mode]) return resolveColor(c[mode], chain)
} }
const resolved = Object.fromEntries( const resolved = Object.fromEntries(
@@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
.map(([key, value]) => { .map(([key, value]) => {
return [key, resolveColor(value as ColorValue)] return [key, resolveColor(value as ColorValue)]
}), }),
) as Partial<ThemeColors> ) as Partial<Record<ThemeColor, RGBA>>
// Handle selectedListItemText separately since it's optional // Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
@@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (value === "dark" || value === "light") return value if (value === "dark" || value === "light") return value
return return
} }
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({ setStore(
themes: DEFAULT_THEMES, produce((draft) => {
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode, const lock = pick(kv.get("theme_mode_lock"))
lock, const mode = pick(kv.get("theme_mode", props.mode))
active: (config.theme ?? kv.get("theme", "opencode")) as string, draft.mode = lock ?? mode ?? props.mode
ready: false, draft.lock = lock
}) const active = config.theme ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
)
createEffect(() => { createEffect(() => {
const theme = config.theme const theme = config.theme
@@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
function init() { function init() {
resolveSystemTheme(store.mode) Promise.allSettled([
getCustomThemes() resolveSystemTheme(store.mode),
.then((custom) => { getCustomThemes()
setStore( .then((custom) => {
produce((draft) => { customThemes = custom
Object.assign(draft.themes, custom) syncThemes()
}), })
) .catch(() => {
}) setStore("active", "opencode")
.catch(() => { }),
setStore("active", "opencode") ]).finally(() => {
}) setStore("ready", true)
.finally(() => { })
if (store.active !== "system") {
setStore("ready", true)
}
})
} }
onMount(init) onMount(init)
function resolveSystemTheme(mode: "dark" | "light" = store.mode) { function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
renderer return renderer
.getPalette({ .getPalette({
size: 16, size: 16,
}) })
.then((colors) => { .then((colors: TerminalColors) => {
if (!colors.palette[0]) { if (!colors.palette[0]) {
systemTheme = undefined
syncThemes()
if (store.active === "system") { if (store.active === "system") {
setStore( setStore("active", "opencode")
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
} }
return return
} }
setStore( systemTheme = generateSystem(colors, mode)
produce((draft) => { syncThemes()
draft.themes.system = generateSystem(colors, mode) })
if (store.active === "system") { .catch(() => {
draft.ready = true systemTheme = undefined
} syncThemes()
}), if (store.active === "system") {
) setStore("active", "opencode")
}
}) })
} }
@@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
apply(mode) apply(mode)
} }
renderer.on(CliRenderEvents.THEME_MODE, handle) renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => {
renderer.clearPaletteCache()
init()
}
process.on("SIGUSR2", refresh)
onCleanup(() => { onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle) renderer.off(CliRenderEvents.THEME_MODE, handle)
process.off("SIGUSR2", refresh)
}) })
const values = createMemo(() => { const values = createMemo(() => {
@@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active return store.active
}, },
all() { all() {
return store.themes return allThemes()
},
has(name: string) {
return hasTheme(name)
}, },
syntax, syntax,
subtleSyntax, subtleSyntax,
@@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
pin(mode) pin(mode)
}, },
set(theme: string) { set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme) setStore("active", theme)
kv.set("theme", theme) kv.set("theme", theme)
return true
}, },
get ready() { get ready() {
return store.ready return store.ready

View File

@@ -1,4 +1,4 @@
import { createMemo, createSignal, For } from "solid-js" import { For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeCount = Object.keys(DEFAULT_THEMES).length const themeCount = Object.keys(DEFAULT_THEMES).length

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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()
},
}
}

View File

@@ -0,0 +1,3 @@
export { TuiPluginRuntime } from "./runtime"
export { createTuiApi } from "./api"
export type { RouteMap } from "./api"

View File

@@ -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,
]

View File

@@ -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 })
})
}
}

View File

@@ -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)
},
}
}

View File

@@ -1,9 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js" import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { Tips } from "../component/tips"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
@@ -12,20 +10,17 @@ import { useDirectory } from "../context/directory"
import { useRouteData } from "@tui/context/route" import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt" import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this? // TODO: what is the best way to do this?
let once = false let once = false
export function Home() { export function Home() {
const sync = useSync() const sync = useSync()
const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const route = useRouteData("home") const route = useRouteData("home")
const promptRef = usePromptRef() const promptRef = usePromptRef()
const command = useCommandDialog()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0) const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => { const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed") 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 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 = ( 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}> <text fg={theme.text}>
<Switch> <Switch>
<Match when={mcpError()}> <Match when={mcpError()}>
@@ -71,8 +45,8 @@ export function Home() {
</Match> </Match>
</Switch> </Switch>
</text> </text>
</box> </Show>
</Show> </box>
) )
let prompt: PromptRef let prompt: PromptRef
@@ -103,15 +77,15 @@ export function Home() {
) )
const directory = useDirectory() const directory = useDirectory()
const keybind = useKeybind()
return ( return (
<> <>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}> <box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} /> <box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}> <box flexShrink={0}>
<Logo /> <TuiPluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</TuiPluginRuntime.Slot>
</box> </box>
<box height={1} minHeight={0} flexShrink={1} /> <box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}> <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
@@ -124,11 +98,7 @@ export function Home() {
workspaceID={route.workspaceID} workspaceID={route.workspaceID}
/> />
</box> </box>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}> <TuiPluginRuntime.Slot name="home_bottom" />
<Show when={showTips()}>
<Tips />
</Show>
</box>
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
<Toast /> <Toast />
</box> </box>

View File

@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx" import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor" import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"

View File

@@ -1,72 +1,13 @@
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js" import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme" 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 { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind" import { TuiPluginRuntime } from "../../plugin"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) { export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!) 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))
return ( return (
<Show when={session()}> <Show when={session()}>
@@ -90,230 +31,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}} }}
> >
<box flexShrink={0} gap={1} paddingRight={1}> <box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}> <TuiPluginRuntime.Slot
<text fg={theme.text}> name="sidebar_title"
<b>{session().title}</b> mode="single_winner"
</text> session_id={props.sessionID}
<Show when={session().share?.url}> title={session()!.title}
<text fg={theme.textMuted}>{session().share!.url}</text> share_url={session()!.share?.url}
</Show> >
</box> <box paddingRight={1}>
<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>
<text fg={theme.text}> <text fg={theme.text}>
<b>LSP</b> <b>{session()!.title}</b>
</text> </text>
</box> <Show when={session()!.share?.url}>
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}> <text fg={theme.textMuted}>{session()!.share!.url}</text>
<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> </Show>
</box> </box>
</Show> </TuiPluginRuntime.Slot>
<Show when={diff().length > 0}> <TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
<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>
</box> </box>
</scrollbox> </scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}> <box flexShrink={0} gap={1} paddingTop={1}>
<Show when={!hasProviders() && !gettingStartedDismissed()}> <TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
<box <text fg={theme.textMuted}>
backgroundColor={theme.backgroundElement} <span style={{ fg: theme.success }}></span> <b>Open</b>
paddingTop={1} <span style={{ fg: theme.text }}>
paddingBottom={1} <b>Code</b>
paddingLeft={2} </span>{" "}
paddingRight={2} <span>{Installation.VERSION}</span>
flexDirection="row" </text>
gap={1} </TuiPluginRuntime.Slot>
>
<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>
</box> </box>
</box> </box>
</Show> </Show>

View File

@@ -6,6 +6,7 @@ import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
const reload = () => { const reload = () => {
client.call("reload", undefined).catch((err) => { client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", { 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) process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", { Log.Default.warn("worker shutdown failed", {
error: error instanceof Error ? error.message : String(error), error: errorMessage(error),
}) })
}) })
worker.terminate() worker.terminate()

View File

@@ -9,7 +9,7 @@ import { Selection } from "@tui/util/selection"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
size?: "medium" | "large" size?: "medium" | "large" | "xlarge"
onClose: () => void onClose: () => void
}>, }>,
) { ) {
@@ -18,6 +18,11 @@ export function Dialog(
const renderer = useRenderer() const renderer = useRenderer()
let dismiss = false let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return ( return (
<box <box
@@ -35,6 +40,7 @@ export function Dialog(
height={dimensions().height} height={dimensions().height}
alignItems="center" alignItems="center"
position="absolute" position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4} paddingTop={dimensions().height / 4}
left={0} left={0}
top={0} top={0}
@@ -45,7 +51,7 @@ export function Dialog(
dismiss = false dismiss = false
e.stopPropagation() e.stopPropagation()
}} }}
width={props.size === "large" ? 80 : 60} width={width()}
maxWidth={dimensions().width - 2} maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
paddingTop={1} paddingTop={1}
@@ -62,7 +68,7 @@ function init() {
element: JSX.Element element: JSX.Element
onClose?: () => void onClose?: () => void
}[], }[],
size: "medium" as "medium" | "large", size: "medium" as "medium" | "large" | "xlarge",
}) })
const renderer = useRenderer() const renderer = useRenderer()
@@ -72,6 +78,9 @@ function init() {
if (evt.defaultPrevented) return 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")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)! const current = store.stack.at(-1)!
current.onClose?.() current.onClose?.()
setStore("stack", store.stack.slice(0, -1)) setStore("stack", store.stack.slice(0, -1))
@@ -132,7 +141,7 @@ function init() {
get size() { get size() {
return store.size return store.size
}, },
setSize(size: "medium" | "large") { setSize(size: "medium" | "large" | "xlarge") {
setStore("size", size) setStore("size", size)
}, },
} }
@@ -151,6 +160,7 @@ export function DialogProvider(props: ParentProps) {
{props.children} {props.children}
<box <box
position="absolute" position="absolute"
zIndex={3000}
onMouseDown={(evt) => { onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return if (evt.button !== MouseButton.RIGHT) return

View File

@@ -1,4 +1,5 @@
import { ConfigMarkdown } from "@/config/markdown" import { ConfigMarkdown } from "@/config/markdown"
import { errorFormat } from "@/util/error"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
@@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
} }
export function FormatUnknownError(input: unknown): string { export function FormatUnknownError(input: unknown): string {
if (input instanceof Error) { return errorFormat(input)
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)
} }

View File

@@ -30,20 +30,27 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry" import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied" import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Lock } from "@/util/lock"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect" import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) 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" }) const log = Log.create({ service: "config" })
@@ -78,34 +85,65 @@ export namespace Config {
return merged 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 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(() => ({ const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {}, dependencies: {},
})) }))
json.dependencies = { json.dependencies = {
...json.dependencies, ...json.dependencies,
"@opencode-ai/plugin": targetVersion, "@opencode-ai/plugin": target,
} }
await Filesystem.writeJson(pkg, json) await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore) const ignore = await Filesystem.exists(gitignore)
if (!hasGitIgnore) if (!ignore) {
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) 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( await BunProc.run(
[ [
"install", "install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []), ...(proxied() || process.env.CI ? ["--no-cache"] : []),
], ],
{ cwd: dir }, {
cwd: dir,
abort: input?.signal,
},
).catch((err) => { ).catch((err) => {
if (err instanceof Process.RunFailedError) { if (err instanceof Process.RunFailedError) {
const detail = { const detail = {
@@ -149,8 +187,8 @@ export namespace Config {
return false return false
} }
const nodeModules = path.join(dir, "node_modules") const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
if (!existsSync(nodeModules)) return true if (!existsSync(mod)) return true
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg) const pkgExists = await Filesystem.exists(pkg)
@@ -163,8 +201,9 @@ export namespace Config {
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") { if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) if (!online()) return false
if (!isOutdated) 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", { log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin", pkg: "@opencode-ai/plugin",
cachedVersion: depVersion, cachedVersion: depVersion,
@@ -303,7 +342,7 @@ export namespace Config {
} }
async function loadPlugin(dir: string) { async function loadPlugin(dir: string) {
const plugins: string[] = [] const plugins: PluginSpec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir, cwd: dir,
@@ -316,25 +355,44 @@ export namespace Config {
return plugins return plugins
} }
/** export function pluginSpecifier(plugin: PluginSpec): string {
* Extracts a canonical plugin name from a plugin specifier. return Array.isArray(plugin) ? plugin[0] : plugin
* - For file:// URLs: extracts filename without extension }
* - For npm packages: extracts package name without version
* export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
* @example return Array.isArray(plugin) ? plugin[1] : undefined
* getPluginName("file:///path/to/plugin/foo.js") // "foo" }
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
*/ const spec = pluginSpecifier(plugin)
export function getPluginName(plugin: string): string { if (!isPathPluginSpec(spec)) return plugin
if (plugin.startsWith("file://")) { if (spec.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} }
const lastAt = plugin.lastIndexOf("@") if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
if (lastAt > 0) { const base = pathToFileURL(spec).href
return plugin.substring(0, lastAt) const resolved = await resolvePathPluginTarget(base).catch(() => base)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
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, * Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order. * we reverse, deduplicate (keeping first occurrence), then restore order.
*/ */
export function deduplicatePlugins(plugins: string[]): string[] { export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>() const seenNames = new Set<string>()
const uniqueSpecifiers: PluginSpec[] = []
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
for (const specifier of plugins.toReversed()) { 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)) { if (!seenNames.has(name)) {
seenNames.add(name) seenNames.add(name)
uniqueSpecifiers.push(specifier) uniqueSpecifiers.push(specifier)
@@ -757,6 +811,7 @@ export namespace Config {
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), 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"), 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"), display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
}) })
.strict() .strict()
@@ -858,13 +913,13 @@ export namespace Config {
ignore: z.array(z.string()).optional(), ignore: z.array(z.string()).optional(),
}) })
.optional(), .optional(),
plugin: z.string().array().optional(),
snapshot: z snapshot: z
.boolean() .boolean()
.optional() .optional()
.describe( .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.", "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 share: z
.enum(["manual", "auto", "disabled"]) .enum(["manual", "auto", "disabled"])
.optional() .optional()
@@ -1070,10 +1125,6 @@ export namespace Config {
return candidates[0] 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 { function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) { if (!isRecord(patch)) {
const edits = modify(input, path, patch, { const edits = modify(input, path, patch, {
@@ -1189,19 +1240,9 @@ export namespace Config {
} }
const data = parsed.data const data = parsed.data
if (data.plugin && isFile) { if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) { const list = data.plugin
const plugin = data.plugin[i] for (let i = 0; i < list.length; i++) {
try { list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
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"
}
}
} }
} }
return data return data
@@ -1326,12 +1367,14 @@ export namespace Config {
} }
} }
deps.push( const dep = iife(async () => {
iife(async () => { const stale = await needsInstall(dir)
const shouldInstall = await needsInstall(dir) if (stale) await installDependencies(dir)
if (shouldInstall) 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.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))

View File

@@ -29,6 +29,8 @@ export const TuiInfo = z
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional(), keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)
.strict() .strict()

View File

@@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo 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 { 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() { function customPath() {
return Flag.OPENCODE_TUI_CONFIG 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 () => { const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? [] ? []
@@ -38,38 +116,55 @@ export namespace TuiConfig {
? [] ? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) : 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")) { for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file)) await mergeFile(acc, file)
} }
if (custom) { if (custom) {
result = mergeInfo(result, await loadFile(custom)) await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom }) log.debug("loaded custom tui config", { path: custom })
} }
for (const file of projectFiles) { for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file)) await mergeFile(acc, file)
} }
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file)) await mergeFile(acc, file)
} }
} }
if (existsSync(managed)) { if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { 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 { return {
config: result, config: acc.result,
deps,
} }
}) })
@@ -77,6 +172,11 @@ export namespace TuiConfig {
return state().then((x) => x.config) 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> { async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath) const text = await ConfigPaths.readFile(filepath)
if (!text) return {} if (!text) return {}
@@ -87,25 +187,12 @@ export namespace TuiConfig {
} }
async function load(text: string, configFilepath: string): Promise<Info> { async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty") const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {} if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied. // (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => { const normalized = normalize(raw)
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 parsed = Info.safeParse(normalized) const parsed = Info.safeParse(normalized)
if (!parsed.success) { if (!parsed.success) {
@@ -113,6 +200,13 @@ export namespace TuiConfig {
return {} 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
} }
} }

View File

@@ -14,13 +14,16 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] 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_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: 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_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE") export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") 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_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS") export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false, 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 // Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time, // This must be evaluated at access time, not module load time,
// because some commands override the client at runtime // because some commands override the client at runtime

View File

@@ -33,16 +33,18 @@ import path from "path"
import { Global } from "./global" import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration" import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db" import { Database } from "./storage/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", { Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e, e: errorMessage(e),
}) })
}) })
process.on("uncaughtException", (e) => { process.on("uncaughtException", (e) => {
Log.Default.error("exception", { 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", type: "string",
choices: ["DEBUG", "INFO", "WARN", "ERROR"], choices: ["DEBUG", "INFO", "WARN", "ERROR"],
}) })
.option("pure", {
describe: "run without external plugins",
type: "boolean",
})
.middleware(async (opts) => { .middleware(async (opts) => {
if (opts.pure) {
process.env.OPENCODE_PURE = "1"
}
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(), dev: Installation.isLocal(),
@@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand) .command(GithubCommand)
.command(PrCommand) .command(PrCommand)
.command(SessionCommand) .command(SessionCommand)
.command(PluginCommand)
.command(DbCommand) .command(DbCommand)
.fail((msg, err) => { .fail((msg, err) => {
if ( if (
@@ -194,7 +205,7 @@ try {
if (formatted) UI.error(formatted) if (formatted) UI.error(formatted)
if (formatted === undefined) { if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) 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 process.exitCode = 1
} finally { } finally {

View File

@@ -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 { Config } from "../config/config"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { BunProc } from "../bun"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
import { Session } from "../session" import { Session } from "../session"
@@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" 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 { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -22,6 +32,12 @@ export namespace Plugin {
hooks: Hooks[] hooks: Hooks[]
} }
type Loaded = {
item: Config.PluginSpec
spec: string
mod: Record<string, unknown>
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern // Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = { type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never [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) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] 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 function isServerPlugin(value: unknown): value is PluginInstance {
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] 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( export const layer = Layer.effect(
Service, Service,
@@ -91,51 +214,27 @@ export namespace Plugin {
if (init) hooks.push(init) 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() if (plugins.length) await Config.waitForDependencies()
for (let plugin of plugins) { const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue for (const load of loaded) {
log.info("loading plugin", { path: plugin }) if (!load) continue
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
}
// Prevent duplicate initialization when plugins export the same function // Keep plugin execution sequential so hook registration and execution
// as both a named export and default export (e.g., `export const X` and `export default X`). // order remains deterministic across plugin runs.
// Object.entries(mod) would return both entries pointing to the same function reference. await applyPlugin(load, input, hooks).catch((err) => {
await import(plugin) const message = errorMessage(err)
.then(async (mod) => { log.error("failed to load plugin", { path: load.spec, error: message })
const seen = new Set<PluginInstance>() Bus.publish(Session.Event.Error, {
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) { error: new NamedError.Unknown({
if (seen.has(fn)) continue message: `Failed to load plugin ${load.spec}: ${message}`,
seen.add(fn) }).toObject(),
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(),
})
}) })
})
} }
// Notify plugins of current config // Notify plugins of current config

View File

@@ -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,
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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 { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
interface State { interface State {
hooks: Record<ProviderID, Hook> hooks: Record<ProviderID, Hook>
pending: Map<ProviderID, AuthOuathResult> pending: Map<ProviderID, AuthOAuthResult>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
@@ -127,7 +127,7 @@ export namespace ProviderAuth {
: Result.failVoid, : Result.failVoid,
), ),
), ),
pending: new Map<ProviderID, AuthOuathResult>(), pending: new Map<ProviderID, AuthOAuthResult>(),
} }
}), }),
), ),

View File

@@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
import { MessageTable, PartTable, SessionTable } from "./session.sql" import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider/error" import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import type { SystemError } from "bun" import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
@@ -990,7 +991,7 @@ export namespace MessageV2 {
{ cause: e }, { cause: e },
).toObject() ).toObject()
case e instanceof Error: 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: default:
try { try {
const parsed = ProviderError.parseStreamError(e) const parsed = ProviderError.parseStreamError(e)

View File

@@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ProviderID, ModelID } from "../provider/schema" import { ProviderID, ModelID } from "../provider/schema"
import { errorMessage } from "../util/error"
import DESCRIPTION from "./batch.txt" import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"]) const DISALLOWED = new Set(["batch"])
@@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
state: { state: {
status: "error", status: "error",
input: call.parameters, input: call.parameters,
error: error instanceof Error ? error.message : String(error), error: errorMessage(error),
time: { time: {
start: callStartTime, start: callStartTime,
end: Date.now(), end: Date.now(),

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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() { export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
} }

View File

@@ -1,6 +1,7 @@
import { type ChildProcess } from "child_process" import { type ChildProcess } from "child_process"
import launch from "cross-spawn" import launch from "cross-spawn"
import { buffer } from "node:stream/consumers" import { buffer } from "node:stream/consumers"
import { errorMessage } from "./error"
export namespace Process { export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore" export type Stdio = "inherit" | "pipe" | "ignore"
@@ -136,7 +137,7 @@ export namespace Process {
return { return {
code: 1, code: 1,
stdout: Buffer.alloc(0), 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 if (out.code === 0 || opts.nothrow) return out

View File

@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}

View File

@@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Slug } from "@opencode-ai/util/slug" import { Slug } from "@opencode-ai/util/slug"
import { errorMessage } from "../util/error"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
@@ -260,7 +261,7 @@ export namespace Worktree {
}) })
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error) const message = errorMessage(error)
log.error("worktree bootstrap failed", { directory: info.directory, message }) log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
@@ -344,9 +345,12 @@ export namespace Worktree {
function cleanDirectory(target: string) { function cleanDirectory(target: string) {
return Effect.promise(() => return Effect.promise(() =>
import("fs/promises").then((fsp) => import("fs/promises")
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), .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" })
}),
) )
} }

View File

@@ -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"])
})
})

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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 },
)

View File

@@ -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: "acme-plugin@1.0.0" }
},
})
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: "acme-plugin@1.0.0" }
},
})
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
}
})

View File

@@ -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
}
}
})

View File

@@ -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)
})
})

View File

@@ -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
}
})

View File

@@ -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")
})

View File

@@ -20,6 +20,7 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import * as Network from "../../src/util/network"
import { BunProc } from "../../src/bun" import { BunProc } from "../../src/bun"
const emptyAccount = Layer.mock(Account.Service)({ 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 const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra 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 { try {
await Instance.provide({ 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, "package.json"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
} finally { } finally {
online.mockRestore()
run.mockRestore()
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev 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() await using tmp = await tmpdir()
const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")] const dir = path.join(tmp.path, "a")
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true }))) await fs.mkdir(dir, { recursive: true })
const seen: string[] = [] const ticks: number[] = []
let active = 0 let calls = 0
let max = 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) => { const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
active++ calls += 1
max = Math.max(max, active) start()
seen.push(opts?.cwd ?? "") await gate
await new Promise((resolve) => setTimeout(resolve, 25)) const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
active-- 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 { return {
code: 0, code: 0,
stdout: Buffer.alloc(0), stdout: Buffer.alloc(0),
@@ -805,15 +838,85 @@ test("serializes concurrent config dependency installs", async () => {
}) })
try { 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 { } finally {
online.mockRestore()
run.mockRestore() run.mockRestore()
} }
expect(max).toBe(1) expect(calls).toBe(1)
expect(seen.toSorted()).toEqual(dirs.toSorted()) expect(ticks.length).toBeGreaterThan(0)
expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true) expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
expect(await Filesystem.exists(path.join(dirs[1], "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 () => { test("resolves scoped npm plugins in config", async () => {
@@ -855,15 +958,7 @@ test("resolves scoped npm plugins in config", async () => {
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const pluginEntries = config.plugin ?? [] const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/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)
}, },
}) })
}) })
@@ -1710,27 +1805,43 @@ test("wellknown URL with trailing slash is normalized", async () => {
} }
}) })
describe("getPluginName", () => { describe("resolvePluginSpec", () => {
test("extracts name from file:// URL", () => { test("keeps package specs unchanged", async () => {
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo") await using tmp = await tmpdir()
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar") const file = path.join(tmp.path, "opencode.json")
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
}) })
test("extracts name from npm package with version", () => { test("resolves relative file plugin paths to file urls", async () => {
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode") await using tmp = await tmpdir({
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin") init: async (dir) => {
expect(Config.getPluginName("plugin@latest")).toBe("plugin") await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
},
})
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("extracts name from scoped npm package", () => { test("resolves plugin directory paths to package main files", async () => {
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg") await using tmp = await tmpdir({
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin") 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 {}")
},
})
test("returns full string for package without version", () => { const file = path.join(tmp.path, "opencode.json")
expect(Config.getPluginName("some-plugin")).toBe("some-plugin") const hit = await Config.resolvePluginSpec("./plugin", file)
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg") 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) 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 = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
const result = Config.deduplicatePlugins(plugins) const result = Config.deduplicatePlugins(plugins)
expect(result.length).toBe(1) expect(result).toEqual(plugins)
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js") })
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", () => { test("preserves order of remaining plugins", () => {
@@ -1764,7 +1882,7 @@ describe("deduplicatePlugins", () => {
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
}) })
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({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
const projectDir = path.join(dir, "project") const projectDir = path.join(dir, "project")
@@ -1790,9 +1908,8 @@ describe("deduplicatePlugins", () => {
const config = await Config.get() const config = await Config.get()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin") expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(myPlugins.length).toBe(1) expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
expect(myPlugins[0].startsWith("file://")).toBe(true)
}, },
}) })
}) })

View File

@@ -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 () => { test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { 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: ["shared-plugin@1.0.0"] }, null, 2),
)
await fs.mkdir(managedConfigDir, { recursive: true }) 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: ["shared-plugin@2.0.0"] }, null, 2),
)
}, },
}) })
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
fn: async () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme") expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
expect(config.plugin_meta).toEqual({
"shared-plugin@2.0.0": {
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: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_meta).toEqual({
"acme-plugin@1.2.3": {
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: [["acme-plugin@1.0.0", { source: "global" }]],
}),
)
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
plugin: [
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_meta).toEqual({
"acme-plugin@2.0.0": {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
"second-plugin@3.0.0": {
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: ["global-plugin@1.0.0"],
}),
)
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
plugin: ["local-plugin@2.0.0"],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_meta).toEqual({
"global-plugin@1.0.0": {
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
"local-plugin@2.0.0": {
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,
})
},
})
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)

View File

@@ -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
},
},
}
}

View File

@@ -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
}
}

View File

@@ -16,15 +16,18 @@ describe("plugin.auth-override", () => {
await Bun.write( await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"), path.join(pluginDir, "custom-copilot-auth.ts"),
[ [
"export default async () => ({", "export default {",
" auth: {", ' id: "demo.custom-copilot-auth",',
' provider: "github-copilot",', " server: async () => ({",
" methods: [", " auth: {",
' { type: "api", label: "Test Override Auth" },', ' provider: "github-copilot",',
" ],", " methods: [",
" loader: async () => ({ access: 'test-token' }),", ' { type: "api", label: "Test Override Auth" },',
" },", " ],",
"})", " loader: async () => ({ access: 'test-token' }),",
" },",
" }),",
"}",
"", "",
].join("\n"), ].join("\n"),
) )

View File

@@ -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: ["seed@1.0.0"] }, 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, ["seed@1.0.0", ...next])
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
}, 25_000)
})

View File

@@ -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(
{
mod: "acme@1.2.3",
},
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(["acme@1.2.3"])
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
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(
{
mod: "acme@1.2.3",
},
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([["acme@1.2.3", { custom: true, other: false }]])
expect(tui.plugin).toEqual([["acme@1.2.3", { 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(
{
mod: "acme@1.2.3",
},
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(["acme@1.2.3"])
})
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: ["acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
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(["acme@1.0.0"])
})
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/acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "@scope/acme@2.0.0",
},
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/acme@1.0.0"])
})
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(
{
mod: "acme@1.2.3",
},
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", "acme@1.2.3"])
})
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: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"],
},
null,
2,
),
)
const run = createPlugTask(
{
mod: "acme@2.0.0",
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([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
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(
{
mod: "acme@1.2.3",
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(
{
mod: "acme@1.2.3",
},
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(
{
mod: "acme@1.2.3",
},
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(
{
mod: "acme@1.2.3",
},
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(
{
mod: "acme@1.2.3",
},
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: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
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(["acme@2.0.0", "other@1.0.0"])
expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
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": ["acme@1.0.0",}'
await Bun.write(cfg, bad)
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
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(
{
mod: "acme@1.2.3",
},
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(
{
mod: "acme@1.2.3",
},
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(
{
mod: "acme@9.9.9",
},
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)
})
})

View File

@@ -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", "scope-plugin@2.3.4"] }, 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: ["acme-plugin@1.0.0"] }, 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(
{
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
},
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: ["broken-plugin@9.9.9"] }, 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 broken-plugin@9.9.9") && 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
}
}
})
})

View File

@@ -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)
})

View File

@@ -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")
})
})

View File

@@ -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)
}
})
})

View File

@@ -10,7 +10,8 @@
}, },
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./tool": "./src/tool.ts" "./tool": "./src/tool.ts",
"./tui": "./src/tui.ts"
}, },
"files": [ "files": [
"dist" "dist"
@@ -19,7 +20,21 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:" "zod": "catalog:"
}, },
"peerDependencies": {
"@opentui/core": ">=0.1.90",
"@opentui/solid": ">=0.1.90"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
},
"devDependencies": { "devDependencies": {
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",

View File

@@ -9,7 +9,7 @@ import type {
Message, Message,
Part, Part,
Auth, Auth,
Config, Config as SDKConfig,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { BunShell } from "./shell.js" import type { BunShell } from "./shell.js"
@@ -32,7 +32,18 @@ export type PluginInput = {
$: BunShell $: 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 = { type Rule = {
key: string key: string
@@ -72,7 +83,7 @@ export type AuthHook = {
when?: Rule when?: Rule
} }
> >
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult> authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
} }
| { | {
type: "api" 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" method: "auto"
callback(): Promise< callback(): Promise<
@@ -161,6 +172,9 @@ export type AuthOuathResult = { url: string; instructions: string } & (
} }
) )
/** @deprecated Use AuthOAuthResult instead. */
export type AuthOuathResult = AuthOAuthResult
export interface Hooks { export interface Hooks {
event?: (input: { event: Event }) => Promise<void> event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void> config?: (input: Config) => Promise<void>

419
packages/plugin/src/tui.ts Normal file
View File

@@ -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
}

View File

@@ -1447,7 +1447,15 @@ export type Config = {
watcher?: { watcher?: {
ignore?: Array<string> 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. * 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.
*/ */