Merge dev into refactor/npm-over-bunproc
commit
4d079b34f4
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
smoke-theme.json
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
20
bun.lock
20
bun.lock
|
|
@ -430,11 +430,21 @@
|
|||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.90",
|
||||
"@opentui/solid": ">=0.1.90",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
|
|
@ -518,6 +528,7 @@
|
|||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
|
|
@ -633,6 +644,7 @@
|
|||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.0",
|
||||
"shiki": "3.20.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-list": "0.3.0",
|
||||
|
|
@ -3931,7 +3943,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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -4213,6 +4225,8 @@
|
|||
|
||||
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
||||
|
||||
"remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="],
|
||||
|
||||
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
|
@ -5801,12 +5815,12 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
|
||||
"aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
|
||||
"aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
|
||||
"x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
|
||||
"x86_64-linux": "sha256-a2eTu0ISjqPuojkNPnPXzVb/PLlDvw/DXDvmxi9RD5k=",
|
||||
"aarch64-linux": "sha256-yLaTXRzZ7M/6j2WDP+IL1YCY3+rYY4Qmq3xTDatNzD0=",
|
||||
"aarch64-darwin": "sha256-uGSVe8S/QvnW+RCI/CxzrlfAAJ1YA+NrhzRE0GTcnvE=",
|
||||
"x86_64-darwin": "sha256-tplWx2tLg6jWvOBmM41lODJV8pHpkAm4HKWRG7lpkcU="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remend": "1.3.0",
|
||||
"@playwright/test": "1.51.0",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const plugins = createMemo(() =>
|
||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
|
|
|||
|
|
@ -57,12 +57,15 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
|
|
@ -512,15 +515,20 @@ export default function Page() {
|
|||
deferRender: false,
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
paused: {} as Record<string, boolean | undefined>,
|
||||
edit: {} as Record<
|
||||
string,
|
||||
{ id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
|
||||
>,
|
||||
})
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
items: Record<string, FollowupItem[] | undefined>
|
||||
failed: Record<string, string | undefined>
|
||||
paused: Record<string, boolean | undefined>
|
||||
edit: Record<string, FollowupEdit | undefined>
|
||||
}>({
|
||||
items: {},
|
||||
failed: {},
|
||||
paused: {},
|
||||
edit: {},
|
||||
}),
|
||||
)
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ research
|
|||
dist
|
||||
gen
|
||||
app.log
|
||||
src/provider/models-snapshot.ts
|
||||
src/provider/models-snapshot.js
|
||||
src/provider/models-snapshot.d.ts
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
preload = ["@opentui/solid/preload", "./test/preload.ts"]
|
||||
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
|
||||
# using --timeout in package.json scripts instead
|
||||
# https://github.com/oven-sh/bun/issues/7789
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { $ } from "bun"
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
|
@ -63,22 +63,30 @@ console.log(`Loaded ${migrations.length} migrations`)
|
|||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
|
||||
const createEmbeddedWebUIBundle = async () => {
|
||||
console.log(`Building Web UI to embed in the binary`)
|
||||
const appDir = path.join(import.meta.dirname, "../../app")
|
||||
const dist = path.join(appDir, "dist")
|
||||
await $`bun run --cwd ${appDir} build`
|
||||
const allFiles = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: path.join(appDir, "dist") }))
|
||||
const fileMap = `
|
||||
// Import all files as file_$i with type: "file"
|
||||
${allFiles.map((filePath, i) => `import file_${i} from "${path.join(appDir, "dist", filePath)}" with { type: "file" };`).join("\n")}
|
||||
// Export with original mappings
|
||||
export default {
|
||||
${allFiles.map((filePath, i) => `"${filePath}": file_${i},`).join("\n")}
|
||||
}
|
||||
`.trim()
|
||||
return fileMap
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
|
||||
})
|
||||
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
|
||||
return [
|
||||
`// Import all files as file_$i with type: "file"`,
|
||||
...imports,
|
||||
`// Export with original mappings`,
|
||||
`export default {`,
|
||||
...entries,
|
||||
`}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
|
||||
|
|
@ -200,7 +208,7 @@ for (const item of targets) {
|
|||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin],
|
||||
plugins: [plugin],
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
|
|||
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
|
|
@ -181,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
- [x] `AppFileSystem` — `filesystem/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `Bus` — `bus/index.ts`
|
||||
- [x] `Command` — `command/index.ts`
|
||||
- [x] `Config` — `config/config.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `LSP` — `lsp/index.ts`
|
||||
- [x] `MCP` — `mcp/index.ts`
|
||||
- [x] `McpAuth` — `mcp/auth.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `Plugin` — `plugin/index.ts`
|
||||
- [x] `Project` — `project/project.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Pty` — `pty/index.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `SessionStatus` — `session/status.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `ToolRegistry` — `tool/registry.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
- [x] `SessionStatus`
|
||||
- [x] `Worktree` — `worktree/index.ts`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [x] `Worktree`
|
||||
- [x] `Bus`
|
||||
- [x] `Command`
|
||||
- [x] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [x] `LSP`
|
||||
- [x] `MCP`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -72,13 +72,14 @@ export namespace Agent {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
|
|
@ -281,7 +282,7 @@ export namespace Agent {
|
|||
})
|
||||
|
||||
const list = Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config()
|
||||
const cfg = yield* config.get()
|
||||
return pipe(
|
||||
agents,
|
||||
values(),
|
||||
|
|
@ -293,7 +294,7 @@ export namespace Agent {
|
|||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
const c = yield* config()
|
||||
const c = yield* config.get()
|
||||
if (c.default_agent) {
|
||||
const agent = agents[c.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
|
||||
|
|
@ -328,7 +329,7 @@ export namespace Agent {
|
|||
description: string
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config()
|
||||
const cfg = yield* config.get()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
|
|
@ -391,7 +392,11 @@ export namespace Agent {
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { online, proxied } from "@/util/network"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(cmd: string[], options?: Process.RunOptions) {
|
||||
const full = [which(), ...cmd]
|
||||
log.info("running", {
|
||||
cmd: full,
|
||||
...options,
|
||||
})
|
||||
const result = await Process.run(full, {
|
||||
cwd: options?.cwd,
|
||||
abort: options?.abort,
|
||||
kill: options?.kill,
|
||||
timeout: options?.timeout,
|
||||
nothrow: options?.nothrow,
|
||||
env: {
|
||||
...process.env,
|
||||
...options?.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
log.info("done", {
|
||||
code: result.code,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version === "latest") {
|
||||
if (!online()) return mod
|
||||
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!stale) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
} else if (cachedVersion === version) {
|
||||
return mod
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
]
|
||||
|
||||
// Let Bun handle registry resolution:
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { online } from "@/util/network"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
if (!online()) {
|
||||
log.debug("offline, skipping bun info", { pkg, field })
|
||||
return null
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
|
||||
return null
|
||||
}
|
||||
|
||||
const value = stdout.toString().trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
}
|
||||
|
||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||
const latestVersion = await info(pkg, "version", cwd)
|
||||
if (!latestVersion) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { UI } from "../ui"
|
|||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { EOL } from "os"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
const QueryCommand = cmd({
|
||||
command: "$0 [query]",
|
||||
|
|
@ -39,7 +40,7 @@ const QueryCommand = cmd({
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
UI.error(err instanceof Error ? err.message : String(err))
|
||||
UI.error(errorMessage(err))
|
||||
process.exit(1)
|
||||
}
|
||||
db.close()
|
||||
|
|
@ -100,7 +101,7 @@ const MigrateCommand = cmd({
|
|||
}
|
||||
} catch (err) {
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
UI.error(`Migration failed: ${errorMessage(err)}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
sqlite.close()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
@ -1,15 +1,30 @@
|
|||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
createMemo,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||
|
|
@ -21,7 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
|
|||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
|
|
@ -40,8 +55,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
|
|
@ -104,7 +121,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const formatted = FormatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"data" in error &&
|
||||
typeof error.data === "object" &&
|
||||
error.data !== null &&
|
||||
"message" in error.data &&
|
||||
typeof error.data.message === "string"
|
||||
) {
|
||||
return error.data.message
|
||||
}
|
||||
return FormatUnknownError(error)
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
|
|
@ -132,77 +184,68 @@ export function tui(input: {
|
|||
resolve()
|
||||
}
|
||||
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const onBeforeExit = async () => {
|
||||
await TuiPluginRuntime.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -211,12 +254,47 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes: RouteMap = new Map()
|
||||
const [routeRev, setRouteRev] = createSignal(0)
|
||||
const routeView = (name: string) => {
|
||||
routeRev()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
}
|
||||
|
||||
const api = createTuiApi({
|
||||
command,
|
||||
tuiConfig,
|
||||
dialog,
|
||||
keybind,
|
||||
kv,
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
sdk,
|
||||
sync,
|
||||
theme: themeState,
|
||||
toast,
|
||||
renderer,
|
||||
})
|
||||
onCleanup(() => {
|
||||
api.dispose()
|
||||
})
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPluginRuntime.init(api)
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
|
|
@ -259,10 +337,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
|
|
@ -279,9 +353,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
return
|
||||
}
|
||||
|
||||
// Truncate title to 40 chars max
|
||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||
renderer.setTerminalTitle(`OC | ${title}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.data.type === "plugin") {
|
||||
renderer.setTerminalTitle(`OC | ${route.data.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -723,17 +801,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
sdk.event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = (() => {
|
||||
if (!error) return "An error occurred"
|
||||
|
||||
if (typeof error === "object") {
|
||||
const data = error.data
|
||||
if ("message" in data && typeof data.message === "string") {
|
||||
return data.message
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
})()
|
||||
const message = errorMessage(error)
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
|
|
@ -789,6 +857,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (!ready()) return
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = routeView(route.data.id)
|
||||
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||
return render({ params: route.data.data })
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
|
|
@ -804,97 +880,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onExit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Show when={ready()}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
{plugin()}
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import {
|
|||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
|
@ -21,7 +23,7 @@ export type Slash = {
|
|||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: KeybindKey
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
|
|
@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
|
|||
}
|
||||
|
||||
function init() {
|
||||
const root = getOwner()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
|
|
@ -100,11 +103,32 @@ function init() {
|
|||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
const owner = getOwner() ?? root
|
||||
if (!owner) return () => {}
|
||||
|
||||
let list: Accessor<CommandOption[]> | undefined
|
||||
|
||||
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||
runWithOwner(owner, () => {
|
||||
list = createMemo(cb)
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => [ref, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
})
|
||||
})
|
||||
|
||||
if (!list) return () => {}
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export function DialogStatus() {
|
|||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((value) => {
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
|
|
|
|||
|
|
@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
|||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
|
|
@ -29,12 +37,7 @@ async function openWorkspace(input: {
|
|||
)
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
})
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
|
|
@ -187,12 +190,7 @@ export function DialogWorkspaceList() {
|
|||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
const client = scoped(sdk, sync, workspaceID)
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
|
|
@ -223,12 +221,7 @@ export function DialogWorkspaceList() {
|
|||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspace.id,
|
||||
})
|
||||
const client = scoped(sdk, sync, workspace.id)
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
|||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
|
|
@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
|
|
|
|||
|
|
@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
|||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,13 @@ export type SessionRoute = {
|
|||
initialPrompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute
|
||||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
|
|
@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
|||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
get client() {
|
||||
return sdk
|
||||
},
|
||||
get workspaceID() {
|
||||
return workspaceID
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
|
|
|
|||
|
|
@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
secondary: RGBA
|
||||
accent: RGBA
|
||||
error: RGBA
|
||||
warning: RGBA
|
||||
success: RGBA
|
||||
info: RGBA
|
||||
text: RGBA
|
||||
textMuted: RGBA
|
||||
selectedListItemText: RGBA
|
||||
background: RGBA
|
||||
backgroundPanel: RGBA
|
||||
backgroundElement: RGBA
|
||||
backgroundMenu: RGBA
|
||||
border: RGBA
|
||||
borderActive: RGBA
|
||||
borderSubtle: RGBA
|
||||
diffAdded: RGBA
|
||||
diffRemoved: RGBA
|
||||
diffContext: RGBA
|
||||
diffHunkHeader: RGBA
|
||||
diffHighlightAdded: RGBA
|
||||
diffHighlightRemoved: RGBA
|
||||
diffAddedBg: RGBA
|
||||
diffRemovedBg: RGBA
|
||||
diffContextBg: RGBA
|
||||
diffLineNumber: RGBA
|
||||
diffAddedLineNumberBg: RGBA
|
||||
diffRemovedLineNumberBg: RGBA
|
||||
markdownText: RGBA
|
||||
markdownHeading: RGBA
|
||||
markdownLink: RGBA
|
||||
markdownLinkText: RGBA
|
||||
markdownCode: RGBA
|
||||
markdownBlockQuote: RGBA
|
||||
markdownEmph: RGBA
|
||||
markdownStrong: RGBA
|
||||
markdownHorizontalRule: RGBA
|
||||
markdownListItem: RGBA
|
||||
markdownListEnumeration: RGBA
|
||||
markdownImage: RGBA
|
||||
markdownImageText: RGBA
|
||||
markdownCodeBlock: RGBA
|
||||
syntaxComment: RGBA
|
||||
syntaxKeyword: RGBA
|
||||
syntaxFunction: RGBA
|
||||
syntaxVariable: RGBA
|
||||
syntaxString: RGBA
|
||||
syntaxNumber: RGBA
|
||||
syntaxType: RGBA
|
||||
syntaxOperator: RGBA
|
||||
syntaxPunctuation: RGBA
|
||||
}
|
||||
|
||||
type Theme = ThemeColors & {
|
||||
type Theme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
thinkingOpacity: number
|
||||
}
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
|
|
@ -128,10 +75,10 @@ type Variant = {
|
|||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA
|
||||
type ThemeJson = {
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
|
|
@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
|||
carbonfox,
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
const pluginThemes: Record<string, ThemeJson> = {}
|
||||
let customThemes: Record<string, ThemeJson> = {}
|
||||
let systemTheme: ThemeJson | undefined
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
if (!systemTheme) return themes
|
||||
return {
|
||||
...themes,
|
||||
system: systemTheme,
|
||||
}
|
||||
}
|
||||
|
||||
function syncThemes() {
|
||||
setStore("themes", listThemes())
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: listThemes(),
|
||||
mode: "dark",
|
||||
lock: undefined,
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
|
||||
export function allThemes() {
|
||||
return store.themes
|
||||
}
|
||||
|
||||
function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (!isRecord(theme)) return false
|
||||
if (!isRecord(theme.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasTheme(name: string) {
|
||||
if (!name) return false
|
||||
return allThemes()[name] !== undefined
|
||||
}
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
if (c instanceof RGBA) return c
|
||||
if (typeof c === "string") {
|
||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||
|
||||
if (defs[c] != null) {
|
||||
return resolveColor(defs[c])
|
||||
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
|
||||
return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
||||
} else {
|
||||
if (chain.includes(c)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[c] ?? theme.theme[c as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, c])
|
||||
}
|
||||
if (typeof c === "number") {
|
||||
return ansiToRgba(c)
|
||||
}
|
||||
return resolveColor(c[mode])
|
||||
return resolveColor(c[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
|
|
@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
|||
.map(([key, value]) => {
|
||||
return [key, resolveColor(value as ColorValue)]
|
||||
}),
|
||||
) as Partial<ThemeColors>
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
// Handle selectedListItemText separately since it's optional
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
|
|
@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
||||
lock,
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const mode = pick(kv.get("theme_mode", props.mode))
|
||||
draft.mode = lock ?? mode ?? props.mode
|
||||
draft.lock = lock
|
||||
const active = config.theme ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
|
|
@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme(store.mode)
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
Promise.allSettled([
|
||||
resolveSystemTheme(store.mode),
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
customThemes = custom
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
}),
|
||||
]).finally(() => {
|
||||
setStore("ready", true)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
renderer
|
||||
return renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
.then((colors: TerminalColors) => {
|
||||
if (!colors.palette[0]) {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
systemTheme = generateSystem(colors, mode)
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
|
||||
const refresh = () => {
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
}
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
process.off("SIGUSR2", refresh)
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
|
|
@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
return store.active
|
||||
},
|
||||
all() {
|
||||
return store.themes
|
||||
return allThemes()
|
||||
},
|
||||
has(name: string) {
|
||||
return hasTheme(name)
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
|
|
@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
pin(mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo, createSignal, For } from "solid-js"
|
||||
import { For } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { TuiPluginRuntime } from "./runtime"
|
||||
export { createTuiApi } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
|
|
@ -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,
|
||||
]
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Tips } from "../component/tips"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
|
|
@ -12,20 +10,17 @@ import { useDirectory } from "../context/directory"
|
|||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
|
|
@ -35,30 +30,9 @@ export function Home() {
|
|||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||
})
|
||||
|
||||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const showTips = createMemo(() => {
|
||||
// Don't show tips for first-time users
|
||||
if (isFirstTimeUser()) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
|
|
@ -71,8 +45,8 @@ export function Home() {
|
|||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
|
|
@ -103,15 +77,15 @@ export function Home() {
|
|||
)
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
<TuiPluginRuntime.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
|
|
@ -124,11 +98,7 @@ export function Home() {
|
|||
workspaceID={route.workspaceID}
|
||||
/>
|
||||
</box>
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={showTips()}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="home_bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
|
|||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
|
|
|||
|
|
@ -1,72 +1,13 @@
|
|||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Global } from "@/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
|
||||
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
|
||||
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
||||
|
||||
const [expanded, setExpanded] = createStore({
|
||||
mcp: true,
|
||||
diff: true,
|
||||
todo: true,
|
||||
lsp: true,
|
||||
})
|
||||
|
||||
// Sort MCP servers alphabetically for consistent display order
|
||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
// Count connected and error MCP servers for collapsed header display
|
||||
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
|
||||
const errorMcpCount = createMemo(
|
||||
() =>
|
||||
mcpEntries().filter(
|
||||
([_, item]) =>
|
||||
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
).length,
|
||||
)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
|
||||
const directory = useDirectory()
|
||||
const kv = useKV()
|
||||
|
||||
const hasProviders = createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
|
||||
return (
|
||||
<Show when={session()}>
|
||||
|
|
@ -90,230 +31,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
}}
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
|
||||
>
|
||||
<Show when={mcpEntries().length > 2}>
|
||||
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>MCP</b>
|
||||
<Show when={!expanded.mcp}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{" "}
|
||||
({connectedMcpCount()} active
|
||||
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
|
||||
<For each={mcpEntries()}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: (
|
||||
{
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
needs_auth: theme.warning,
|
||||
needs_client_registration: theme.error,
|
||||
} as Record<string, typeof theme.success>
|
||||
)[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch fallback={item.status}>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled</Match>
|
||||
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
|
||||
<Match when={(item.status as string) === "needs_client_registration"}>
|
||||
Needs client ID
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
|
||||
>
|
||||
<Show when={sync.data.lsp.length > 2}>
|
||||
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_title"
|
||||
mode="single_winner"
|
||||
session_id={props.sessionID}
|
||||
title={session()!.title}
|
||||
share_url={session()!.share?.url}
|
||||
>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>LSP</b>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
|
||||
<Show when={sync.data.lsp.length === 0}>
|
||||
<text fg={theme.textMuted}>
|
||||
{sync.data.config.lsp === false
|
||||
? "LSPs have been disabled in settings"
|
||||
: "LSPs will activate as files are read"}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
|
||||
>
|
||||
<Show when={todo().length > 2}>
|
||||
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={todo().length <= 2 || expanded.todo}>
|
||||
<For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
||||
<Show when={session()!.share?.url}>
|
||||
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={diff().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
|
||||
>
|
||||
<Show when={diff().length > 2}>
|
||||
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={diff().length <= 2 || expanded.diff}>
|
||||
<For each={diff() || []}>
|
||||
{(item) => {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{item.file}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Show when={!hasProviders() && !gettingStartedDismissed()}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text flexShrink={0} fg={theme.text}>
|
||||
⬖
|
||||
</text>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.text}>Connect provider</text>
|
||||
<text fg={theme.textMuted}>/connect</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
|
||||
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{Installation.VERSION}</span>
|
||||
</text>
|
||||
<TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{Installation.VERSION}</span>
|
||||
</text>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import path from "path"
|
|||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { Log } from "@/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
|
@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
|
|||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
Log.Default.warn("worker reload failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({
|
|||
process.off("SIGUSR2", reload)
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
worker.terminate()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Selection } from "@tui/util/selection"
|
|||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: "medium" | "large"
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
onClose: () => void
|
||||
}>,
|
||||
) {
|
||||
|
|
@ -18,6 +18,11 @@ export function Dialog(
|
|||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
const width = () => {
|
||||
if (props.size === "xlarge") return 116
|
||||
if (props.size === "large") return 88
|
||||
return 60
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
|
|
@ -35,6 +40,7 @@ export function Dialog(
|
|||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
|
|
@ -45,7 +51,7 @@ export function Dialog(
|
|||
dismiss = false
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
width={width()}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingTop={1}
|
||||
|
|
@ -62,7 +68,7 @@ function init() {
|
|||
element: JSX.Element
|
||||
onClose?: () => void
|
||||
}[],
|
||||
size: "medium" as "medium" | "large",
|
||||
size: "medium" as "medium" | "large" | "xlarge",
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -72,6 +78,9 @@ function init() {
|
|||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
|
|
@ -132,7 +141,7 @@ function init() {
|
|||
get size() {
|
||||
return store.size
|
||||
},
|
||||
setSize(size: "medium" | "large") {
|
||||
setSize(size: "medium" | "large" | "xlarge") {
|
||||
setStore("size", size)
|
||||
},
|
||||
}
|
||||
|
|
@ -151,6 +160,7 @@ export function DialogProvider(props: ParentProps) {
|
|||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { errorFormat } from "@/util/error"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Provider } from "../provider/provider"
|
||||
|
|
@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
|
|||
}
|
||||
|
||||
export function FormatUnknownError(input: unknown): string {
|
||||
if (input instanceof Error) {
|
||||
return input.stack ?? `${input.name}: ${input.message}`
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null) {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2)
|
||||
} catch {
|
||||
return "Unexpected error (unserializable)"
|
||||
}
|
||||
}
|
||||
|
||||
return String(input)
|
||||
return errorFormat(input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,8 +75,12 @@ export namespace Command {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
commands[Default.INIT] = {
|
||||
|
|
@ -114,7 +118,7 @@ export namespace Command {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
|
||||
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
||||
commands[name] = {
|
||||
name,
|
||||
source: "mcp",
|
||||
|
|
@ -139,14 +143,14 @@ export namespace Command {
|
|||
}
|
||||
}
|
||||
|
||||
for (const skill of yield* Effect.promise(() => Skill.all())) {
|
||||
if (commands[skill.name]) continue
|
||||
commands[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
for (const item of yield* skill.all()) {
|
||||
if (commands[item.name]) continue
|
||||
commands[item.name] = {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
return skill.content
|
||||
return item.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
|
|
@ -173,7 +177,13 @@ export namespace Command {
|
|||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((svc) => svc.get(name))
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "jsonc-parser"
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
|
|
@ -28,20 +29,28 @@ import { Bus } from "@/bus"
|
|||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { online, proxied } from "@/util/network"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Npm } from "@/npm"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Duration, Effect, Layer, 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 {
|
||||
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" })
|
||||
|
||||
|
|
@ -76,12 +85,88 @@ export namespace Config {
|
|||
return merged
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
if (!(await isWritable(dir))) {
|
||||
log.info("config dir is not writable, skipping dependency install", { dir })
|
||||
return
|
||||
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 target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
||||
dependencies: {},
|
||||
}))
|
||||
json.dependencies = {
|
||||
...json.dependencies,
|
||||
"@opencode-ai/plugin": target,
|
||||
}
|
||||
await Npm.install(dir)
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const ignore = await Filesystem.exists(gitignore)
|
||||
if (!ignore) {
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
}
|
||||
|
||||
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
||||
// Serialize installs globally on win32, but keep parallel installs on other platforms.
|
||||
await using __ =
|
||||
process.platform === "win32"
|
||||
? await Flock.acquire("config-install:bun", {
|
||||
signal: input?.signal,
|
||||
})
|
||||
: undefined
|
||||
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{
|
||||
cwd: dir,
|
||||
abort: input?.signal,
|
||||
},
|
||||
).catch((err) => {
|
||||
if (err instanceof Process.RunFailedError) {
|
||||
const detail = {
|
||||
dir,
|
||||
cmd: err.cmd,
|
||||
code: err.code,
|
||||
stdout: err.stdout.toString(),
|
||||
stderr: err.stderr.toString(),
|
||||
}
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", detail)
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", detail)
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", { dir, error: err })
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", { dir, error: err })
|
||||
})
|
||||
}
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
|
|
@ -93,6 +178,42 @@ export namespace Config {
|
|||
}
|
||||
}
|
||||
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
if (!writable) {
|
||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
||||
return false
|
||||
}
|
||||
|
||||
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
|
||||
if (!existsSync(mod)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
if (!pkgExists) return true
|
||||
|
||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
||||
const dependencies = parsed?.dependencies ?? {}
|
||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
if (!online()) return false
|
||||
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!stale) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (depVersion === targetVersion) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
|
|
@ -221,7 +342,7 @@ export namespace Config {
|
|||
}
|
||||
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
const plugins: PluginSpec[] = []
|
||||
|
||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: dir,
|
||||
|
|
@ -234,25 +355,44 @@ export namespace Config {
|
|||
return plugins
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a canonical plugin name from a plugin specifier.
|
||||
* - For file:// URLs: extracts filename without extension
|
||||
* - For npm packages: extracts package name without version
|
||||
*
|
||||
* @example
|
||||
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
|
||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||
*/
|
||||
export function getPluginName(plugin: string): string {
|
||||
if (plugin.startsWith("file://")) {
|
||||
return path.parse(new URL(plugin).pathname).name
|
||||
export function pluginSpecifier(plugin: PluginSpec): string {
|
||||
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||
}
|
||||
|
||||
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
||||
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||
}
|
||||
|
||||
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
if (!isPathPluginSpec(spec)) return plugin
|
||||
if (spec.startsWith("file://")) {
|
||||
const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
}
|
||||
const lastAt = plugin.lastIndexOf("@")
|
||||
if (lastAt > 0) {
|
||||
return plugin.substring(0, lastAt)
|
||||
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
|
||||
const base = pathToFileURL(spec).href
|
||||
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -266,17 +406,13 @@ export namespace Config {
|
|||
* Since plugins are added in low-to-high priority order,
|
||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||
*/
|
||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||
// seenNames: canonical plugin names for duplicate detection
|
||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
// uniqueSpecifiers: full plugin specifiers to return
|
||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||
const uniqueSpecifiers: string[] = []
|
||||
const uniqueSpecifiers: PluginSpec[] = []
|
||||
|
||||
for (const specifier of plugins.toReversed()) {
|
||||
const name = getPluginName(specifier)
|
||||
const spec = pluginSpecifier(specifier)
|
||||
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||
if (!seenNames.has(name)) {
|
||||
seenNames.add(name)
|
||||
uniqueSpecifiers.push(specifier)
|
||||
|
|
@ -675,6 +811,7 @@ export namespace Config {
|
|||
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
|
||||
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
||||
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
||||
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
|
||||
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
|
||||
})
|
||||
.strict()
|
||||
|
|
@ -776,13 +913,13 @@ export namespace Config {
|
|||
ignore: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
),
|
||||
plugin: PluginSpec.array().optional(),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
.optional()
|
||||
|
|
@ -988,10 +1125,6 @@ export namespace Config {
|
|||
return candidates[0]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
const edits = modify(input, path, patch, {
|
||||
|
|
@ -1054,369 +1187,379 @@ export namespace Config {
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = yield* Effect.promise(() =>
|
||||
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
|
||||
)
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = yield* Effect.promise(() =>
|
||||
ConfigPaths.parseText(
|
||||
text,
|
||||
"path" in options ? options.path : { source: options.source, dir: options.dir },
|
||||
),
|
||||
)
|
||||
|
||||
const normalized = (() => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
delete copy.keybinds
|
||||
delete copy.tui
|
||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||
return copy
|
||||
})()
|
||||
const normalized = (() => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
delete copy.keybinds
|
||||
delete copy.tui
|
||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||
return copy
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
const list = data.plugin
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
yield* Effect.promise(() =>
|
||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||
loadGlobal().pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||
),
|
||||
Effect.orElseSucceed((): Info => ({})),
|
||||
),
|
||||
Duration.infinity,
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
yield* Effect.promise(() =>
|
||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||
return yield* cachedGlobal
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||
loadGlobal().pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||
),
|
||||
Effect.orElseSucceed((): Info => ({})),
|
||||
),
|
||||
Duration.infinity,
|
||||
)
|
||||
|
||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||
return yield* cachedGlobal
|
||||
})
|
||||
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* Effect.promise(() => Auth.all())
|
||||
|
||||
let result: Info = {}
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||
source: `${url}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
deps.push(installDependencies(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(() => loadMode(dir)))
|
||||
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: ctx.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = yield* Effect.promise(() => Account.active())
|
||||
if (active?.active_org_id) {
|
||||
yield* Effect.gen(function* () {
|
||||
const [config, token] = yield* Effect.promise(() =>
|
||||
Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
|
||||
)
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||
}
|
||||
|
||||
if (config) {
|
||||
let result: Info = {}
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||
source: `${url}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchDefect((err) => {
|
||||
log.debug("failed to fetch remote account config", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
const deps: Promise<void>[] = []
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
for (const dir of unique(directories)) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
const dep = iife(async () => {
|
||||
const stale = await needsInstall(dir)
|
||||
if (stale) await installDependencies(dir)
|
||||
})
|
||||
void dep.catch((err) => {
|
||||
log.warn("background dependency install failed", { dir, error: err })
|
||||
})
|
||||
deps.push(dep)
|
||||
|
||||
const get = Effect.fn("Config.get")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.config)
|
||||
})
|
||||
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(() => loadMode(dir)))
|
||||
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
||||
}
|
||||
|
||||
const directories = Effect.fn("Config.directories")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.directories)
|
||||
})
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: ctx.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
||||
})
|
||||
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
|
||||
if (active?.active_org_id) {
|
||||
yield* Effect.gen(function* () {
|
||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const token = Option.getOrUndefined(tokenOpt)
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||
}
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const file = path.join(Instance.directory, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
const config = Option.getOrUndefined(configOpt)
|
||||
if (config) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.debug("failed to fetch remote account config", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, file)
|
||||
const merged = mergeDeep(existing, config)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, config)
|
||||
next = parseConfig(updated, file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
getGlobal,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
directories,
|
||||
waitForDependencies,
|
||||
})
|
||||
}),
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Config.get")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.config)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Config.directories")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.directories)
|
||||
})
|
||||
|
||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const file = path.join(Instance.directory, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, file)
|
||||
const merged = mergeDeep(existing, config)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, config)
|
||||
next = parseConfig(updated, file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
getGlobal,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
directories,
|
||||
waitForDependencies,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const TuiInfo = z
|
|||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
plugin: Config.PluginSpec.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
|
|
|
|||
|
|
@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
|
|||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { parsePluginSpecifier } from "@/plugin/shared"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
export type PluginMeta = {
|
||||
scope: "global" | "local"
|
||||
source: string
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
item: Config.PluginSpec
|
||||
meta: PluginMeta
|
||||
}
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
entries: PluginEntry[]
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
plugin_meta?: Record<string, PluginMeta>
|
||||
}
|
||||
|
||||
function pluginScope(file: string): PluginMeta["scope"] {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function dedupePlugins(list: PluginEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: PluginEntry[] = []
|
||||
for (const item of list.toReversed()) {
|
||||
const spec = Config.pluginSpecifier(item.item)
|
||||
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(item)
|
||||
}
|
||||
return result.toReversed()
|
||||
}
|
||||
|
||||
function mergeInfo(target: Info, source: Info): Info {
|
||||
return mergeDeep(target, source)
|
||||
const merged = mergeDeep(target, source)
|
||||
if (target.plugin && source.plugin) {
|
||||
merged.plugin = [...target.plugin, ...source.plugin]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return Config.installDependencies(dir)
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeInfo(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file)
|
||||
for (const item of data.plugin) {
|
||||
acc.entries.push({
|
||||
item,
|
||||
meta: {
|
||||
scope,
|
||||
source: file,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
|
|
@ -38,38 +116,55 @@ export namespace TuiConfig {
|
|||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
let result: Info = {}
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
entries: [],
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
result = mergeInfo(result, await loadFile(custom))
|
||||
await mergeFile(acc, custom)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
const merged = dedupePlugins(acc.entries)
|
||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||
acc.result.plugin = merged.map((item) => item.item)
|
||||
acc.result.plugin_meta = merged.length
|
||||
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
|
||||
: undefined
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
deps.push(installDeps(dir))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: result,
|
||||
config: acc.result,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -77,6 +172,11 @@ export namespace TuiConfig {
|
|||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
|
|
@ -87,25 +187,12 @@ export namespace TuiConfig {
|
|||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
|
||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = (() => {
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
if (!("tui" in copy)) return copy
|
||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
||||
delete copy.tui
|
||||
return copy
|
||||
}
|
||||
const tui = copy.tui as Record<string, unknown>
|
||||
delete copy.tui
|
||||
return {
|
||||
...tui,
|
||||
...copy,
|
||||
}
|
||||
})()
|
||||
const normalized = normalize(raw)
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (!parsed.success) {
|
||||
|
|
@ -113,6 +200,13 @@ export namespace TuiConfig {
|
|||
return {}
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type * as Arr from "effect/Array"
|
||||
import { NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
|
|
@ -474,3 +475,5 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
|||
ChildProcessSpawner,
|
||||
make,
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export namespace FileWatcher {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
function* () {
|
||||
|
|
@ -117,7 +119,7 @@ export namespace FileWatcher {
|
|||
)
|
||||
}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
|
|
@ -159,7 +161,9 @@ export namespace FileWatcher {
|
|||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
|
|
|||
|
|
@ -14,13 +14,16 @@ export namespace Flag {
|
|||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_PURE: boolean
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
|
|
@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
|||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PURE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because the CLI can set this flag at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PURE", {
|
||||
get() {
|
||||
return truthy("OPENCODE_PURE")
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because tests and external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
|
||||
get() {
|
||||
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CLIENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because some commands override the client at runtime
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@ export namespace Format {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
const enabled: Record<string, string[] | false> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
|
|
@ -177,7 +179,9 @@ export namespace Format {
|
|||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
|
|
|
|||
|
|
@ -33,16 +33,18 @@ import path from "path"
|
|||
import { Global } from "./global"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
import { Database } from "./storage/db"
|
||||
import { errorMessage } from "./util/error"
|
||||
import { PluginCommand } from "./cli/cmd/plug"
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
e: errorMessage(e),
|
||||
})
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error("exception", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
e: errorMessage(e),
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv))
|
|||
type: "string",
|
||||
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
||||
})
|
||||
.option("pure", {
|
||||
describe: "run without external plugins",
|
||||
type: "boolean",
|
||||
})
|
||||
.middleware(async (opts) => {
|
||||
if (opts.pure) {
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
}
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
dev: Installation.isLocal(),
|
||||
|
|
@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
|
|||
.command(GithubCommand)
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(PluginCommand)
|
||||
.command(DbCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
|
|
@ -194,7 +205,7 @@ try {
|
|||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined) {
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||
process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
|
||||
process.stderr.write(errorMessage(e) + EOL)
|
||||
}
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
|
@ -341,9 +340,7 @@ export namespace Installation {
|
|||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
|
|
|||
|
|
@ -161,9 +161,11 @@ export namespace LSP {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* () {
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
|
|
@ -504,7 +506,9 @@ export namespace LSP {
|
|||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const init = async () => runPromise((svc) => svc.init())
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import { InstanceState } from "@/effect/instance-state"
|
|||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
|
||||
export namespace MCP {
|
||||
const log = Log.create({ service: "mcp" })
|
||||
|
|
@ -437,6 +435,7 @@ export namespace MCP {
|
|||
log.info("create() successfully created client", { key, toolCount: listed.length })
|
||||
return { mcpClient, status, defs: listed } satisfies CreateResult
|
||||
})
|
||||
const cfgSvc = yield* Config.Service
|
||||
|
||||
const descendants = Effect.fnUntraced(
|
||||
function* (pid: number) {
|
||||
|
|
@ -478,11 +477,9 @@ export namespace MCP {
|
|||
})
|
||||
}
|
||||
|
||||
const getConfig = () => Effect.promise(() => Config.get())
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* getConfig()
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
status: {},
|
||||
|
|
@ -553,7 +550,8 @@ export namespace MCP {
|
|||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const cfg = yield* getConfig()
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const result: Record<string, Status> = {}
|
||||
|
||||
|
|
@ -613,7 +611,8 @@ export namespace MCP {
|
|||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const cfg = yield* getConfig()
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const defaultTimeout = cfg.experimental?.mcp_timeout
|
||||
|
||||
|
|
@ -705,7 +704,7 @@ export namespace MCP {
|
|||
})
|
||||
|
||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||
const cfg = yield* getConfig()
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||
return mcpConfig
|
||||
|
|
@ -876,13 +875,12 @@ export namespace MCP {
|
|||
|
||||
// --- Per-service runtime ---
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(McpAuth.layer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
|
||||
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
|
||||
import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Npm } from "../npm"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
|
|
@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
|
|||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
getDefaultPlugin,
|
||||
isDeprecatedPlugin,
|
||||
parsePluginSpecifier,
|
||||
pluginSource,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginTarget,
|
||||
} from "./shared"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
|
@ -22,6 +32,12 @@ export namespace Plugin {
|
|||
hooks: Hooks[]
|
||||
}
|
||||
|
||||
type Loaded = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||
type TriggerName = {
|
||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||
|
|
@ -46,8 +62,115 @@ export namespace Plugin {
|
|||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
|
||||
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getServerPlugin(value: unknown) {
|
||||
if (isServerPlugin(value)) return value
|
||||
if (!value || typeof value !== "object" || !("server" in value)) return
|
||||
if (!isServerPlugin(value.server)) return
|
||||
return value.server
|
||||
}
|
||||
|
||||
function getLegacyPlugins(mod: Record<string, unknown>) {
|
||||
const seen = new Set<unknown>()
|
||||
const result: PluginInstance[] = []
|
||||
|
||||
for (const entry of Object.values(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
const plugin = getServerPlugin(entry)
|
||||
if (!plugin) throw new TypeError("Plugin export is not a function")
|
||||
result.push(plugin)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = errorMessage(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!target) return
|
||||
return target
|
||||
}
|
||||
|
||||
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const resolved = await resolvePlugin(spec)
|
||||
if (!resolved) return
|
||||
|
||||
if (pluginSource(spec) === "npm") {
|
||||
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.warn("plugin incompatible", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Plugin ${spec} skipped: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return true
|
||||
})
|
||||
if (incompatible) return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: spec, target: entry, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
|
||||
if (plugin?.server) {
|
||||
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
|
||||
return
|
||||
}
|
||||
|
||||
for (const server of getLegacyPlugins(load.mod)) {
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
|
|
@ -80,8 +203,7 @@ export namespace Plugin {
|
|||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
|
|
@ -92,48 +214,27 @@ export namespace Plugin {
|
|||
if (init) hooks.push(init)
|
||||
}
|
||||
|
||||
let plugins = cfg.plugin ?? []
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
|
||||
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
|
||||
}
|
||||
if (plugins.length) await Config.waitForDependencies()
|
||||
|
||||
for (let plugin of plugins) {
|
||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
plugin = await Npm.add(plugin).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", { plugin, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!plugin) continue
|
||||
}
|
||||
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
// Keep plugin execution sequential so hook registration and execution
|
||||
// order remains deterministic across plugin runs.
|
||||
await applyPlugin(load, input, hooks).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Notify plugins of current config
|
||||
|
|
@ -192,7 +293,7 @@ export namespace Plugin {
|
|||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function trigger<
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ export namespace Project {
|
|||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ export namespace Project {
|
|||
const scope = yield* Scope.Scope
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
|
|
@ -169,7 +169,7 @@ export namespace Project {
|
|||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
|
|
@ -222,7 +222,7 @@ export namespace Project {
|
|||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +270,7 @@ export namespace Project {
|
|||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
fsys.exists(s).pipe(
|
||||
fs.exists(s).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
|
|
@ -329,7 +329,7 @@ export namespace Project {
|
|||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
|
||||
const matches = yield* fsys
|
||||
const matches = yield* fs
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
|
|
@ -339,7 +339,7 @@ export namespace Project {
|
|||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
|
||||
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
|
||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
|
|
@ -400,7 +400,7 @@ export namespace Project {
|
|||
return yield* Effect.forEach(
|
||||
data.sandboxes,
|
||||
(dir) =>
|
||||
fsys.isDir(dir).pipe(
|
||||
fs.isDir(dir).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((ok) => (ok ? dir : undefined)),
|
||||
),
|
||||
|
|
@ -457,9 +457,8 @@ export namespace Project {
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
|
|
@ -41,10 +41,25 @@ export namespace Vcs {
|
|||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Bus.Service | ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts: { cwd: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text }
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), text: "" })),
|
||||
)
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -52,17 +67,15 @@ export namespace Vcs {
|
|||
return { current: undefined }
|
||||
}
|
||||
|
||||
const get = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
const getBranch = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree })
|
||||
if (result.code !== 0) return undefined
|
||||
const text = result.text.trim()
|
||||
return text || undefined
|
||||
}
|
||||
})
|
||||
|
||||
const value = {
|
||||
current: yield* Effect.promise(() => get()),
|
||||
current: yield* getBranch(),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
|
|
@ -70,7 +83,7 @@ export namespace Vcs {
|
|||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach(() =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* Effect.promise(() => get())
|
||||
const next = yield* getBranch()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
|
|
@ -97,7 +110,7 @@ export namespace Vcs {
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
|
@ -106,7 +106,7 @@ export namespace ProviderAuth {
|
|||
|
||||
interface State {
|
||||
hooks: Record<ProviderID, Hook>
|
||||
pending: Map<ProviderID, AuthOuathResult>
|
||||
pending: Map<ProviderID, AuthOAuthResult>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
|
@ -127,7 +127,7 @@ export namespace ProviderAuth {
|
|||
: Result.failVoid,
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export namespace Pty {
|
|||
if (input.size) {
|
||||
session.process.resize(input.size.cols, input.size.rows)
|
||||
}
|
||||
yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
|
||||
void Bus.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
|
|||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
|
@ -990,7 +991,7 @@ export namespace MessageV2 {
|
|||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof Error:
|
||||
return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
|
||||
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
|
||||
default:
|
||||
try {
|
||||
const parsed = ProviderError.parseStreamError(e)
|
||||
|
|
|
|||
|
|
@ -63,16 +63,23 @@ export namespace Skill {
|
|||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
||||
const md = yield* Effect.tryPromise({
|
||||
try: () => ConfigMarkdown.parse(match),
|
||||
catch: (err) => err,
|
||||
}).pipe(
|
||||
Effect.catch(
|
||||
Effect.fnUntraced(function* (err) {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = yield* Effect.promise(() => import("@/session"))
|
||||
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!md) return
|
||||
|
||||
|
|
@ -94,80 +101,115 @@ export namespace Skill {
|
|||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
const scan = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
bus: Bus.Interface,
|
||||
root: string,
|
||||
pattern: string,
|
||||
opts?: { dot?: boolean; scope?: string },
|
||||
) {
|
||||
const matches = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
}),
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!opts?.scope) return Effect.die(error)
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
return Effect.succeed([] as string[])
|
||||
}),
|
||||
)
|
||||
|
||||
async function loadSkills(state: State, discovery: Discovery.Interface, directory: string, worktree: string) {
|
||||
yield* Effect.forEach(matches, (match) => add(state, match, bus), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
})
|
||||
|
||||
const loadSkills = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
config: Config.Interface,
|
||||
discovery: Discovery.Interface,
|
||||
bus: Bus.Interface,
|
||||
directory: string,
|
||||
worktree: string,
|
||||
) {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
const isDir = yield* Effect.promise(() => Filesystem.isDir(root))
|
||||
if (!isDir) continue
|
||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
const upDirs = yield* Effect.promise(async () => {
|
||||
const dirs: string[] = []
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
dirs.push(root)
|
||||
}
|
||||
return dirs
|
||||
})
|
||||
|
||||
for (const root of upDirs) {
|
||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
const configDirs = yield* config.directories()
|
||||
for (const dir of configDirs) {
|
||||
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
const cfg = yield* config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
const isDir = yield* Effect.promise(() => Filesystem.isDir(dir))
|
||||
if (!isDir) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
const pulledDirs = yield* discovery.pull(url)
|
||||
for (const dir of pulledDirs) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
})
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Discovery.Service | Config.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
yield* Effect.promise(() => loadSkills(s, discovery, ctx.directory, ctx.worktree))
|
||||
return s
|
||||
}),
|
||||
),
|
||||
Effect.fn("Skill.state")(function* (ctx) {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
yield* loadSkills(s, config, discovery, bus, ctx.directory, ctx.worktree)
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
|
|
@ -196,7 +238,11 @@ export namespace Skill {
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
|
|
|||
|
|
@ -60,403 +60,397 @@ export namespace Snapshot {
|
|||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const config = yield* Config.Service
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
],
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* config.get()).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
)
|
||||
})
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...quote,
|
||||
...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { ProviderID, ModelID } from "../provider/schema"
|
||||
import { errorMessage } from "../util/error"
|
||||
import DESCRIPTION from "./batch.txt"
|
||||
|
||||
const DISALLOWED = new Set(["batch"])
|
||||
|
|
@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
|||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage(error),
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export namespace ToolRegistry {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
|
|
@ -82,35 +85,34 @@ export namespace ToolRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const matches = await Config.directories().then((dirs) =>
|
||||
dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
),
|
||||
const dirs = yield* config.directories()
|
||||
const matches = dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
)
|
||||
if (matches.length) yield* config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = yield* Effect.promise(
|
||||
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
||||
)
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = await Plugin.list()
|
||||
for (const plugin of plugins) {
|
||||
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
}
|
||||
const plugins = yield* plugin.list()
|
||||
for (const p of plugins) {
|
||||
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { custom }
|
||||
}),
|
||||
)
|
||||
|
||||
async function all(custom: Tool.Info[]): Promise<Tool.Info[]> {
|
||||
const cfg = await Config.get()
|
||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||
const cfg = yield* config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
|
|
@ -134,7 +136,7 @@ export namespace ToolRegistry {
|
|||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
|
|
@ -148,7 +150,7 @@ export namespace ToolRegistry {
|
|||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const tools = yield* Effect.promise(() => all(state.custom))
|
||||
const tools = yield* all(state.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
|
|
@ -157,40 +159,37 @@ export namespace ToolRegistry {
|
|||
agent?: Agent.Info,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const allTools = yield* Effect.promise(() => all(state.custom))
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
allTools
|
||||
.filter((tool) => {
|
||||
// Enable websearch/codesearch for zen users OR via enable flag
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
const allTools = yield* all(state.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
// use apply tool in same format as codex
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
.map(async (tool) => {
|
||||
using _ = log.time(tool.id)
|
||||
const next = await tool.init({ agent })
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
await Plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
...next,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
}
|
||||
}),
|
||||
),
|
||||
return true
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
...next,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
} as Awaited<ReturnType<Tool.Info["init"]>> & { id: string }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -198,7 +197,11 @@ export namespace ToolRegistry {
|
|||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function register(tool: Tool.Info) {
|
||||
return runPromise((svc) => svc.register(tool))
|
||||
|
|
@ -214,7 +217,7 @@ export namespace ToolRegistry {
|
|||
modelID: ModelID
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
): Promise<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(model, agent))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
export function online() {
|
||||
const nav = globalThis.navigator
|
||||
if (!nav || typeof nav.onLine !== "boolean") return true
|
||||
return nav.onLine
|
||||
}
|
||||
|
||||
export function proxied() {
|
||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { type ChildProcess } from "child_process"
|
||||
import launch from "cross-spawn"
|
||||
import { buffer } from "node:stream/consumers"
|
||||
import { errorMessage } from "./error"
|
||||
|
||||
export namespace Process {
|
||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||
|
|
@ -136,7 +137,7 @@ export namespace Process {
|
|||
return {
|
||||
code: 1,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
stderr: Buffer.from(errorMessage(err)),
|
||||
}
|
||||
})
|
||||
if (out.code === 0 || opts.nothrow) return out
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@ import { ProjectTable } from "../project/project.sql"
|
|||
import type { ProjectID } from "../project/schema"
|
||||
import { Log } from "../util/log"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
|
|
@ -167,14 +169,15 @@ export namespace Worktree {
|
|||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const fsys = yield* FileSystem.FileSystem
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const project = yield* Project.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
|
|
@ -201,7 +204,7 @@ export namespace Worktree {
|
|||
const branch = `opencode/${name}`
|
||||
const directory = pathSvc.join(root, name)
|
||||
|
||||
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
|
||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||
|
|
@ -218,7 +221,7 @@ export namespace Worktree {
|
|||
}
|
||||
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
|
||||
const base = name ? slugify(name) : ""
|
||||
return yield* candidate(root, base || undefined)
|
||||
|
|
@ -232,7 +235,7 @@ export namespace Worktree {
|
|||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
|
||||
yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||
})
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
|
|
@ -258,7 +261,7 @@ export namespace Worktree {
|
|||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
|
|
@ -297,7 +300,7 @@ export namespace Worktree {
|
|||
|
||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||
const abs = pathSvc.resolve(input)
|
||||
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const normalized = pathSvc.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
})
|
||||
|
|
@ -334,7 +337,7 @@ export namespace Worktree {
|
|||
})
|
||||
|
||||
function stopFsmonitor(target: string) {
|
||||
return fsys.exists(target).pipe(
|
||||
return fs.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||
)
|
||||
|
|
@ -342,9 +345,12 @@ export namespace Worktree {
|
|||
|
||||
function cleanDirectory(target: string) {
|
||||
return Effect.promise(() =>
|
||||
import("fs/promises").then((fsp) =>
|
||||
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
),
|
||||
import("fs/promises")
|
||||
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||
.catch((error) => {
|
||||
const message = errorMessage(error)
|
||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -364,7 +370,7 @@ export namespace Worktree {
|
|||
const entry = yield* locateWorktree(entries, directory)
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
|
||||
const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
|
||||
if (directoryExists) {
|
||||
yield* stopFsmonitor(directory)
|
||||
yield* cleanDirectory(directory)
|
||||
|
|
@ -464,7 +470,7 @@ export namespace Worktree {
|
|||
const target = yield* canonical(pathSvc.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
||||
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
|
@ -603,8 +609,9 @@ export namespace Worktree {
|
|||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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 },
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
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")
|
||||
})
|
||||
|
|
@ -1,15 +1,35 @@
|
|||
import { test, expect, describe, mock, afterEach } from "bun:test"
|
||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
||||
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import * as Network from "../../src/util/network"
|
||||
import { BunProc } from "../../src/bun"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
|
@ -245,43 +265,44 @@ test("preserves env variables when adding $schema to config", async () => {
|
|||
})
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalConfig = Account.config
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
const fakeAccount = Layer.mock(Account.Service)({
|
||||
active: () =>
|
||||
Effect.succeed(
|
||||
Option.some({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}),
|
||||
),
|
||||
config: () =>
|
||||
Effect.succeed(
|
||||
Option.some({
|
||||
provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
|
||||
}),
|
||||
),
|
||||
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
||||
})
|
||||
|
||||
Account.config = mock(async () => ({
|
||||
provider: {
|
||||
opencode: {
|
||||
options: {
|
||||
apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Account.token = mock(async () => AccessToken.make("st_test_token"))
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
},
|
||||
})
|
||||
await provideTmpdirInstance(() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.config = originalConfig
|
||||
Account.token = originalToken
|
||||
if (originalControlToken !== undefined) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
|
||||
} else {
|
||||
|
|
@ -745,6 +766,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||
|
||||
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||
)
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
|
|
@ -758,11 +793,132 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||
} finally {
|
||||
online.mockRestore()
|
||||
run.mockRestore()
|
||||
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||
else process.env.OPENCODE_CONFIG_DIR = prev
|
||||
}
|
||||
})
|
||||
|
||||
test("dedupes concurrent config dependency installs for the same dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const ticks: number[] = []
|
||||
let calls = 0
|
||||
let start = () => {}
|
||||
let done = () => {}
|
||||
let blocked = () => {}
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
start = resolve
|
||||
})
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
blocked = resolve
|
||||
})
|
||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||
calls += 1
|
||||
start()
|
||||
await gate
|
||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||
)
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const first = Config.installDependencies(dir)
|
||||
await ready
|
||||
const second = Config.installDependencies(dir, {
|
||||
waitTick: (tick) => {
|
||||
ticks.push(tick.attempt)
|
||||
blocked()
|
||||
blocked = () => {}
|
||||
},
|
||||
})
|
||||
await waiting
|
||||
done()
|
||||
await Promise.all([first, second])
|
||||
} finally {
|
||||
online.mockRestore()
|
||||
run.mockRestore()
|
||||
}
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(ticks.length).toBeGreaterThan(0)
|
||||
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("serializes config dependency installs across dirs", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
await fs.mkdir(a, { recursive: true })
|
||||
await fs.mkdir(b, { recursive: true })
|
||||
|
||||
let calls = 0
|
||||
let open = 0
|
||||
let peak = 0
|
||||
let start = () => {}
|
||||
let done = () => {}
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
start = resolve
|
||||
})
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||
calls += 1
|
||||
open += 1
|
||||
peak = Math.max(peak, open)
|
||||
if (calls === 1) {
|
||||
start()
|
||||
await gate
|
||||
}
|
||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||
)
|
||||
open -= 1
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const first = Config.installDependencies(a)
|
||||
await ready
|
||||
const second = Config.installDependencies(b)
|
||||
done()
|
||||
await Promise.all([first, second])
|
||||
} finally {
|
||||
online.mockRestore()
|
||||
run.mockRestore()
|
||||
}
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(peak).toBe(1)
|
||||
})
|
||||
|
||||
test("resolves scoped npm plugins in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
|
@ -802,15 +958,7 @@ test("resolves scoped npm plugins in config", async () => {
|
|||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const pluginEntries = config.plugin ?? []
|
||||
|
||||
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
||||
const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href
|
||||
|
||||
expect(pluginEntries.includes(expected)).toBe(true)
|
||||
|
||||
const scopedEntry = pluginEntries.find((entry) => entry === expected)
|
||||
expect(scopedEntry).toBeDefined()
|
||||
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
|
||||
expect(pluginEntries).toContain("@scope/plugin")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -1554,7 +1702,7 @@ test("local .opencode config can override MCP from project config", async () =>
|
|||
test("project config overrides remote well-known config", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url.toString()
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
|
|
@ -1562,13 +1710,7 @@ test("project config overrides remote well-known config", async () => {
|
|||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: {
|
||||
jira: {
|
||||
type: "remote",
|
||||
url: "https://jira.example.com/mcp",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
|
|
@ -1576,60 +1718,46 @@ test("project config overrides remote well-known config", async () => {
|
|||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
})
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const originalAuthAll = Auth.all
|
||||
Auth.all = mock(() =>
|
||||
Promise.resolve({
|
||||
"https://example.com": {
|
||||
type: "wellknown" as const,
|
||||
key: "TEST_TOKEN",
|
||||
token: "test-token",
|
||||
},
|
||||
}),
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Project config enables jira (overriding remote default)
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
jira: {
|
||||
type: "remote",
|
||||
url: "https://jira.example.com/mcp",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
// Verify fetch was called for wellknown config
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
// Project config (enabled: true) should override remote (enabled: false)
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
},
|
||||
})
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
Auth.all = originalAuthAll
|
||||
}
|
||||
})
|
||||
|
||||
test("wellknown URL with trailing slash is normalized", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url.toString()
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
|
|
@ -1637,13 +1765,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: {
|
||||
slack: {
|
||||
type: "remote",
|
||||
url: "https://slack.example.com/mcp",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
|
|
@ -1651,67 +1773,75 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
})
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const originalAuthAll = Auth.all
|
||||
Auth.all = mock(() =>
|
||||
Promise.resolve({
|
||||
"https://example.com/": {
|
||||
type: "wellknown" as const,
|
||||
key: "TEST_TOKEN",
|
||||
token: "test-token",
|
||||
},
|
||||
}),
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
// Trailing slash should be stripped — no double slash in the fetch URL
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
},
|
||||
})
|
||||
),
|
||||
{ git: true },
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
Auth.all = originalAuthAll
|
||||
}
|
||||
})
|
||||
|
||||
describe("getPluginName", () => {
|
||||
test("extracts name from file:// URL", () => {
|
||||
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
||||
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
|
||||
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
|
||||
describe("resolvePluginSpec", () => {
|
||||
test("keeps package specs unchanged", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
expect(await Config.resolvePluginSpec("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", () => {
|
||||
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
|
||||
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
|
||||
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
|
||||
test("resolves relative file plugin paths to file urls", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
|
||||
},
|
||||
})
|
||||
|
||||
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", () => {
|
||||
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
|
||||
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
|
||||
})
|
||||
test("resolves plugin directory paths to package main files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const plugin = path.join(dir, "plugin")
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await Filesystem.writeJson(path.join(plugin, "package.json"), {
|
||||
name: "demo-plugin",
|
||||
type: "module",
|
||||
main: "./index.ts",
|
||||
})
|
||||
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
|
||||
},
|
||||
})
|
||||
|
||||
test("returns full string for package without version", () => {
|
||||
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
|
||||
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
const hit = await Config.resolvePluginSpec("./plugin", file)
|
||||
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1728,13 +1858,20 @@ describe("deduplicatePlugins", () => {
|
|||
expect(result.length).toBe(3)
|
||||
})
|
||||
|
||||
test("prefers local file over npm package with same name", () => {
|
||||
test("keeps path plugins separate from package plugins", () => {
|
||||
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
|
||||
expect(result).toEqual(plugins)
|
||||
})
|
||||
|
||||
test("deduplicates direct path plugins by exact spec", () => {
|
||||
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
|
||||
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
|
||||
})
|
||||
|
||||
test("preserves order of remaining plugins", () => {
|
||||
|
|
@ -1745,7 +1882,7 @@ describe("deduplicatePlugins", () => {
|
|||
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({
|
||||
init: async (dir) => {
|
||||
const projectDir = path.join(dir, "project")
|
||||
|
|
@ -1771,9 +1908,8 @@ describe("deduplicatePlugins", () => {
|
|||
const config = await Config.get()
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
||||
expect(myPlugins.length).toBe(1)
|
||||
expect(myPlugins[0].startsWith("file://")).toBe(true)
|
||||
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
||||
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
|
|||
test("loads managed tui config and gives it highest precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
|
||||
)
|
||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
|
||||
await Bun.write(
|
||||
path.join(managedConfigDir, "tui.json"),
|
||||
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
|
|||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("managed-theme")
|
||||
expect(config.plugin).toEqual(["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,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
|||
import { tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
const live = CrossSpawnSpawner.defaultLayer
|
||||
const fx = testEffect(live)
|
||||
|
||||
function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "path"
|
|||
import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||
directory,
|
||||
fn: async () => {
|
||||
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(watcherConfigLayer),
|
||||
)
|
||||
const rt = ManagedRuntime.make(layer)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue