mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
tui plugins (#19347)
This commit is contained in:
223
.opencode/plugins/smoke-theme.json
Normal file
223
.opencode/plugins/smoke-theme.json
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"nord0": "#2E3440",
|
||||||
|
"nord1": "#3B4252",
|
||||||
|
"nord2": "#434C5E",
|
||||||
|
"nord3": "#4C566A",
|
||||||
|
"nord4": "#D8DEE9",
|
||||||
|
"nord5": "#E5E9F0",
|
||||||
|
"nord6": "#ECEFF4",
|
||||||
|
"nord7": "#8FBCBB",
|
||||||
|
"nord8": "#88C0D0",
|
||||||
|
"nord9": "#81A1C1",
|
||||||
|
"nord10": "#5E81AC",
|
||||||
|
"nord11": "#BF616A",
|
||||||
|
"nord12": "#D08770",
|
||||||
|
"nord13": "#EBCB8B",
|
||||||
|
"nord14": "#A3BE8C",
|
||||||
|
"nord15": "#B48EAD"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": {
|
||||||
|
"dark": "nord10",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"dark": "nord12",
|
||||||
|
"light": "nord12"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"dark": "nord6",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"textMuted": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord1"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"dark": "nord0",
|
||||||
|
"light": "nord6"
|
||||||
|
},
|
||||||
|
"backgroundPanel": {
|
||||||
|
"dark": "nord1",
|
||||||
|
"light": "nord5"
|
||||||
|
},
|
||||||
|
"backgroundElement": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord4"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"borderActive": {
|
||||||
|
"dark": "nord3",
|
||||||
|
"light": "nord2"
|
||||||
|
},
|
||||||
|
"borderSubtle": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffAdded": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"diffRemoved": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"diffContext": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffHunkHeader": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffHighlightAdded": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"diffHighlightRemoved": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"diffAddedBg": {
|
||||||
|
"dark": "#36413C",
|
||||||
|
"light": "#E6EBE7"
|
||||||
|
},
|
||||||
|
"diffRemovedBg": {
|
||||||
|
"dark": "#43393D",
|
||||||
|
"light": "#ECE6E8"
|
||||||
|
},
|
||||||
|
"diffContextBg": {
|
||||||
|
"dark": "nord1",
|
||||||
|
"light": "nord5"
|
||||||
|
},
|
||||||
|
"diffLineNumber": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord4"
|
||||||
|
},
|
||||||
|
"diffAddedLineNumberBg": {
|
||||||
|
"dark": "#303A35",
|
||||||
|
"light": "#DDE4DF"
|
||||||
|
},
|
||||||
|
"diffRemovedLineNumberBg": {
|
||||||
|
"dark": "#3C3336",
|
||||||
|
"light": "#E4DDE0"
|
||||||
|
},
|
||||||
|
"markdownText": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"markdownHeading": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"markdownLink": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"markdownLinkText": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownCode": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"markdownBlockQuote": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"markdownEmph": {
|
||||||
|
"dark": "nord12",
|
||||||
|
"light": "nord12"
|
||||||
|
},
|
||||||
|
"markdownStrong": {
|
||||||
|
"dark": "nord13",
|
||||||
|
"light": "nord13"
|
||||||
|
},
|
||||||
|
"markdownHorizontalRule": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"markdownListItem": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"markdownListEnumeration": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownImage": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"markdownImageText": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownCodeBlock": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"syntaxComment": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"syntaxKeyword": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"syntaxFunction": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord8"
|
||||||
|
},
|
||||||
|
"syntaxVariable": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"syntaxString": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"syntaxNumber": {
|
||||||
|
"dark": "nord15",
|
||||||
|
"light": "nord15"
|
||||||
|
},
|
||||||
|
"syntaxType": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"syntaxOperator": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"syntaxPunctuation": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
852
.opencode/plugins/tui-smoke.tsx
Normal file
852
.opencode/plugins/tui-smoke.tsx
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||||
|
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
|
||||||
|
|
||||||
|
const tabs = ["overview", "counter", "help"]
|
||||||
|
const bind = {
|
||||||
|
modal: "ctrl+shift+m",
|
||||||
|
screen: "ctrl+shift+o",
|
||||||
|
home: "escape,ctrl+h",
|
||||||
|
left: "left,h",
|
||||||
|
right: "right,l",
|
||||||
|
up: "up,k",
|
||||||
|
down: "down,j",
|
||||||
|
alert: "a",
|
||||||
|
confirm: "c",
|
||||||
|
prompt: "p",
|
||||||
|
select: "s",
|
||||||
|
modal_accept: "enter,return",
|
||||||
|
modal_close: "escape",
|
||||||
|
dialog_close: "escape",
|
||||||
|
local: "x",
|
||||||
|
local_push: "enter,return",
|
||||||
|
local_close: "q,backspace",
|
||||||
|
host: "z",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = (value: unknown, fallback: string) => {
|
||||||
|
if (typeof value !== "string") return fallback
|
||||||
|
if (!value.trim()) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (value: unknown, fallback: number) => {
|
||||||
|
if (typeof value !== "number") return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = (value: unknown) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||||
|
return Object.fromEntries(Object.entries(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cfg = {
|
||||||
|
label: string
|
||||||
|
route: string
|
||||||
|
vignette: number
|
||||||
|
keybinds: Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
modal: string
|
||||||
|
screen: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
tab: number
|
||||||
|
count: number
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
selected: string
|
||||||
|
local: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||||
|
return {
|
||||||
|
label: pick(options?.label, "smoke"),
|
||||||
|
route: pick(options?.route, "workspace-smoke"),
|
||||||
|
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||||
|
keybinds: rec(options?.keybinds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = (input: Cfg) => {
|
||||||
|
return {
|
||||||
|
modal: `${input.route}.modal`,
|
||||||
|
screen: `${input.route}.screen`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keys = TuiKeybindSet
|
||||||
|
const ui = {
|
||||||
|
panel: "#1d1d1d",
|
||||||
|
border: "#4a4a4a",
|
||||||
|
text: "#f0f0f0",
|
||||||
|
muted: "#a5a5a5",
|
||||||
|
accent: "#5f87ff",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = RGBA | string
|
||||||
|
|
||||||
|
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
|
||||||
|
const value = map[name]
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
if (value instanceof RGBA) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const look = (map: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
panel: ink(map, "backgroundPanel", ui.panel),
|
||||||
|
border: ink(map, "border", ui.border),
|
||||||
|
text: ink(map, "text", ui.text),
|
||||||
|
muted: ink(map, "textMuted", ui.muted),
|
||||||
|
accent: ink(map, "primary", ui.accent),
|
||||||
|
selected: ink(map, "selectedListItemText", ui.text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = (api: TuiPluginApi) => {
|
||||||
|
return look(api.theme.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Skin = {
|
||||||
|
panel: Color
|
||||||
|
border: Color
|
||||||
|
text: Color
|
||||||
|
muted: Color
|
||||||
|
accent: Color
|
||||||
|
selected: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
onMouseUp={() => {
|
||||||
|
props.run()
|
||||||
|
}}
|
||||||
|
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
>
|
||||||
|
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parse = (params: Record<string, unknown> | undefined) => {
|
||||||
|
const tab = typeof params?.tab === "number" ? params.tab : 0
|
||||||
|
const count = typeof params?.count === "number" ? params.count : 0
|
||||||
|
const source = typeof params?.source === "string" ? params.source : "unknown"
|
||||||
|
const note = typeof params?.note === "string" ? params.note : ""
|
||||||
|
const selected = typeof params?.selected === "string" ? params.selected : ""
|
||||||
|
const local = typeof params?.local === "number" ? params.local : 0
|
||||||
|
return {
|
||||||
|
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||||
|
count,
|
||||||
|
source,
|
||||||
|
note,
|
||||||
|
selected,
|
||||||
|
local: Math.max(0, local),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (api: TuiPluginApi, route: Route) => {
|
||||||
|
const value = api.route.current
|
||||||
|
const ok = Object.values(route).includes(value.name)
|
||||||
|
if (!ok) return parse(undefined)
|
||||||
|
if (!("params" in value)) return parse(undefined)
|
||||||
|
return parse(value.params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = [
|
||||||
|
{
|
||||||
|
title: "Overview",
|
||||||
|
value: 0,
|
||||||
|
description: "Switch to overview tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Counter",
|
||||||
|
value: 1,
|
||||||
|
description: "Switch to counter tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Help",
|
||||||
|
value: 2,
|
||||||
|
description: "Switch to help tab",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{input.label} host overlay</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||||
|
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogAlert = api.ui.DialogAlert
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogAlert
|
||||||
|
title="Smoke alert"
|
||||||
|
message="Testing built-in alert dialog"
|
||||||
|
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogConfirm = api.ui.DialogConfirm
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogConfirm
|
||||||
|
title="Smoke confirm"
|
||||||
|
message="Apply +1 to counter?"
|
||||||
|
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||||
|
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogPrompt = api.ui.DialogPrompt
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogPrompt
|
||||||
|
title="Smoke prompt"
|
||||||
|
value={value.note}
|
||||||
|
onConfirm={(note) => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const picker = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogSelect = api.ui.DialogSelect
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Smoke select"
|
||||||
|
options={opts}
|
||||||
|
current={value.tab}
|
||||||
|
onSelect={(item) => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, {
|
||||||
|
...value,
|
||||||
|
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||||
|
selected: item.title,
|
||||||
|
source: "select",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const Screen = (props: {
|
||||||
|
api: TuiPluginApi
|
||||||
|
input: Cfg
|
||||||
|
route: Route
|
||||||
|
keys: Keys
|
||||||
|
meta: TuiPluginMeta
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
const dim = useTerminalDimensions()
|
||||||
|
const value = parse(props.params)
|
||||||
|
const skin = tone(props.api)
|
||||||
|
const set = (local: number, base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||||
|
}
|
||||||
|
const push = (base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
set(next.local + 1, next)
|
||||||
|
}
|
||||||
|
const open = () => {
|
||||||
|
const next = current(props.api, props.route)
|
||||||
|
if (next.local > 0) return
|
||||||
|
set(1, next)
|
||||||
|
}
|
||||||
|
const pop = (base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
const local = Math.max(0, next.local - 1)
|
||||||
|
set(local, next)
|
||||||
|
}
|
||||||
|
const show = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
open()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (props.api.route.current.name !== props.route.screen) return
|
||||||
|
const next = current(props.api, props.route)
|
||||||
|
if (props.api.ui.dialog.open) {
|
||||||
|
if (props.keys.match("dialog_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.ui.dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.local > 0) {
|
||||||
|
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
pop(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("local_push", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
push(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("home", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate("home")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("left", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("right", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("up", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("down", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("modal", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.modal, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("local", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("host", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
host(props.api, props.input, skin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("alert", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
warn(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("confirm", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
check(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("prompt", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
entry(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("select", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
picker(props.api, props.route, next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} screen</b>
|
||||||
|
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||||
|
{tabs.map((item, i) => {
|
||||||
|
const on = value.tab === i
|
||||||
|
return (
|
||||||
|
<Btn
|
||||||
|
txt={item}
|
||||||
|
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||||
|
skin={skin}
|
||||||
|
on={on}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
{value.tab === 0 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||||
|
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||||
|
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
|
||||||
|
<text fg={skin.muted}>source: {value.source}</text>
|
||||||
|
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||||
|
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||||
|
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||||
|
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.tab === 1 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.text}>Counter: {value.count}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.tab === 2 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||||
|
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||||
|
close
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||||
|
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||||
|
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||||
|
<Btn txt="local overlay" run={show} skin={skin} />
|
||||||
|
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||||
|
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
visible={value.local > 0}
|
||||||
|
width={dim().width}
|
||||||
|
height={dim().height}
|
||||||
|
alignItems="center"
|
||||||
|
position="absolute"
|
||||||
|
zIndex={3000}
|
||||||
|
paddingTop={dim().height / 4}
|
||||||
|
left={0}
|
||||||
|
top={0}
|
||||||
|
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||||
|
onMouseUp={() => {
|
||||||
|
pop()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
onMouseUp={(evt) => {
|
||||||
|
evt.stopPropagation()
|
||||||
|
}}
|
||||||
|
width={60}
|
||||||
|
maxWidth={dim().width - 2}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
gap={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} local overlay</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn txt="push" run={push} skin={skin} on />
|
||||||
|
<Btn txt="pop" run={pop} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal = (props: {
|
||||||
|
api: TuiPluginApi
|
||||||
|
input: Cfg
|
||||||
|
route: Route
|
||||||
|
keys: Keys
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
const Dialog = props.api.ui.Dialog
|
||||||
|
const value = parse(props.params)
|
||||||
|
const skin = tone(props.api)
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (props.api.route.current.name !== props.route.modal) return
|
||||||
|
|
||||||
|
if (props.keys.match("modal_accept", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("modal_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate("home")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||||
|
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||||
|
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} modal</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn
|
||||||
|
txt="open screen"
|
||||||
|
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||||
|
skin={skin}
|
||||||
|
on
|
||||||
|
/>
|
||||||
|
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Dialog>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = (input: Cfg): TuiSlotPlugin => ({
|
||||||
|
slots: {
|
||||||
|
home_logo(ctx) {
|
||||||
|
const map = ctx.theme.current
|
||||||
|
const skin = look(map)
|
||||||
|
const art = [
|
||||||
|
" $$\\",
|
||||||
|
" $$ |",
|
||||||
|
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
||||||
|
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
||||||
|
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
||||||
|
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
||||||
|
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
||||||
|
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
||||||
|
]
|
||||||
|
const fill = [
|
||||||
|
skin.accent,
|
||||||
|
skin.muted,
|
||||||
|
ink(map, "info", ui.accent),
|
||||||
|
skin.text,
|
||||||
|
ink(map, "success", ui.accent),
|
||||||
|
ink(map, "warning", ui.accent),
|
||||||
|
ink(map, "secondary", ui.accent),
|
||||||
|
ink(map, "error", ui.accent),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column">
|
||||||
|
{art.map((line, i) => (
|
||||||
|
<text fg={fill[i]}>{line}</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_bottom(ctx) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const text = "extra content in the unified home bottom slot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
|
||||||
|
order,
|
||||||
|
slots: {
|
||||||
|
sidebar_content(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<text fg={skin.accent}>
|
||||||
|
<b>{title}</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.text}>{text}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{input.label} order {order} · session {value.session_id.slice(0, 8)}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const slot = (input: Cfg): TuiSlotPlugin[] => [
|
||||||
|
home(input),
|
||||||
|
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
|
||||||
|
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
|
||||||
|
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||||
|
const route = names(input)
|
||||||
|
api.command.register(() => [
|
||||||
|
{
|
||||||
|
title: `${input.label} modal`,
|
||||||
|
value: "plugin.smoke.modal",
|
||||||
|
keybind: keys.get("modal"),
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate(route.modal, { source: "command" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} screen`,
|
||||||
|
value: "plugin.smoke.screen",
|
||||||
|
keybind: keys.get("screen"),
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-screen",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} alert dialog`,
|
||||||
|
value: "plugin.smoke.alert",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-alert",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
warn(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} confirm dialog`,
|
||||||
|
value: "plugin.smoke.confirm",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-confirm",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
check(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} prompt dialog`,
|
||||||
|
value: "plugin.smoke.prompt",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-prompt",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
entry(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} select dialog`,
|
||||||
|
value: "plugin.smoke.select",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-select",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
picker(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} host overlay`,
|
||||||
|
value: "plugin.smoke.host",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-host",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
host(api, input, tone(api))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} go home`,
|
||||||
|
value: "plugin.smoke.home",
|
||||||
|
category: "Plugin",
|
||||||
|
enabled: api.route.current.name !== "home",
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate("home")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} toast`,
|
||||||
|
value: "plugin.smoke.toast",
|
||||||
|
category: "Plugin",
|
||||||
|
onSelect: () => {
|
||||||
|
api.ui.toast({
|
||||||
|
variant: "info",
|
||||||
|
title: "Smoke",
|
||||||
|
message: "Plugin toast works",
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
|
||||||
|
if (options?.enabled === false) return
|
||||||
|
|
||||||
|
await api.theme.install("./smoke-theme.json")
|
||||||
|
api.theme.set("smoke-theme")
|
||||||
|
|
||||||
|
const value = cfg(options ?? undefined)
|
||||||
|
const route = names(value)
|
||||||
|
const keys = api.keybind.create(bind, value.keybinds)
|
||||||
|
const fx = new VignetteEffect(value.vignette)
|
||||||
|
const post = fx.apply.bind(fx)
|
||||||
|
api.renderer.addPostProcessFn(post)
|
||||||
|
api.lifecycle.onDispose(() => {
|
||||||
|
api.renderer.removePostProcessFn(post)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.route.register([
|
||||||
|
{
|
||||||
|
name: route.screen,
|
||||||
|
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: route.modal,
|
||||||
|
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
reg(api, value, keys)
|
||||||
|
for (const item of slot(value)) {
|
||||||
|
api.slots.register(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "tui-smoke",
|
||||||
|
tui,
|
||||||
|
}
|
||||||
1
.opencode/themes/.gitignore
vendored
Normal file
1
.opencode/themes/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
smoke-theme.json
|
||||||
19
.opencode/tui.json
Normal file
19
.opencode/tui.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/tui.json",
|
||||||
|
"theme": "smoke-theme",
|
||||||
|
"plugin": [
|
||||||
|
[
|
||||||
|
"./plugins/tui-smoke.tsx",
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"label": "workspace",
|
||||||
|
"keybinds": {
|
||||||
|
"modal": "ctrl+alt+m",
|
||||||
|
"screen": "ctrl+alt+o",
|
||||||
|
"home": "escape,ctrl+shift+h",
|
||||||
|
"dialog_close": "escape,q"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
16
bun.lock
16
bun.lock
@@ -428,11 +428,21 @@
|
|||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opentui/core": "0.1.90",
|
||||||
|
"@opentui/solid": "0.1.90",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.1.90",
|
||||||
|
"@opentui/solid": ">=0.1.90",
|
||||||
|
},
|
||||||
|
"optionalPeers": [
|
||||||
|
"@opentui/core",
|
||||||
|
"@opentui/solid",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"packages/script": {
|
"packages/script": {
|
||||||
"name": "@opencode-ai/script",
|
"name": "@opencode-ai/script",
|
||||||
@@ -3837,7 +3847,7 @@
|
|||||||
|
|
||||||
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
||||||
|
|
||||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||||
|
|
||||||
@@ -5677,12 +5687,12 @@
|
|||||||
|
|
||||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||||
|
|
||||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
|
||||||
|
|
||||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||||
|
|
||||||
"vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
"vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
const plugins = createMemo(() =>
|
||||||
|
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||||
|
)
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
preload = ["@opentui/solid/preload"]
|
preload = ["@opentui/solid/preload"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
preload = ["./test/preload.ts"]
|
preload = ["@opentui/solid/preload", "./test/preload.ts"]
|
||||||
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
|
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
|
||||||
# using --timeout in package.json scripts instead
|
# using --timeout in package.json scripts instead
|
||||||
# https://github.com/oven-sh/bun/issues/7789
|
# https://github.com/oven-sh/bun/issues/7789
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -63,6 +63,7 @@ console.log(`Loaded ${migrations.length} migrations`)
|
|||||||
const singleFlag = process.argv.includes("--single")
|
const singleFlag = process.argv.includes("--single")
|
||||||
const baselineFlag = process.argv.includes("--baseline")
|
const baselineFlag = process.argv.includes("--baseline")
|
||||||
const skipInstall = process.argv.includes("--skip-install")
|
const skipInstall = process.argv.includes("--skip-install")
|
||||||
|
const plugin = createSolidTransformPlugin()
|
||||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||||
|
|
||||||
const createEmbeddedWebUIBundle = async () => {
|
const createEmbeddedWebUIBundle = async () => {
|
||||||
@@ -207,7 +208,7 @@ for (const item of targets) {
|
|||||||
await Bun.build({
|
await Bun.build({
|
||||||
conditions: ["browser"],
|
conditions: ["browser"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
plugins: [solidPlugin],
|
plugins: [plugin],
|
||||||
compile: {
|
compile: {
|
||||||
autoloadBunfig: false,
|
autoloadBunfig: false,
|
||||||
autoloadDotenv: false,
|
autoloadDotenv: false,
|
||||||
|
|||||||
377
packages/opencode/specs/tui-plugins.md
Normal file
377
packages/opencode/specs/tui-plugins.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# TUI plugins
|
||||||
|
|
||||||
|
Technical reference for the current TUI plugin system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- TUI plugin config lives in `tui.json`.
|
||||||
|
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
||||||
|
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
||||||
|
- Package plugins can be installed from CLI or TUI.
|
||||||
|
|
||||||
|
## TUI config
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/tui.json",
|
||||||
|
"theme": "smoke-theme",
|
||||||
|
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
|
||||||
|
"plugin_enabled": {
|
||||||
|
"acme.demo": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `plugin` entries can be either a string spec or `[spec, options]`.
|
||||||
|
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
|
||||||
|
- Relative path specs are resolved relative to the config file that declared them.
|
||||||
|
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
|
||||||
|
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
|
||||||
|
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||||
|
- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
|
||||||
|
- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
|
||||||
|
- `plugin_enabled` is merged across config layers.
|
||||||
|
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||||
|
|
||||||
|
## Author package shape
|
||||||
|
|
||||||
|
Package entrypoint:
|
||||||
|
|
||||||
|
- Import types from `@opencode-ai/plugin/tui`.
|
||||||
|
- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`.
|
||||||
|
|
||||||
|
Minimal module shape:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||||
|
|
||||||
|
const tui: TuiPlugin = async (api, options, meta) => {
|
||||||
|
api.command.register(() => [
|
||||||
|
{
|
||||||
|
title: "Demo",
|
||||||
|
value: "demo.open",
|
||||||
|
onSelect: () => api.route.navigate("demo"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
api.route.register([
|
||||||
|
{
|
||||||
|
name: "demo",
|
||||||
|
render: () => (
|
||||||
|
<box>
|
||||||
|
<text>demo</text>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "acme.demo",
|
||||||
|
tui,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Loader only reads the module default export object. Named exports are ignored.
|
||||||
|
- TUI shape is `default export { id?, tui }`.
|
||||||
|
- `tui` signature is `(api, options, meta) => Promise<void>`.
|
||||||
|
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
|
||||||
|
- File/path plugins must export a non-empty `id`.
|
||||||
|
- npm plugins may omit `id`; package `name` is used.
|
||||||
|
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
|
||||||
|
- If a path spec points at a directory, that directory must have `package.json` with `main`.
|
||||||
|
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
|
||||||
|
|
||||||
|
## Package manifest and install
|
||||||
|
|
||||||
|
Package manifest is read from `package.json` field `oc-plugin`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@acme/opencode-plugin",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"engines": {
|
||||||
|
"opencode": "^1.0.0"
|
||||||
|
},
|
||||||
|
"oc-plugin": [
|
||||||
|
["server", { "custom": true }],
|
||||||
|
["tui", { "compact": true }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version compatibility
|
||||||
|
|
||||||
|
npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"engines": {
|
||||||
|
"opencode": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- The value is a semver range checked against the running OpenCode version.
|
||||||
|
- If the range is not satisfied, the plugin is skipped with a warning and a session error.
|
||||||
|
- If `engines.opencode` is absent, no check is performed (backward compatible).
|
||||||
|
- File plugins are never checked; only npm package plugins are validated.
|
||||||
|
|
||||||
|
- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
|
||||||
|
- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
|
||||||
|
- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
|
||||||
|
- Alias: `opencode plug <module>`.
|
||||||
|
- `-g` / `--global` writes into the global config dir.
|
||||||
|
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||||
|
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||||
|
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||||
|
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
||||||
|
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||||
|
- Without `--force`, an already-configured npm package name is a no-op.
|
||||||
|
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||||
|
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||||
|
- A package can target `server`, `tui`, or both.
|
||||||
|
- There is no uninstall, list, or update CLI command for external plugins.
|
||||||
|
- Local file plugins are configured directly in `tui.json`.
|
||||||
|
|
||||||
|
When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes:
|
||||||
|
|
||||||
|
- `package.json`
|
||||||
|
- `bun.lock`
|
||||||
|
- `node_modules/`
|
||||||
|
- `.gitignore`
|
||||||
|
|
||||||
|
That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`.
|
||||||
|
|
||||||
|
## TUI plugin API
|
||||||
|
|
||||||
|
Top-level API groups exposed to `tui(api, options, meta)`:
|
||||||
|
|
||||||
|
- `api.app.version`
|
||||||
|
- `api.command.register(cb)` / `api.command.trigger(value)`
|
||||||
|
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||||
|
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
|
||||||
|
- `api.keybind.match`, `print`, `create`
|
||||||
|
- `api.tuiConfig`
|
||||||
|
- `api.kv.get`, `set`, `ready`
|
||||||
|
- `api.state`
|
||||||
|
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
|
||||||
|
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
|
||||||
|
- `api.event.on(type, handler)`
|
||||||
|
- `api.renderer`
|
||||||
|
- `api.slots.register(plugin)`
|
||||||
|
- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
|
||||||
|
- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
`api.command.register` returns an unregister function. Command rows support:
|
||||||
|
|
||||||
|
- `title`, `value`
|
||||||
|
- `description`, `category`
|
||||||
|
- `keybind`
|
||||||
|
- `suggested`, `hidden`, `enabled`
|
||||||
|
- `slash: { name, aliases? }`
|
||||||
|
- `onSelect`
|
||||||
|
|
||||||
|
Command behavior:
|
||||||
|
|
||||||
|
- Registrations are reactive.
|
||||||
|
- Later registrations win for duplicate `value` and for keybind handling.
|
||||||
|
- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
- Reserved route names: `home` and `session`.
|
||||||
|
- Any other name is treated as a plugin route.
|
||||||
|
- `api.route.current` returns one of:
|
||||||
|
- `{ name: "home" }`
|
||||||
|
- `{ name: "session", params: { sessionID, initialPrompt? } }`
|
||||||
|
- `{ name: string, params?: Record<string, unknown> }`
|
||||||
|
- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`.
|
||||||
|
- If multiple plugins register the same route name, the last registered route wins.
|
||||||
|
- Unknown plugin routes render a fallback screen with a `go home` action.
|
||||||
|
|
||||||
|
### Dialogs and toast
|
||||||
|
|
||||||
|
- `ui.Dialog` is the base dialog wrapper.
|
||||||
|
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||||
|
- `ui.toast(...)` shows a toast.
|
||||||
|
- `ui.dialog` exposes the host dialog stack:
|
||||||
|
- `replace(render, onClose?)`
|
||||||
|
- `clear()`
|
||||||
|
- `setSize("medium" | "large" | "xlarge")`
|
||||||
|
- readonly `size`, `depth`, `open`
|
||||||
|
|
||||||
|
### Keybinds
|
||||||
|
|
||||||
|
- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
|
||||||
|
- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
|
||||||
|
- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
|
||||||
|
- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
|
||||||
|
|
||||||
|
### KV, state, client, events
|
||||||
|
|
||||||
|
- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
|
||||||
|
- `api.kv` exposes `ready`.
|
||||||
|
- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots.
|
||||||
|
- `api.state` exposes synced TUI state:
|
||||||
|
- `ready`
|
||||||
|
- `config`
|
||||||
|
- `provider`
|
||||||
|
- `path.{state,config,worktree,directory}`
|
||||||
|
- `vcs?.branch`
|
||||||
|
- `workspace.list()` / `workspace.get(workspaceID)`
|
||||||
|
- `session.count()`
|
||||||
|
- `session.diff(sessionID)`
|
||||||
|
- `session.todo(sessionID)`
|
||||||
|
- `session.messages(sessionID)`
|
||||||
|
- `session.status(sessionID)`
|
||||||
|
- `session.permission(sessionID)`
|
||||||
|
- `session.question(sessionID)`
|
||||||
|
- `part(messageID)`
|
||||||
|
- `lsp()`
|
||||||
|
- `mcp()`
|
||||||
|
- `api.client` always reflects the current runtime client.
|
||||||
|
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
|
||||||
|
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
|
||||||
|
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
|
||||||
|
- `api.renderer` exposes the raw `CliRenderer`.
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
|
||||||
|
- `api.theme.current` exposes the resolved current theme tokens.
|
||||||
|
- `api.theme.selected` is the selected theme name.
|
||||||
|
- `api.theme.has(name)` checks for an installed theme.
|
||||||
|
- `api.theme.set(name)` switches theme and returns `boolean`.
|
||||||
|
- `api.theme.mode()` returns `"dark" | "light"`.
|
||||||
|
- `api.theme.install(jsonPath)` installs a theme JSON file.
|
||||||
|
- `api.theme.ready` reports theme readiness.
|
||||||
|
|
||||||
|
Theme install behavior:
|
||||||
|
|
||||||
|
- Relative theme paths are resolved from the plugin root.
|
||||||
|
- Theme name is the JSON basename.
|
||||||
|
- Install is skipped if that theme name already exists.
|
||||||
|
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||||
|
- Global plugins persist installed themes under the global `themes` dir.
|
||||||
|
- Invalid or unreadable theme files are ignored.
|
||||||
|
|
||||||
|
### Slots
|
||||||
|
|
||||||
|
Current host slot names:
|
||||||
|
|
||||||
|
- `app`
|
||||||
|
- `home_logo`
|
||||||
|
- `home_bottom`
|
||||||
|
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||||
|
- `sidebar_content` with props `{ session_id }`
|
||||||
|
- `sidebar_footer` with props `{ session_id }`
|
||||||
|
|
||||||
|
Slot notes:
|
||||||
|
|
||||||
|
- Slot context currently exposes only `theme`.
|
||||||
|
- `api.slots.register(plugin)` returns the host-assigned slot plugin id.
|
||||||
|
- `api.slots.register(plugin)` does not return an unregister function.
|
||||||
|
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||||
|
- Plugin-provided `id` is not allowed.
|
||||||
|
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||||
|
- Plugins cannot define new slot names in this branch.
|
||||||
|
|
||||||
|
### Plugin control and lifecycle
|
||||||
|
|
||||||
|
- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`.
|
||||||
|
- `enabled` is the persisted desired state. `active` means the plugin is currently initialized.
|
||||||
|
- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin.
|
||||||
|
- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope.
|
||||||
|
- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
|
||||||
|
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
||||||
|
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
||||||
|
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
||||||
|
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||||
|
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||||
|
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||||
|
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
||||||
|
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||||
|
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||||
|
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||||
|
|
||||||
|
## Plugin metadata
|
||||||
|
|
||||||
|
`meta` passed to `tui(api, options, meta)` contains:
|
||||||
|
|
||||||
|
- `state`: `first | updated | same`
|
||||||
|
- `id`, `source`, `spec`, `target`
|
||||||
|
- npm-only fields when available: `requested`, `version`
|
||||||
|
- file-only field when available: `modified`
|
||||||
|
- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint`
|
||||||
|
|
||||||
|
Metadata is persisted by plugin id.
|
||||||
|
|
||||||
|
- File plugin fingerprint is `target|modified`.
|
||||||
|
- npm plugin fingerprint is `target|requested|version`.
|
||||||
|
- Internal plugins get synthetic metadata with `state: "same"`.
|
||||||
|
|
||||||
|
## Runtime behavior
|
||||||
|
|
||||||
|
- Internal TUI plugins load first.
|
||||||
|
- External TUI plugins load from `tuiConfig.plugin`.
|
||||||
|
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
|
||||||
|
- External plugin resolution and import are parallel.
|
||||||
|
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
|
||||||
|
- File plugins that fail initially are retried once after waiting for config dependency installation.
|
||||||
|
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
||||||
|
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
||||||
|
- Runtime install and runtime add are separate operations.
|
||||||
|
- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
|
||||||
|
- TUI runtime tracks and disposes:
|
||||||
|
- command registrations
|
||||||
|
- route registrations
|
||||||
|
- event subscriptions
|
||||||
|
- slot registrations
|
||||||
|
- explicit `lifecycle.onDispose(...)` handlers
|
||||||
|
- Cleanup runs in reverse order.
|
||||||
|
- Cleanup is awaited.
|
||||||
|
- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
|
||||||
|
|
||||||
|
## Built-in plugins
|
||||||
|
|
||||||
|
- `internal:home-tips`
|
||||||
|
- `internal:sidebar-context`
|
||||||
|
- `internal:sidebar-mcp`
|
||||||
|
- `internal:sidebar-lsp`
|
||||||
|
- `internal:sidebar-todo`
|
||||||
|
- `internal:sidebar-files`
|
||||||
|
- `internal:sidebar-footer`
|
||||||
|
- `internal:plugin-manager`
|
||||||
|
|
||||||
|
Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
|
||||||
|
|
||||||
|
The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.
|
||||||
|
|
||||||
|
- Keybind name is `plugin_manager`.
|
||||||
|
- Default keybind is `none`.
|
||||||
|
- It lists both internal and external plugins.
|
||||||
|
- It toggles based on `active`.
|
||||||
|
- Its own row is disabled only inside the manager dialog.
|
||||||
|
- It also exposes command `plugins.install` with title `Install plugin`.
|
||||||
|
- Inside the Plugins dialog, key `shift+i` opens the install prompt.
|
||||||
|
- Install prompt asks for npm package name.
|
||||||
|
- Scope defaults to local, and `tab` toggles local/global.
|
||||||
|
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
||||||
|
- Manager install uses `api.plugins.install(spec, { global })`.
|
||||||
|
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
||||||
|
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
||||||
|
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
||||||
|
|
||||||
|
## Current in-repo examples
|
||||||
|
|
||||||
|
- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
|
||||||
|
- Local smoke config: `.opencode/tui.json`
|
||||||
|
- Local smoke theme: `.opencode/plugins/smoke-theme.json`
|
||||||
@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
|
|||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { PackageRegistry } from "./registry"
|
import { PackageRegistry } from "./registry"
|
||||||
import { proxied } from "@/util/proxied"
|
import { online, proxied } from "@/util/network"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
@@ -68,12 +68,13 @@ export namespace BunProc {
|
|||||||
|
|
||||||
if (!modExists || !cachedVersion) {
|
if (!modExists || !cachedVersion) {
|
||||||
// continue to install
|
// continue to install
|
||||||
} else if (version !== "latest" && cachedVersion === version) {
|
|
||||||
return mod
|
|
||||||
} else if (version === "latest") {
|
} else if (version === "latest") {
|
||||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
if (!online()) return mod
|
||||||
if (!isOutdated) return mod
|
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||||
|
if (!stale) return mod
|
||||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||||
|
} else if (cachedVersion === version) {
|
||||||
|
return mod
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build command arguments
|
// Build command arguments
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
|
import { online } from "@/util/network"
|
||||||
|
|
||||||
export namespace PackageRegistry {
|
export namespace PackageRegistry {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
@@ -10,6 +11,11 @@ export namespace PackageRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||||
|
if (!online()) {
|
||||||
|
log.debug("offline, skipping bun info", { pkg, field })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||||
cwd,
|
cwd,
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { UI } from "../ui"
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { JsonMigration } from "../../storage/json-migration"
|
import { JsonMigration } from "../../storage/json-migration"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import { errorMessage } from "../../util/error"
|
||||||
|
|
||||||
const QueryCommand = cmd({
|
const QueryCommand = cmd({
|
||||||
command: "$0 [query]",
|
command: "$0 [query]",
|
||||||
@@ -39,7 +40,7 @@ const QueryCommand = cmd({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
UI.error(err instanceof Error ? err.message : String(err))
|
UI.error(errorMessage(err))
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
db.close()
|
db.close()
|
||||||
@@ -100,7 +101,7 @@ const MigrateCommand = cmd({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (tty) process.stderr.write("\x1b[?25h")
|
if (tty) process.stderr.write("\x1b[?25h")
|
||||||
UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
|
UI.error(`Migration failed: ${errorMessage(err)}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
} finally {
|
} finally {
|
||||||
sqlite.close()
|
sqlite.close()
|
||||||
|
|||||||
231
packages/opencode/src/cli/cmd/plug.ts
Normal file
231
packages/opencode/src/cli/cmd/plug.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||||
|
import type { Argv } from "yargs"
|
||||||
|
|
||||||
|
import { ConfigPaths } from "../../config/paths"
|
||||||
|
import { Global } from "../../global"
|
||||||
|
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||||
|
import { resolvePluginTarget } from "../../plugin/shared"
|
||||||
|
import { Instance } from "../../project/instance"
|
||||||
|
import { errorMessage } from "../../util/error"
|
||||||
|
import { Filesystem } from "../../util/filesystem"
|
||||||
|
import { Process } from "../../util/process"
|
||||||
|
import { UI } from "../ui"
|
||||||
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
|
type Spin = {
|
||||||
|
start: (msg: string) => void
|
||||||
|
stop: (msg: string, code?: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlugDeps = {
|
||||||
|
spinner: () => Spin
|
||||||
|
log: {
|
||||||
|
error: (msg: string) => void
|
||||||
|
info: (msg: string) => void
|
||||||
|
success: (msg: string) => void
|
||||||
|
}
|
||||||
|
resolve: (spec: string) => Promise<string>
|
||||||
|
readText: (file: string) => Promise<string>
|
||||||
|
write: (file: string, text: string) => Promise<void>
|
||||||
|
exists: (file: string) => Promise<boolean>
|
||||||
|
files: (dir: string, name: "opencode" | "tui") => string[]
|
||||||
|
global: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlugInput = {
|
||||||
|
mod: string
|
||||||
|
global?: boolean
|
||||||
|
force?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlugCtx = {
|
||||||
|
vcs?: string
|
||||||
|
worktree: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPlugDeps: PlugDeps = {
|
||||||
|
spinner: () => spinner(),
|
||||||
|
log: {
|
||||||
|
error: (msg) => log.error(msg),
|
||||||
|
info: (msg) => log.info(msg),
|
||||||
|
success: (msg) => log.success(msg),
|
||||||
|
},
|
||||||
|
resolve: (spec) => resolvePluginTarget(spec),
|
||||||
|
readText: (file) => Filesystem.readText(file),
|
||||||
|
write: async (file, text) => {
|
||||||
|
await Filesystem.write(file, text)
|
||||||
|
},
|
||||||
|
exists: (file) => Filesystem.exists(file),
|
||||||
|
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
||||||
|
global: Global.Path.config,
|
||||||
|
}
|
||||||
|
|
||||||
|
function cause(err: unknown) {
|
||||||
|
if (!err || typeof err !== "object") return
|
||||||
|
if (!("cause" in err)) return
|
||||||
|
return (err as { cause?: unknown }).cause
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
|
||||||
|
const mod = input.mod
|
||||||
|
const force = Boolean(input.force)
|
||||||
|
const global = Boolean(input.global)
|
||||||
|
|
||||||
|
return async (ctx: PlugCtx) => {
|
||||||
|
const install = dep.spinner()
|
||||||
|
install.start("Installing plugin package...")
|
||||||
|
const target = await installPlugin(mod, dep)
|
||||||
|
if (!target.ok) {
|
||||||
|
install.stop("Install failed", 1)
|
||||||
|
dep.log.error(`Could not install "${mod}"`)
|
||||||
|
const hit = cause(target.error) ?? target.error
|
||||||
|
if (hit instanceof Process.RunFailedError) {
|
||||||
|
const lines = hit.stderr
|
||||||
|
.toString()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||||
|
const detail = errs[0] ?? lines.at(-1)
|
||||||
|
if (detail) dep.log.error(detail)
|
||||||
|
if (lines.some((line) => line.includes("No version matching"))) {
|
||||||
|
dep.log.info("This package depends on a version that is not available in your npm registry.")
|
||||||
|
dep.log.info("Check npm registry/auth settings and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(hit instanceof Process.RunFailedError)) {
|
||||||
|
dep.log.error(errorMessage(hit))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
install.stop("Plugin package ready")
|
||||||
|
|
||||||
|
const inspect = dep.spinner()
|
||||||
|
inspect.start("Reading plugin manifest...")
|
||||||
|
const manifest = await readPluginManifest(target.target)
|
||||||
|
if (!manifest.ok) {
|
||||||
|
if (manifest.code === "manifest_read_failed") {
|
||||||
|
inspect.stop("Manifest read failed", 1)
|
||||||
|
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
|
||||||
|
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.code === "manifest_no_targets") {
|
||||||
|
inspect.stop("No plugin targets found", 1)
|
||||||
|
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
|
||||||
|
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect.stop("Manifest read failed", 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect.stop(
|
||||||
|
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const patch = dep.spinner()
|
||||||
|
patch.start("Updating plugin config...")
|
||||||
|
const out = await patchPluginConfig(
|
||||||
|
{
|
||||||
|
spec: mod,
|
||||||
|
targets: manifest.targets,
|
||||||
|
force,
|
||||||
|
global,
|
||||||
|
vcs: ctx.vcs,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
directory: ctx.directory,
|
||||||
|
config: dep.global,
|
||||||
|
},
|
||||||
|
dep,
|
||||||
|
)
|
||||||
|
if (!out.ok) {
|
||||||
|
if (out.code === "invalid_json") {
|
||||||
|
patch.stop(`Failed updating ${out.kind} config`, 1)
|
||||||
|
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
|
||||||
|
dep.log.info("Fix the config file and run the command again.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
patch.stop("Failed updating plugin config", 1)
|
||||||
|
dep.log.error(errorMessage(out.error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
patch.stop("Plugin config updated")
|
||||||
|
for (const item of out.items) {
|
||||||
|
if (item.mode === "noop") {
|
||||||
|
dep.log.info(`Already configured in ${item.file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.mode === "replace") {
|
||||||
|
dep.log.info(`Replaced in ${item.file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dep.log.info(`Added to ${item.file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
dep.log.success(`Installed ${mod}`)
|
||||||
|
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginCommand = cmd({
|
||||||
|
command: "plugin <module>",
|
||||||
|
aliases: ["plug"],
|
||||||
|
describe: "install plugin and update config",
|
||||||
|
builder: (yargs: Argv) => {
|
||||||
|
return yargs
|
||||||
|
.positional("module", {
|
||||||
|
type: "string",
|
||||||
|
describe: "npm module name",
|
||||||
|
})
|
||||||
|
.option("global", {
|
||||||
|
alias: ["g"],
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "install in global config",
|
||||||
|
})
|
||||||
|
.option("force", {
|
||||||
|
alias: ["f"],
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "replace existing plugin version",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handler: async (args) => {
|
||||||
|
const mod = String(args.module ?? "").trim()
|
||||||
|
if (!mod) {
|
||||||
|
UI.error("module is required")
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UI.empty()
|
||||||
|
intro(`Install plugin ${mod}`)
|
||||||
|
|
||||||
|
const run = createPlugTask({
|
||||||
|
mod,
|
||||||
|
global: Boolean(args.global),
|
||||||
|
force: Boolean(args.force),
|
||||||
|
})
|
||||||
|
let ok = true
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: process.cwd(),
|
||||||
|
fn: async () => {
|
||||||
|
ok = await run({
|
||||||
|
vcs: Instance.project.vcs,
|
||||||
|
worktree: Instance.worktree,
|
||||||
|
directory: Instance.directory,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
outro("Done")
|
||||||
|
if (!ok) process.exitCode = 1
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
import { Selection } from "@tui/util/selection"
|
||||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
import {
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
Switch,
|
||||||
|
Match,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
ErrorBoundary,
|
||||||
|
createSignal,
|
||||||
|
onMount,
|
||||||
|
batch,
|
||||||
|
Show,
|
||||||
|
on,
|
||||||
|
onCleanup,
|
||||||
|
} from "solid-js"
|
||||||
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
|
import { ErrorComponent } from "@tui/component/error-component"
|
||||||
|
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||||
@@ -21,7 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
|
|||||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||||
import { KeybindProvider } from "@tui/context/keybind"
|
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||||
import { Home } from "@tui/routes/home"
|
import { Home } from "@tui/routes/home"
|
||||||
import { Session } from "@tui/routes/session"
|
import { Session } from "@tui/routes/session"
|
||||||
@@ -40,8 +55,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
// can't set raw mode if not a TTY
|
// can't set raw mode if not a TTY
|
||||||
@@ -104,7 +121,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { Installation } from "@/installation"
|
|
||||||
|
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||||
|
return {
|
||||||
|
targetFps: 60,
|
||||||
|
gatherStats: false,
|
||||||
|
exitOnCtrlC: false,
|
||||||
|
useKittyKeyboard: { events: process.platform === "win32" },
|
||||||
|
autoFocus: false,
|
||||||
|
openConsoleOnError: false,
|
||||||
|
consoleOptions: {
|
||||||
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||||
|
onCopySelection: (text) => {
|
||||||
|
Clipboard.copy(text).catch((error) => {
|
||||||
|
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
const formatted = FormatError(error)
|
||||||
|
if (formatted !== undefined) return formatted
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"data" in error &&
|
||||||
|
typeof error.data === "object" &&
|
||||||
|
error.data !== null &&
|
||||||
|
"message" in error.data &&
|
||||||
|
typeof error.data.message === "string"
|
||||||
|
) {
|
||||||
|
return error.data.message
|
||||||
|
}
|
||||||
|
return FormatUnknownError(error)
|
||||||
|
}
|
||||||
|
|
||||||
export function tui(input: {
|
export function tui(input: {
|
||||||
url: string
|
url: string
|
||||||
@@ -132,77 +184,68 @@ export function tui(input: {
|
|||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
const onBeforeExit = async () => {
|
||||||
() => {
|
await TuiPluginRuntime.dispose()
|
||||||
return (
|
}
|
||||||
<ErrorBoundary
|
|
||||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||||
>
|
|
||||||
<ArgsProvider {...input.args}>
|
await render(() => {
|
||||||
<ExitProvider onExit={onExit}>
|
return (
|
||||||
<KVProvider>
|
<ErrorBoundary
|
||||||
<ToastProvider>
|
fallback={(error, reset) => (
|
||||||
<RouteProvider>
|
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||||
<TuiConfigProvider config={input.config}>
|
)}
|
||||||
<SDKProvider
|
>
|
||||||
url={input.url}
|
<ArgsProvider {...input.args}>
|
||||||
directory={input.directory}
|
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||||
fetch={input.fetch}
|
<KVProvider>
|
||||||
headers={input.headers}
|
<ToastProvider>
|
||||||
events={input.events}
|
<RouteProvider>
|
||||||
>
|
<TuiConfigProvider config={input.config}>
|
||||||
<SyncProvider>
|
<SDKProvider
|
||||||
<ThemeProvider mode={mode}>
|
url={input.url}
|
||||||
<LocalProvider>
|
directory={input.directory}
|
||||||
<KeybindProvider>
|
fetch={input.fetch}
|
||||||
<PromptStashProvider>
|
headers={input.headers}
|
||||||
<DialogProvider>
|
events={input.events}
|
||||||
<CommandProvider>
|
>
|
||||||
<FrecencyProvider>
|
<SyncProvider>
|
||||||
<PromptHistoryProvider>
|
<ThemeProvider mode={mode}>
|
||||||
<PromptRefProvider>
|
<LocalProvider>
|
||||||
<App onSnapshot={input.onSnapshot} />
|
<KeybindProvider>
|
||||||
</PromptRefProvider>
|
<PromptStashProvider>
|
||||||
</PromptHistoryProvider>
|
<DialogProvider>
|
||||||
</FrecencyProvider>
|
<CommandProvider>
|
||||||
</CommandProvider>
|
<FrecencyProvider>
|
||||||
</DialogProvider>
|
<PromptHistoryProvider>
|
||||||
</PromptStashProvider>
|
<PromptRefProvider>
|
||||||
</KeybindProvider>
|
<App onSnapshot={input.onSnapshot} />
|
||||||
</LocalProvider>
|
</PromptRefProvider>
|
||||||
</ThemeProvider>
|
</PromptHistoryProvider>
|
||||||
</SyncProvider>
|
</FrecencyProvider>
|
||||||
</SDKProvider>
|
</CommandProvider>
|
||||||
</TuiConfigProvider>
|
</DialogProvider>
|
||||||
</RouteProvider>
|
</PromptStashProvider>
|
||||||
</ToastProvider>
|
</KeybindProvider>
|
||||||
</KVProvider>
|
</LocalProvider>
|
||||||
</ExitProvider>
|
</ThemeProvider>
|
||||||
</ArgsProvider>
|
</SyncProvider>
|
||||||
</ErrorBoundary>
|
</SDKProvider>
|
||||||
)
|
</TuiConfigProvider>
|
||||||
},
|
</RouteProvider>
|
||||||
{
|
</ToastProvider>
|
||||||
targetFps: 60,
|
</KVProvider>
|
||||||
gatherStats: false,
|
</ExitProvider>
|
||||||
exitOnCtrlC: false,
|
</ArgsProvider>
|
||||||
useKittyKeyboard: { events: process.platform === "win32" },
|
</ErrorBoundary>
|
||||||
autoFocus: false,
|
)
|
||||||
openConsoleOnError: false,
|
}, renderer)
|
||||||
consoleOptions: {
|
|
||||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
|
||||||
onCopySelection: (text) => {
|
|
||||||
Clipboard.copy(text).catch((error) => {
|
|
||||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
@@ -211,12 +254,47 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
|
const keybind = useKeybind()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
const themeState = useTheme()
|
||||||
|
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
|
const routes: RouteMap = new Map()
|
||||||
|
const [routeRev, setRouteRev] = createSignal(0)
|
||||||
|
const routeView = (name: string) => {
|
||||||
|
routeRev()
|
||||||
|
return routes.get(name)?.at(-1)?.render
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createTuiApi({
|
||||||
|
command,
|
||||||
|
tuiConfig,
|
||||||
|
dialog,
|
||||||
|
keybind,
|
||||||
|
kv,
|
||||||
|
route,
|
||||||
|
routes,
|
||||||
|
bump: () => setRouteRev((x) => x + 1),
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
theme: themeState,
|
||||||
|
toast,
|
||||||
|
renderer,
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
api.dispose()
|
||||||
|
})
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
TuiPluginRuntime.init(api)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load TUI plugins", error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
@@ -259,10 +337,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}
|
}
|
||||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(JSON.stringify(route.data))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update terminal window title based on current route and session
|
// Update terminal window title based on current route and session
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||||
@@ -279,9 +353,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate title to 40 chars max
|
|
||||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||||
renderer.setTerminalTitle(`OC | ${title}`)
|
renderer.setTerminalTitle(`OC | ${title}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.data.type === "plugin") {
|
||||||
|
renderer.setTerminalTitle(`OC | ${route.data.id}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -723,17 +801,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
sdk.event.on("session.error", (evt) => {
|
sdk.event.on("session.error", (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = (() => {
|
const message = errorMessage(error)
|
||||||
if (!error) return "An error occurred"
|
|
||||||
|
|
||||||
if (typeof error === "object") {
|
|
||||||
const data = error.data
|
|
||||||
if ("message" in data && typeof data.message === "string") {
|
|
||||||
return data.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(error)
|
|
||||||
})()
|
|
||||||
|
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -789,6 +857,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
exit()
|
exit()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const plugin = createMemo(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (route.data.type !== "plugin") return
|
||||||
|
const render = routeView(route.data.id)
|
||||||
|
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||||
|
return render({ params: route.data.data })
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
@@ -804,97 +880,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}}
|
}}
|
||||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||||
<Match when={route.data.type === "home"}>
|
<TimeToFirstDraw />
|
||||||
<Home />
|
</Show>
|
||||||
</Match>
|
<Show when={ready()}>
|
||||||
<Match when={route.data.type === "session"}>
|
<Switch>
|
||||||
<Session />
|
<Match when={route.data.type === "home"}>
|
||||||
</Match>
|
<Home />
|
||||||
</Switch>
|
</Match>
|
||||||
</box>
|
<Match when={route.data.type === "session"}>
|
||||||
)
|
<Session />
|
||||||
}
|
</Match>
|
||||||
|
</Switch>
|
||||||
function ErrorComponent(props: {
|
</Show>
|
||||||
error: Error
|
{plugin()}
|
||||||
reset: () => void
|
<TuiPluginRuntime.Slot name="app" />
|
||||||
onExit: () => Promise<void>
|
<StartupLoading ready={ready} />
|
||||||
mode?: "dark" | "light"
|
|
||||||
}) {
|
|
||||||
const term = useTerminalDimensions()
|
|
||||||
const renderer = useRenderer()
|
|
||||||
|
|
||||||
const handleExit = async () => {
|
|
||||||
renderer.setTerminalTitle("")
|
|
||||||
renderer.destroy()
|
|
||||||
win32FlushInputBuffer()
|
|
||||||
await props.onExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
|
||||||
if (evt.ctrl && evt.name === "c") {
|
|
||||||
handleExit()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const [copied, setCopied] = createSignal(false)
|
|
||||||
|
|
||||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
|
||||||
|
|
||||||
// Choose safe fallback colors per mode since theme context may not be available
|
|
||||||
const isLight = props.mode === "light"
|
|
||||||
const colors = {
|
|
||||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
|
||||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
|
||||||
muted: isLight ? "#8a8a8a" : "#808080",
|
|
||||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.error.message) {
|
|
||||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.error.stack) {
|
|
||||||
issueURL.searchParams.set(
|
|
||||||
"description",
|
|
||||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
|
||||||
|
|
||||||
const copyIssueURL = () => {
|
|
||||||
Clipboard.copy(issueURL.toString()).then(() => {
|
|
||||||
setCopied(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
|
||||||
Please report an issue.
|
|
||||||
</text>
|
|
||||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
|
||||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
|
||||||
Copy issue URL (exception info pre-filled)
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<text fg={colors.text}>A fatal error occurred!</text>
|
|
||||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
|
||||||
<text fg={colors.bg}>Reset TUI</text>
|
|
||||||
</box>
|
|
||||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
|
||||||
<text fg={colors.bg}>Exit</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
|
||||||
<text fg={colors.muted}>{props.error.stack}</text>
|
|
||||||
</scrollbox>
|
|
||||||
<text fg={colors.text}>{props.error.message}</text>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
|
getOwner,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
runWithOwner,
|
||||||
useContext,
|
useContext,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
|
|
||||||
type Context = ReturnType<typeof init>
|
type Context = ReturnType<typeof init>
|
||||||
const ctx = createContext<Context>()
|
const ctx = createContext<Context>()
|
||||||
@@ -21,7 +23,7 @@ export type Slash = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CommandOption = DialogSelectOption<string> & {
|
export type CommandOption = DialogSelectOption<string> & {
|
||||||
keybind?: KeybindKey
|
keybind?: string
|
||||||
suggested?: boolean
|
suggested?: boolean
|
||||||
slash?: Slash
|
slash?: Slash
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
const root = getOwner()
|
||||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -100,11 +103,32 @@ function init() {
|
|||||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||||
},
|
},
|
||||||
register(cb: () => CommandOption[]) {
|
register(cb: () => CommandOption[]) {
|
||||||
const results = createMemo(cb)
|
const owner = getOwner() ?? root
|
||||||
setRegistrations((arr) => [results, ...arr])
|
if (!owner) return () => {}
|
||||||
onCleanup(() => {
|
|
||||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
let list: Accessor<CommandOption[]> | undefined
|
||||||
|
|
||||||
|
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||||
|
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||||
|
runWithOwner(owner, () => {
|
||||||
|
list = createMemo(cb)
|
||||||
|
const ref = list
|
||||||
|
if (!ref) return
|
||||||
|
setRegistrations((arr) => [ref, ...arr])
|
||||||
|
onCleanup(() => {
|
||||||
|
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!list) return () => {}
|
||||||
|
let done = false
|
||||||
|
return () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
const ref = list
|
||||||
|
if (!ref) return
|
||||||
|
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export function DialogStatus() {
|
|||||||
|
|
||||||
const plugins = createMemo(() => {
|
const plugins = createMemo(() => {
|
||||||
const list = sync.data.config.plugin ?? []
|
const list = sync.data.config.plugin ?? []
|
||||||
const result = list.map((value) => {
|
const result = list.map((item) => {
|
||||||
|
const value = typeof item === "string" ? item : item[0]
|
||||||
if (value.startsWith("file://")) {
|
if (value.startsWith("file://")) {
|
||||||
const path = fileURLToPath(value)
|
const path = fileURLToPath(value)
|
||||||
const parts = path.split("/")
|
const parts = path.split("/")
|
||||||
|
|||||||
@@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
|||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
|
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
|
||||||
|
return createOpencodeClient({
|
||||||
|
baseUrl: sdk.url,
|
||||||
|
fetch: sdk.fetch,
|
||||||
|
directory: sync.data.path.directory || sdk.directory,
|
||||||
|
experimental_workspaceID: workspaceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function openWorkspace(input: {
|
async function openWorkspace(input: {
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
route: ReturnType<typeof useRoute>
|
route: ReturnType<typeof useRoute>
|
||||||
@@ -29,12 +37,7 @@ async function openWorkspace(input: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createOpencodeClient({
|
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||||
baseUrl: input.sdk.url,
|
|
||||||
fetch: input.sdk.fetch,
|
|
||||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
|
||||||
experimental_workspaceID: input.workspaceID,
|
|
||||||
})
|
|
||||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||||
const session = listed?.data?.[0]
|
const session = listed?.data?.[0]
|
||||||
if (session?.id) {
|
if (session?.id) {
|
||||||
@@ -187,12 +190,7 @@ export function DialogWorkspaceList() {
|
|||||||
await open(workspaceID)
|
await open(workspaceID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const client = createOpencodeClient({
|
const client = scoped(sdk, sync, workspaceID)
|
||||||
baseUrl: sdk.url,
|
|
||||||
fetch: sdk.fetch,
|
|
||||||
directory: sync.data.path.directory || sdk.directory,
|
|
||||||
experimental_workspaceID: workspaceID,
|
|
||||||
})
|
|
||||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||||
if (listed?.data?.length) {
|
if (listed?.data?.length) {
|
||||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||||
@@ -223,12 +221,7 @@ export function DialogWorkspaceList() {
|
|||||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
workspaces.map(async (workspace) => {
|
workspaces.map(async (workspace) => {
|
||||||
const client = createOpencodeClient({
|
const client = scoped(sdk, sync, workspace.id)
|
||||||
baseUrl: sdk.url,
|
|
||||||
fetch: sdk.fetch,
|
|
||||||
directory: sync.data.path.directory || sdk.directory,
|
|
||||||
experimental_workspaceID: workspace.id,
|
|
||||||
})
|
|
||||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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({
|
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||||
name: "Exit",
|
name: "Exit",
|
||||||
init: (input: { onExit?: () => Promise<void> }) => {
|
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
let message: string | undefined
|
let message: string | undefined
|
||||||
let task: Promise<void> | undefined
|
let task: Promise<void> | undefined
|
||||||
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||||||
(reason?: unknown) => {
|
(reason?: unknown) => {
|
||||||
if (task) return task
|
if (task) return task
|
||||||
task = (async () => {
|
task = (async () => {
|
||||||
|
await input.onBeforeExit?.()
|
||||||
// Reset window title before destroying renderer
|
// Reset window title before destroying renderer
|
||||||
renderer.setTerminalTitle("")
|
renderer.setTerminalTitle("")
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
|
|||||||
@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
return Keybind.fromParsedKey(evt, store.leader)
|
return Keybind.fromParsedKey(evt, store.leader)
|
||||||
},
|
},
|
||||||
match(key: KeybindKey, evt: ParsedKey) {
|
match(key: string, evt: ParsedKey) {
|
||||||
const keybind = keybinds()[key]
|
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||||
if (!keybind) return false
|
if (!list.length) return false
|
||||||
const parsed: Keybind.Info = result.parse(evt)
|
const parsed: Keybind.Info = result.parse(evt)
|
||||||
for (const key of keybind) {
|
for (const item of list) {
|
||||||
if (Keybind.match(key, parsed)) {
|
if (Keybind.match(item, parsed)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
print(key: KeybindKey) {
|
print(key: string) {
|
||||||
const first = keybinds()[key]?.at(0)
|
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||||
if (!first) return ""
|
if (!first) return ""
|
||||||
const result = Keybind.toString(first)
|
const text = Keybind.toString(first)
|
||||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
const lead = keybinds().leader?.[0]
|
||||||
|
if (!lead) return text
|
||||||
|
return text.replace("<leader>", Keybind.toString(lead))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
41
packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
Normal file
41
packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ParsedKey } from "@opentui/core"
|
||||||
|
|
||||||
|
export type PluginKeybindMap = Record<string, string>
|
||||||
|
|
||||||
|
type Base = {
|
||||||
|
match: (key: string, evt: ParsedKey) => boolean
|
||||||
|
print: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginKeybind = {
|
||||||
|
readonly all: PluginKeybindMap
|
||||||
|
get: (name: string) => string
|
||||||
|
match: (name: string, evt: ParsedKey) => boolean
|
||||||
|
print: (name: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const txt = (value: unknown) => {
|
||||||
|
if (typeof value !== "string") return
|
||||||
|
if (!value.trim()) return
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginKeybind(
|
||||||
|
base: Base,
|
||||||
|
defaults: PluginKeybindMap,
|
||||||
|
overrides?: Record<string, unknown>,
|
||||||
|
): PluginKeybind {
|
||||||
|
const all = Object.freeze(
|
||||||
|
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||||
|
)
|
||||||
|
const get = (name: string) => all[name] ?? name
|
||||||
|
|
||||||
|
return {
|
||||||
|
get all() {
|
||||||
|
return all
|
||||||
|
},
|
||||||
|
get,
|
||||||
|
match: (name, evt) => base.match(get(name), evt),
|
||||||
|
print: (name) => base.print(get(name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,13 @@ export type SessionRoute = {
|
|||||||
initialPrompt?: PromptInfo
|
initialPrompt?: PromptInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Route = HomeRoute | SessionRoute
|
export type PluginRoute = {
|
||||||
|
type: "plugin"
|
||||||
|
id: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||||
|
|
||||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||||
name: "Route",
|
name: "Route",
|
||||||
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
|||||||
return store
|
return store
|
||||||
},
|
},
|
||||||
navigate(route: Route) {
|
navigate(route: Route) {
|
||||||
console.log("navigate", route)
|
|
||||||
setStore(route)
|
setStore(route)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
get client() {
|
get client() {
|
||||||
return sdk
|
return sdk
|
||||||
},
|
},
|
||||||
|
get workspaceID() {
|
||||||
|
return workspaceID
|
||||||
|
},
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
event: emitter,
|
event: emitter,
|
||||||
fetch: props.fetch ?? fetch,
|
fetch: props.fetch ?? fetch,
|
||||||
|
|||||||
@@ -42,66 +42,13 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { useTuiConfig } from "./tui-config"
|
import { useTuiConfig } from "./tui-config"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||||
|
|
||||||
type ThemeColors = {
|
type Theme = TuiThemeCurrent & {
|
||||||
primary: RGBA
|
|
||||||
secondary: RGBA
|
|
||||||
accent: RGBA
|
|
||||||
error: RGBA
|
|
||||||
warning: RGBA
|
|
||||||
success: RGBA
|
|
||||||
info: RGBA
|
|
||||||
text: RGBA
|
|
||||||
textMuted: RGBA
|
|
||||||
selectedListItemText: RGBA
|
|
||||||
background: RGBA
|
|
||||||
backgroundPanel: RGBA
|
|
||||||
backgroundElement: RGBA
|
|
||||||
backgroundMenu: RGBA
|
|
||||||
border: RGBA
|
|
||||||
borderActive: RGBA
|
|
||||||
borderSubtle: RGBA
|
|
||||||
diffAdded: RGBA
|
|
||||||
diffRemoved: RGBA
|
|
||||||
diffContext: RGBA
|
|
||||||
diffHunkHeader: RGBA
|
|
||||||
diffHighlightAdded: RGBA
|
|
||||||
diffHighlightRemoved: RGBA
|
|
||||||
diffAddedBg: RGBA
|
|
||||||
diffRemovedBg: RGBA
|
|
||||||
diffContextBg: RGBA
|
|
||||||
diffLineNumber: RGBA
|
|
||||||
diffAddedLineNumberBg: RGBA
|
|
||||||
diffRemovedLineNumberBg: RGBA
|
|
||||||
markdownText: RGBA
|
|
||||||
markdownHeading: RGBA
|
|
||||||
markdownLink: RGBA
|
|
||||||
markdownLinkText: RGBA
|
|
||||||
markdownCode: RGBA
|
|
||||||
markdownBlockQuote: RGBA
|
|
||||||
markdownEmph: RGBA
|
|
||||||
markdownStrong: RGBA
|
|
||||||
markdownHorizontalRule: RGBA
|
|
||||||
markdownListItem: RGBA
|
|
||||||
markdownListEnumeration: RGBA
|
|
||||||
markdownImage: RGBA
|
|
||||||
markdownImageText: RGBA
|
|
||||||
markdownCodeBlock: RGBA
|
|
||||||
syntaxComment: RGBA
|
|
||||||
syntaxKeyword: RGBA
|
|
||||||
syntaxFunction: RGBA
|
|
||||||
syntaxVariable: RGBA
|
|
||||||
syntaxString: RGBA
|
|
||||||
syntaxNumber: RGBA
|
|
||||||
syntaxType: RGBA
|
|
||||||
syntaxOperator: RGBA
|
|
||||||
syntaxPunctuation: RGBA
|
|
||||||
}
|
|
||||||
|
|
||||||
type Theme = ThemeColors & {
|
|
||||||
_hasSelectedListItemText: boolean
|
_hasSelectedListItemText: boolean
|
||||||
thinkingOpacity: number
|
|
||||||
}
|
}
|
||||||
|
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||||
|
|
||||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||||
// If theme explicitly defines selectedListItemText, use it
|
// If theme explicitly defines selectedListItemText, use it
|
||||||
@@ -128,10 +75,10 @@ type Variant = {
|
|||||||
light: HexColor | RefName
|
light: HexColor | RefName
|
||||||
}
|
}
|
||||||
type ColorValue = HexColor | RefName | Variant | RGBA
|
type ColorValue = HexColor | RefName | Variant | RGBA
|
||||||
type ThemeJson = {
|
export type ThemeJson = {
|
||||||
$schema?: string
|
$schema?: string
|
||||||
defs?: Record<string, HexColor | RefName>
|
defs?: Record<string, HexColor | RefName>
|
||||||
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||||
selectedListItemText?: ColorValue
|
selectedListItemText?: ColorValue
|
||||||
backgroundMenu?: ColorValue
|
backgroundMenu?: ColorValue
|
||||||
thinkingOpacity?: number
|
thinkingOpacity?: number
|
||||||
@@ -174,27 +121,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
|||||||
carbonfox,
|
carbonfox,
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
type State = {
|
||||||
|
themes: Record<string, ThemeJson>
|
||||||
|
mode: "dark" | "light"
|
||||||
|
lock: "dark" | "light" | undefined
|
||||||
|
active: string
|
||||||
|
ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginThemes: Record<string, ThemeJson> = {}
|
||||||
|
let customThemes: Record<string, ThemeJson> = {}
|
||||||
|
let systemTheme: ThemeJson | undefined
|
||||||
|
|
||||||
|
function listThemes() {
|
||||||
|
// Priority: defaults < plugin installs < custom files < generated system.
|
||||||
|
const themes = {
|
||||||
|
...DEFAULT_THEMES,
|
||||||
|
...pluginThemes,
|
||||||
|
...customThemes,
|
||||||
|
}
|
||||||
|
if (!systemTheme) return themes
|
||||||
|
return {
|
||||||
|
...themes,
|
||||||
|
system: systemTheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncThemes() {
|
||||||
|
setStore("themes", listThemes())
|
||||||
|
}
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<State>({
|
||||||
|
themes: listThemes(),
|
||||||
|
mode: "dark",
|
||||||
|
lock: undefined,
|
||||||
|
active: "opencode",
|
||||||
|
ready: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function allThemes() {
|
||||||
|
return store.themes
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTheme(theme: unknown): theme is ThemeJson {
|
||||||
|
if (!isRecord(theme)) return false
|
||||||
|
if (!isRecord(theme.theme)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTheme(name: string) {
|
||||||
|
if (!name) return false
|
||||||
|
return allThemes()[name] !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTheme(name: string, theme: unknown) {
|
||||||
|
if (!name) return false
|
||||||
|
if (!isTheme(theme)) return false
|
||||||
|
if (hasTheme(name)) return false
|
||||||
|
pluginThemes[name] = theme
|
||||||
|
syncThemes()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||||
const defs = theme.defs ?? {}
|
const defs = theme.defs ?? {}
|
||||||
function resolveColor(c: ColorValue): RGBA {
|
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||||
if (c instanceof RGBA) return c
|
if (c instanceof RGBA) return c
|
||||||
if (typeof c === "string") {
|
if (typeof c === "string") {
|
||||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||||
|
|
||||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||||
|
|
||||||
if (defs[c] != null) {
|
if (chain.includes(c)) {
|
||||||
return resolveColor(defs[c])
|
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||||
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
|
}
|
||||||
return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
|
||||||
} else {
|
const next = defs[c] ?? theme.theme[c as ThemeColor]
|
||||||
|
if (next === undefined) {
|
||||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||||
}
|
}
|
||||||
|
return resolveColor(next, [...chain, c])
|
||||||
}
|
}
|
||||||
if (typeof c === "number") {
|
if (typeof c === "number") {
|
||||||
return ansiToRgba(c)
|
return ansiToRgba(c)
|
||||||
}
|
}
|
||||||
return resolveColor(c[mode])
|
return resolveColor(c[mode], chain)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = Object.fromEntries(
|
const resolved = Object.fromEntries(
|
||||||
@@ -203,7 +214,7 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
|||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
return [key, resolveColor(value as ColorValue)]
|
return [key, resolveColor(value as ColorValue)]
|
||||||
}),
|
}),
|
||||||
) as Partial<ThemeColors>
|
) as Partial<Record<ThemeColor, RGBA>>
|
||||||
|
|
||||||
// Handle selectedListItemText separately since it's optional
|
// Handle selectedListItemText separately since it's optional
|
||||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||||
@@ -287,14 +298,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
if (value === "dark" || value === "light") return value
|
if (value === "dark" || value === "light") return value
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lock = pick(kv.get("theme_mode_lock"))
|
|
||||||
const [store, setStore] = createStore({
|
setStore(
|
||||||
themes: DEFAULT_THEMES,
|
produce((draft) => {
|
||||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
const lock = pick(kv.get("theme_mode_lock"))
|
||||||
lock,
|
const mode = pick(kv.get("theme_mode", props.mode))
|
||||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
draft.mode = lock ?? mode ?? props.mode
|
||||||
ready: false,
|
draft.lock = lock
|
||||||
})
|
const active = config.theme ?? kv.get("theme", "opencode")
|
||||||
|
draft.active = typeof active === "string" ? active : "opencode"
|
||||||
|
draft.ready = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const theme = config.theme
|
const theme = config.theme
|
||||||
@@ -302,52 +317,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme(store.mode)
|
Promise.allSettled([
|
||||||
getCustomThemes()
|
resolveSystemTheme(store.mode),
|
||||||
.then((custom) => {
|
getCustomThemes()
|
||||||
setStore(
|
.then((custom) => {
|
||||||
produce((draft) => {
|
customThemes = custom
|
||||||
Object.assign(draft.themes, custom)
|
syncThemes()
|
||||||
}),
|
})
|
||||||
)
|
.catch(() => {
|
||||||
})
|
setStore("active", "opencode")
|
||||||
.catch(() => {
|
}),
|
||||||
setStore("active", "opencode")
|
]).finally(() => {
|
||||||
})
|
setStore("ready", true)
|
||||||
.finally(() => {
|
})
|
||||||
if (store.active !== "system") {
|
|
||||||
setStore("ready", true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(init)
|
onMount(init)
|
||||||
|
|
||||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||||
renderer
|
return renderer
|
||||||
.getPalette({
|
.getPalette({
|
||||||
size: 16,
|
size: 16,
|
||||||
})
|
})
|
||||||
.then((colors) => {
|
.then((colors: TerminalColors) => {
|
||||||
if (!colors.palette[0]) {
|
if (!colors.palette[0]) {
|
||||||
|
systemTheme = undefined
|
||||||
|
syncThemes()
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
setStore(
|
setStore("active", "opencode")
|
||||||
produce((draft) => {
|
|
||||||
draft.active = "opencode"
|
|
||||||
draft.ready = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore(
|
systemTheme = generateSystem(colors, mode)
|
||||||
produce((draft) => {
|
syncThemes()
|
||||||
draft.themes.system = generateSystem(colors, mode)
|
})
|
||||||
if (store.active === "system") {
|
.catch(() => {
|
||||||
draft.ready = true
|
systemTheme = undefined
|
||||||
}
|
syncThemes()
|
||||||
}),
|
if (store.active === "system") {
|
||||||
)
|
setStore("active", "opencode")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +386,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
apply(mode)
|
apply(mode)
|
||||||
}
|
}
|
||||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
renderer.clearPaletteCache()
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
process.on("SIGUSR2", refresh)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||||
|
process.off("SIGUSR2", refresh)
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = createMemo(() => {
|
const values = createMemo(() => {
|
||||||
@@ -403,7 +420,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
return store.active
|
return store.active
|
||||||
},
|
},
|
||||||
all() {
|
all() {
|
||||||
return store.themes
|
return allThemes()
|
||||||
|
},
|
||||||
|
has(name: string) {
|
||||||
|
return hasTheme(name)
|
||||||
},
|
},
|
||||||
syntax,
|
syntax,
|
||||||
subtleSyntax,
|
subtleSyntax,
|
||||||
@@ -423,8 +443,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
pin(mode)
|
pin(mode)
|
||||||
},
|
},
|
||||||
set(theme: string) {
|
set(theme: string) {
|
||||||
|
if (!hasTheme(theme)) return false
|
||||||
setStore("active", theme)
|
setStore("active", theme)
|
||||||
kv.set("theme", theme)
|
kv.set("theme", theme)
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return store.ready
|
return store.ready
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, createSignal, For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||||
|
|
||||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
406
packages/opencode/src/cli/cmd/tui/plugin/api.tsx
Normal file
406
packages/opencode/src/cli/cmd/tui/plugin/api.tsx
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import type { ParsedKey } from "@opentui/core"
|
||||||
|
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
|
import type { useKeybind } from "@tui/context/keybind"
|
||||||
|
import type { useRoute } from "@tui/context/route"
|
||||||
|
import type { useSDK } from "@tui/context/sdk"
|
||||||
|
import type { useSync } from "@tui/context/sync"
|
||||||
|
import type { useTheme } from "@tui/context/theme"
|
||||||
|
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||||
|
import type { TuiConfig } from "@/config/tui"
|
||||||
|
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||||
|
import type { useKV } from "../context/kv"
|
||||||
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
|
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||||
|
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||||
|
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||||
|
import type { useToast } from "../ui/toast"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
type RouteEntry = {
|
||||||
|
key: symbol
|
||||||
|
render: TuiRouteDefinition["render"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteMap = Map<string, RouteEntry[]>
|
||||||
|
|
||||||
|
type Input = {
|
||||||
|
command: ReturnType<typeof useCommandDialog>
|
||||||
|
tuiConfig: TuiConfig.Info
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
keybind: ReturnType<typeof useKeybind>
|
||||||
|
kv: ReturnType<typeof useKV>
|
||||||
|
route: ReturnType<typeof useRoute>
|
||||||
|
routes: RouteMap
|
||||||
|
bump: () => void
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
theme: ReturnType<typeof useTheme>
|
||||||
|
toast: ReturnType<typeof useToast>
|
||||||
|
renderer: TuiPluginApi["renderer"]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TuiHostPluginApi = TuiPluginApi & {
|
||||||
|
map: Map<string | undefined, OpencodeClient>
|
||||||
|
dispose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
|
||||||
|
const key = Symbol()
|
||||||
|
for (const item of list) {
|
||||||
|
const prev = routes.get(item.name) ?? []
|
||||||
|
prev.push({ key, render: item.render })
|
||||||
|
routes.set(item.name, prev)
|
||||||
|
}
|
||||||
|
bump()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const item of list) {
|
||||||
|
const prev = routes.get(item.name)
|
||||||
|
if (!prev) continue
|
||||||
|
const next = prev.filter((x) => x.key !== key)
|
||||||
|
if (!next.length) {
|
||||||
|
routes.delete(item.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
routes.set(item.name, next)
|
||||||
|
}
|
||||||
|
bump()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
|
||||||
|
if (name === "home") {
|
||||||
|
route.navigate({ type: "home" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "session") {
|
||||||
|
const sessionID = params?.sessionID
|
||||||
|
if (typeof sessionID !== "string") return
|
||||||
|
route.navigate({ type: "session", sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
route.navigate({ type: "plugin", id: name, data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
|
||||||
|
if (route.data.type === "home") return { name: "home" }
|
||||||
|
if (route.data.type === "session") {
|
||||||
|
return {
|
||||||
|
name: "session",
|
||||||
|
params: {
|
||||||
|
sessionID: route.data.sessionID,
|
||||||
|
initialPrompt: route.data.initialPrompt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: route.data.id,
|
||||||
|
params: route.data.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
onSelect: () => item.onSelect?.(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
|
||||||
|
return {
|
||||||
|
title: item.title,
|
||||||
|
value: item.value,
|
||||||
|
description: item.description,
|
||||||
|
footer: item.footer,
|
||||||
|
category: item.category,
|
||||||
|
disabled: item.disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||||
|
if (!cb) return
|
||||||
|
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||||
|
return {
|
||||||
|
get ready() {
|
||||||
|
return sync.ready
|
||||||
|
},
|
||||||
|
get config() {
|
||||||
|
return sync.data.config
|
||||||
|
},
|
||||||
|
get provider() {
|
||||||
|
return sync.data.provider
|
||||||
|
},
|
||||||
|
get path() {
|
||||||
|
return sync.data.path
|
||||||
|
},
|
||||||
|
get vcs() {
|
||||||
|
if (!sync.data.vcs) return
|
||||||
|
return {
|
||||||
|
branch: sync.data.vcs.branch,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
list() {
|
||||||
|
return sync.data.workspaceList
|
||||||
|
},
|
||||||
|
get(workspaceID) {
|
||||||
|
return sync.workspace.get(workspaceID)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
count() {
|
||||||
|
return sync.data.session.length
|
||||||
|
},
|
||||||
|
diff(sessionID) {
|
||||||
|
return sync.data.session_diff[sessionID] ?? []
|
||||||
|
},
|
||||||
|
todo(sessionID) {
|
||||||
|
return sync.data.todo[sessionID] ?? []
|
||||||
|
},
|
||||||
|
messages(sessionID) {
|
||||||
|
return sync.data.message[sessionID] ?? []
|
||||||
|
},
|
||||||
|
status(sessionID) {
|
||||||
|
return sync.data.session_status[sessionID]
|
||||||
|
},
|
||||||
|
permission(sessionID) {
|
||||||
|
return sync.data.permission[sessionID] ?? []
|
||||||
|
},
|
||||||
|
question(sessionID) {
|
||||||
|
return sync.data.question[sessionID] ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
part(messageID) {
|
||||||
|
return sync.data.part[messageID] ?? []
|
||||||
|
},
|
||||||
|
lsp() {
|
||||||
|
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||||
|
},
|
||||||
|
mcp() {
|
||||||
|
return Object.entries(sync.data.mcp)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, item]) => ({
|
||||||
|
name,
|
||||||
|
status: item.status,
|
||||||
|
error: item.status === "failed" ? item.error : undefined,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appApi(): TuiPluginApi["app"] {
|
||||||
|
return {
|
||||||
|
get version() {
|
||||||
|
return Installation.VERSION
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTuiApi(input: Input): TuiHostPluginApi {
|
||||||
|
const map = new Map<string | undefined, OpencodeClient>()
|
||||||
|
const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
|
||||||
|
const hit = map.get(workspaceID)
|
||||||
|
if (hit) return hit
|
||||||
|
|
||||||
|
const next = createOpencodeClient({
|
||||||
|
baseUrl: input.sdk.url,
|
||||||
|
fetch: input.sdk.fetch,
|
||||||
|
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||||
|
experimental_workspaceID: workspaceID,
|
||||||
|
})
|
||||||
|
map.set(workspaceID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
const workspace: TuiPluginApi["workspace"] = {
|
||||||
|
current() {
|
||||||
|
return input.sdk.workspaceID
|
||||||
|
},
|
||||||
|
set(workspaceID) {
|
||||||
|
input.sdk.setWorkspace(workspaceID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
onDispose() {
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: appApi(),
|
||||||
|
command: {
|
||||||
|
register(cb) {
|
||||||
|
return input.command.register(() => cb())
|
||||||
|
},
|
||||||
|
trigger(value) {
|
||||||
|
input.command.trigger(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
register(list) {
|
||||||
|
return routeRegister(input.routes, list, input.bump)
|
||||||
|
},
|
||||||
|
navigate(name, params) {
|
||||||
|
routeNavigate(input.route, name, params)
|
||||||
|
},
|
||||||
|
get current() {
|
||||||
|
return routeCurrent(input.route)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
Dialog(props) {
|
||||||
|
return (
|
||||||
|
<DialogUI size={props.size} onClose={props.onClose}>
|
||||||
|
{props.children}
|
||||||
|
</DialogUI>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DialogAlert(props) {
|
||||||
|
return <DialogAlert {...props} />
|
||||||
|
},
|
||||||
|
DialogConfirm(props) {
|
||||||
|
return <DialogConfirm {...props} />
|
||||||
|
},
|
||||||
|
DialogPrompt(props) {
|
||||||
|
return <DialogPrompt {...props} description={props.description} />
|
||||||
|
},
|
||||||
|
DialogSelect(props) {
|
||||||
|
return (
|
||||||
|
<DialogSelect
|
||||||
|
title={props.title}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
options={props.options.map(mapOption)}
|
||||||
|
flat={props.flat}
|
||||||
|
onMove={mapOptionCb(props.onMove)}
|
||||||
|
onFilter={props.onFilter}
|
||||||
|
onSelect={mapOptionCb(props.onSelect)}
|
||||||
|
skipFilter={props.skipFilter}
|
||||||
|
current={props.current}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
toast(inputToast) {
|
||||||
|
input.toast.show({
|
||||||
|
title: inputToast.title,
|
||||||
|
message: inputToast.message,
|
||||||
|
variant: inputToast.variant ?? "info",
|
||||||
|
duration: inputToast.duration,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
replace(render, onClose) {
|
||||||
|
input.dialog.replace(render, onClose)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
input.dialog.clear()
|
||||||
|
},
|
||||||
|
setSize(size) {
|
||||||
|
input.dialog.setSize(size)
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return input.dialog.size
|
||||||
|
},
|
||||||
|
get depth() {
|
||||||
|
return input.dialog.stack.length
|
||||||
|
},
|
||||||
|
get open() {
|
||||||
|
return input.dialog.stack.length > 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keybind: {
|
||||||
|
match(key, evt: ParsedKey) {
|
||||||
|
return input.keybind.match(key, evt)
|
||||||
|
},
|
||||||
|
print(key) {
|
||||||
|
return input.keybind.print(key)
|
||||||
|
},
|
||||||
|
create(defaults, overrides) {
|
||||||
|
return createPluginKeybind(input.keybind, defaults, overrides)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
get tuiConfig() {
|
||||||
|
return input.tuiConfig
|
||||||
|
},
|
||||||
|
kv: {
|
||||||
|
get(key, fallback) {
|
||||||
|
return input.kv.get(key, fallback)
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
input.kv.set(key, value)
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return input.kv.ready
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: stateApi(input.sync),
|
||||||
|
get client() {
|
||||||
|
return input.sdk.client
|
||||||
|
},
|
||||||
|
scopedClient: scoped,
|
||||||
|
workspace,
|
||||||
|
event: input.sdk.event,
|
||||||
|
renderer: input.renderer,
|
||||||
|
slots: {
|
||||||
|
register() {
|
||||||
|
throw new Error("slots.register is only available in plugin context")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
list() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async activate() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async deactivate() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async add() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async install() {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "plugins.install is only available in plugin context",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lifecycle,
|
||||||
|
theme: {
|
||||||
|
get current() {
|
||||||
|
return input.theme.theme
|
||||||
|
},
|
||||||
|
get selected() {
|
||||||
|
return input.theme.selected
|
||||||
|
},
|
||||||
|
has(name) {
|
||||||
|
return input.theme.has(name)
|
||||||
|
},
|
||||||
|
set(name) {
|
||||||
|
return input.theme.set(name)
|
||||||
|
},
|
||||||
|
async install(_jsonPath) {
|
||||||
|
throw new Error("theme.install is only available in plugin context")
|
||||||
|
},
|
||||||
|
mode() {
|
||||||
|
return input.theme.mode()
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return input.theme.ready
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map,
|
||||||
|
dispose() {
|
||||||
|
map.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/opencode/src/cli/cmd/tui/plugin/index.ts
Normal file
3
packages/opencode/src/cli/cmd/tui/plugin/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { TuiPluginRuntime } from "./runtime"
|
||||||
|
export { createTuiApi } from "./api"
|
||||||
|
export type { RouteMap } from "./api"
|
||||||
25
packages/opencode/src/cli/cmd/tui/plugin/internal.ts
Normal file
25
packages/opencode/src/cli/cmd/tui/plugin/internal.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import HomeTips from "../feature-plugins/home/tips"
|
||||||
|
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||||
|
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||||
|
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||||
|
import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||||
|
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||||
|
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||||
|
import PluginManager from "../feature-plugins/system/plugins"
|
||||||
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||||
|
|
||||||
|
export type InternalTuiPlugin = TuiPluginModule & {
|
||||||
|
id: string
|
||||||
|
tui: TuiPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||||
|
HomeTips,
|
||||||
|
SidebarContext,
|
||||||
|
SidebarMcp,
|
||||||
|
SidebarLsp,
|
||||||
|
SidebarTodo,
|
||||||
|
SidebarFiles,
|
||||||
|
SidebarFooter,
|
||||||
|
PluginManager,
|
||||||
|
]
|
||||||
972
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
Normal file
972
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
Normal file
@@ -0,0 +1,972 @@
|
|||||||
|
import "@opentui/solid/runtime-plugin-support"
|
||||||
|
import {
|
||||||
|
type TuiDispose,
|
||||||
|
type TuiPlugin,
|
||||||
|
type TuiPluginApi,
|
||||||
|
type TuiPluginInstallResult,
|
||||||
|
type TuiPluginModule,
|
||||||
|
type TuiPluginMeta,
|
||||||
|
type TuiPluginStatus,
|
||||||
|
type TuiTheme,
|
||||||
|
} from "@opencode-ai/plugin/tui"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import {
|
||||||
|
checkPluginCompatibility,
|
||||||
|
getDefaultPlugin,
|
||||||
|
isDeprecatedPlugin,
|
||||||
|
pluginSource,
|
||||||
|
readPluginId,
|
||||||
|
resolvePluginEntrypoint,
|
||||||
|
resolvePluginId,
|
||||||
|
resolvePluginTarget,
|
||||||
|
type PluginSource,
|
||||||
|
} from "@/plugin/shared"
|
||||||
|
import { PluginMeta } from "@/plugin/meta"
|
||||||
|
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||||
|
import { addTheme, hasTheme } from "../context/theme"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
|
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||||
|
import { setupSlots, Slot as View } from "./slots"
|
||||||
|
import type { HostPluginApi, HostSlots } from "./slots"
|
||||||
|
|
||||||
|
type PluginLoad = {
|
||||||
|
item?: Config.PluginSpec
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
retry: boolean
|
||||||
|
source: PluginSource | "internal"
|
||||||
|
id: string
|
||||||
|
module: TuiPluginModule
|
||||||
|
install_theme: TuiTheme["install"]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Api = HostPluginApi
|
||||||
|
|
||||||
|
type PluginScope = {
|
||||||
|
lifecycle: TuiPluginApi["lifecycle"]
|
||||||
|
track: (fn: (() => void) | undefined) => () => void
|
||||||
|
dispose: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginEntry = {
|
||||||
|
id: string
|
||||||
|
load: PluginLoad
|
||||||
|
meta: TuiPluginMeta
|
||||||
|
plugin: TuiPlugin
|
||||||
|
options: Config.PluginOptions | undefined
|
||||||
|
enabled: boolean
|
||||||
|
scope?: PluginScope
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeState = {
|
||||||
|
directory: string
|
||||||
|
api: Api
|
||||||
|
slots: HostSlots
|
||||||
|
plugins: PluginEntry[]
|
||||||
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
|
pending: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
item: Config.PluginSpec
|
||||||
|
meta: TuiConfig.PluginMeta
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = Log.create({ service: "tui.plugin" })
|
||||||
|
const DISPOSE_TIMEOUT_MS = 5000
|
||||||
|
const KV_KEY = "plugin_enabled"
|
||||||
|
|
||||||
|
function fail(message: string, data: Record<string, unknown>) {
|
||||||
|
if (!("error" in data)) {
|
||||||
|
log.error(message, data)
|
||||||
|
console.error(`[tui.plugin] ${message}`, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `${message}: ${errorMessage(data.error)}`
|
||||||
|
const next = { ...data, error: errorData(data.error) }
|
||||||
|
log.error(text, next)
|
||||||
|
console.error(`[tui.plugin] ${text}`, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||||
|
|
||||||
|
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve({ type: "timeout" })
|
||||||
|
}, ms)
|
||||||
|
|
||||||
|
Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
resolve({ type: "ok" })
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
resolve({ type: "error", error })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTheme(value: unknown) {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
if (!("theme" in value)) return false
|
||||||
|
if (!isRecord(value.theme)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoot(root: string) {
|
||||||
|
if (root.startsWith("file://")) {
|
||||||
|
const file = fileURLToPath(root)
|
||||||
|
if (root.endsWith("/")) return file
|
||||||
|
return path.dirname(file)
|
||||||
|
}
|
||||||
|
if (path.isAbsolute(root)) return root
|
||||||
|
return path.resolve(process.cwd(), root)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||||
|
return async (file) => {
|
||||||
|
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||||
|
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||||
|
const theme = path.basename(src, path.extname(src))
|
||||||
|
if (hasTheme(theme)) return
|
||||||
|
|
||||||
|
const text = await Filesystem.readText(src).catch((error) => {
|
||||||
|
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (text === undefined) return
|
||||||
|
|
||||||
|
const fail = Symbol()
|
||||||
|
const data = await Promise.resolve(text)
|
||||||
|
.then((x) => JSON.parse(x))
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
||||||
|
return fail
|
||||||
|
})
|
||||||
|
if (data === fail) return
|
||||||
|
|
||||||
|
if (!isTheme(data)) {
|
||||||
|
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const source_dir = path.dirname(meta.source)
|
||||||
|
const local_dir =
|
||||||
|
path.basename(source_dir) === ".opencode"
|
||||||
|
? path.join(source_dir, "themes")
|
||||||
|
: path.join(source_dir, ".opencode", "themes")
|
||||||
|
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||||
|
const dest = path.join(dest_dir, `${theme}.json`)
|
||||||
|
if (!(await Filesystem.exists(dest))) {
|
||||||
|
await Filesystem.write(dest, text).catch((error) => {
|
||||||
|
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addTheme(theme, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExternalPlugin(
|
||||||
|
item: Config.PluginSpec,
|
||||||
|
meta: TuiConfig.PluginMeta | undefined,
|
||||||
|
retry = false,
|
||||||
|
): Promise<PluginLoad | undefined> {
|
||||||
|
const spec = Config.pluginSpecifier(item)
|
||||||
|
if (isDeprecatedPlugin(spec)) return
|
||||||
|
log.info("loading tui plugin", { path: spec, retry })
|
||||||
|
const resolved = await resolvePluginTarget(spec).catch((error) => {
|
||||||
|
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!resolved) return
|
||||||
|
|
||||||
|
const source = pluginSource(spec)
|
||||||
|
if (source === "npm") {
|
||||||
|
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
fail("tui plugin incompatible", { path: spec, retry, error })
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolved
|
||||||
|
if (!meta) {
|
||||||
|
fail("missing tui plugin metadata", {
|
||||||
|
path: spec,
|
||||||
|
retry,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveRoot(source === "file" ? spec : target)
|
||||||
|
const install_theme = createThemeInstaller(meta, root, spec)
|
||||||
|
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
|
||||||
|
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const mod = await import(entry)
|
||||||
|
.then((raw) => {
|
||||||
|
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
|
||||||
|
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
||||||
|
return mod
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!mod) return
|
||||||
|
|
||||||
|
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
|
||||||
|
fail("failed to load tui plugin", { path: spec, target, retry, error })
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
spec,
|
||||||
|
target,
|
||||||
|
retry,
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
module: mod,
|
||||||
|
install_theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMeta(
|
||||||
|
source: PluginLoad["source"],
|
||||||
|
spec: string,
|
||||||
|
target: string,
|
||||||
|
meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
|
||||||
|
id?: string,
|
||||||
|
): TuiPluginMeta {
|
||||||
|
if (meta) {
|
||||||
|
return {
|
||||||
|
state: meta.state,
|
||||||
|
...meta.entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
state: source === "internal" ? "same" : "first",
|
||||||
|
id: id ?? spec,
|
||||||
|
source,
|
||||||
|
spec,
|
||||||
|
target,
|
||||||
|
first_time: now,
|
||||||
|
last_time: now,
|
||||||
|
time_changed: now,
|
||||||
|
load_count: 1,
|
||||||
|
fingerprint: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||||
|
const spec = item.id
|
||||||
|
const target = spec
|
||||||
|
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
target,
|
||||||
|
retry: false,
|
||||||
|
source: "internal",
|
||||||
|
id: item.id,
|
||||||
|
module: item,
|
||||||
|
install_theme: createThemeInstaller(
|
||||||
|
{
|
||||||
|
scope: "global",
|
||||||
|
source: target,
|
||||||
|
},
|
||||||
|
process.cwd(),
|
||||||
|
spec,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginScope(load: PluginLoad, id: string) {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
const onDispose = (fn: TuiDispose) => {
|
||||||
|
if (done) return () => {}
|
||||||
|
const key = Symbol()
|
||||||
|
list.push({ key, fn })
|
||||||
|
let drop = false
|
||||||
|
return () => {
|
||||||
|
if (drop) return
|
||||||
|
drop = true
|
||||||
|
list = list.filter((x) => x.key !== key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = (fn: (() => void) | undefined) => {
|
||||||
|
if (!fn) return () => {}
|
||||||
|
const off = onDispose(fn)
|
||||||
|
let drop = false
|
||||||
|
return () => {
|
||||||
|
if (drop) return
|
||||||
|
drop = true
|
||||||
|
off()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
onDispose,
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispose = async () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
ctrl.abort()
|
||||||
|
const queue = [...list].reverse()
|
||||||
|
list = []
|
||||||
|
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
||||||
|
for (const item of queue) {
|
||||||
|
const left = until - Date.now()
|
||||||
|
if (left <= 0) {
|
||||||
|
fail("timed out cleaning up tui plugin", {
|
||||||
|
path: load.spec,
|
||||||
|
id,
|
||||||
|
timeout: DISPOSE_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await runCleanup(item.fn, left)
|
||||||
|
if (out.type === "ok") continue
|
||||||
|
if (out.type === "timeout") {
|
||||||
|
fail("timed out cleaning up tui plugin", {
|
||||||
|
path: load.spec,
|
||||||
|
id,
|
||||||
|
timeout: DISPOSE_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.type === "error") {
|
||||||
|
fail("failed to clean up tui plugin", {
|
||||||
|
path: load.spec,
|
||||||
|
id,
|
||||||
|
error: out.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lifecycle,
|
||||||
|
track,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPluginEnabledMap(value: unknown) {
|
||||||
|
if (!isRecord(value)) return {}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||||
|
return {
|
||||||
|
...readPluginEnabledMap(config.plugin_enabled),
|
||||||
|
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
|
||||||
|
api.kv.set(KV_KEY, {
|
||||||
|
...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
|
||||||
|
[id]: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
|
||||||
|
return state.plugins.map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
source: plugin.meta.source,
|
||||||
|
spec: plugin.meta.spec,
|
||||||
|
target: plugin.meta.target,
|
||||||
|
enabled: plugin.enabled,
|
||||||
|
active: plugin.scope !== undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||||
|
plugin.enabled = false
|
||||||
|
if (persist) writePluginEnabledState(state.api, plugin.id, false)
|
||||||
|
if (!plugin.scope) return true
|
||||||
|
const scope = plugin.scope
|
||||||
|
plugin.scope = undefined
|
||||||
|
await scope.dispose()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||||
|
plugin.enabled = true
|
||||||
|
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
||||||
|
if (plugin.scope) return true
|
||||||
|
|
||||||
|
const scope = createPluginScope(plugin.load, plugin.id)
|
||||||
|
const api = pluginApi(state, plugin.load, scope, plugin.id)
|
||||||
|
const ok = await Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
await plugin.plugin(api, plugin.options, plugin.meta)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
fail("failed to initialize tui plugin", {
|
||||||
|
path: plugin.load.spec,
|
||||||
|
id: plugin.id,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
await scope.dispose()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin.enabled) {
|
||||||
|
await scope.dispose()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.scope = scope
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||||
|
if (!state) return false
|
||||||
|
const plugin = state.plugins_by_id.get(id)
|
||||||
|
if (!plugin) return false
|
||||||
|
return activatePluginEntry(state, plugin, persist)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||||
|
if (!state) return false
|
||||||
|
const plugin = state.plugins_by_id.get(id)
|
||||||
|
if (!plugin) return false
|
||||||
|
return deactivatePluginEntry(state, plugin, persist)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
|
||||||
|
const api = runtime.api
|
||||||
|
const host = runtime.slots
|
||||||
|
const command: TuiPluginApi["command"] = {
|
||||||
|
register(cb) {
|
||||||
|
return scope.track(api.command.register(cb))
|
||||||
|
},
|
||||||
|
trigger(value) {
|
||||||
|
api.command.trigger(value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const route: TuiPluginApi["route"] = {
|
||||||
|
register(list) {
|
||||||
|
return scope.track(api.route.register(list))
|
||||||
|
},
|
||||||
|
navigate(name, params) {
|
||||||
|
api.route.navigate(name, params)
|
||||||
|
},
|
||||||
|
get current() {
|
||||||
|
return api.route.current
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||||
|
install: load.install_theme,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event: TuiPluginApi["event"] = {
|
||||||
|
on(type, handler) {
|
||||||
|
return scope.track(api.event.on(type, handler))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const slots: TuiPluginApi["slots"] = {
|
||||||
|
register(plugin) {
|
||||||
|
const id = count ? `${base}:${count}` : base
|
||||||
|
count += 1
|
||||||
|
scope.track(host.register({ ...plugin, id }))
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: api.app,
|
||||||
|
command,
|
||||||
|
route,
|
||||||
|
ui: api.ui,
|
||||||
|
keybind: api.keybind,
|
||||||
|
tuiConfig: api.tuiConfig,
|
||||||
|
kv: api.kv,
|
||||||
|
state: api.state,
|
||||||
|
theme,
|
||||||
|
get client() {
|
||||||
|
return api.client
|
||||||
|
},
|
||||||
|
scopedClient: api.scopedClient,
|
||||||
|
workspace: api.workspace,
|
||||||
|
event,
|
||||||
|
renderer: api.renderer,
|
||||||
|
slots,
|
||||||
|
plugins: {
|
||||||
|
list() {
|
||||||
|
return listPluginStatus(runtime)
|
||||||
|
},
|
||||||
|
activate(id) {
|
||||||
|
return activatePluginById(runtime, id, true)
|
||||||
|
},
|
||||||
|
deactivate(id) {
|
||||||
|
return deactivatePluginById(runtime, id, true)
|
||||||
|
},
|
||||||
|
add(spec) {
|
||||||
|
return addPluginBySpec(runtime, spec)
|
||||||
|
},
|
||||||
|
install(spec, options) {
|
||||||
|
return installPluginBySpec(runtime, spec, options?.global)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lifecycle: scope.lifecycle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||||
|
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
|
||||||
|
const plugin = load.module.tui
|
||||||
|
if (!plugin) return []
|
||||||
|
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: load.id,
|
||||||
|
load,
|
||||||
|
meta,
|
||||||
|
plugin,
|
||||||
|
options,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
||||||
|
if (state.plugins_by_id.has(plugin.id)) {
|
||||||
|
fail("duplicate tui plugin id", {
|
||||||
|
id: plugin.id,
|
||||||
|
path: plugin.load.spec,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
state.plugins_by_id.set(plugin.id, plugin)
|
||||||
|
state.plugins.push(plugin)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||||
|
const map = pluginEnabledState(state, config)
|
||||||
|
for (const plugin of state.plugins) {
|
||||||
|
const enabled = map[plugin.id]
|
||||||
|
if (enabled === undefined) continue
|
||||||
|
plugin.enabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveExternalPlugins(
|
||||||
|
list: Config.PluginSpec[],
|
||||||
|
wait: () => Promise<void>,
|
||||||
|
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
|
||||||
|
) {
|
||||||
|
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
|
||||||
|
const ready: PluginLoad[] = []
|
||||||
|
let deps: Promise<void> | undefined
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
let entry = loaded[i]
|
||||||
|
if (!entry) {
|
||||||
|
const item = list[i]
|
||||||
|
if (!item) continue
|
||||||
|
const spec = Config.pluginSpecifier(item)
|
||||||
|
if (pluginSource(spec) !== "file") continue
|
||||||
|
deps ??= wait().catch((error) => {
|
||||||
|
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||||
|
})
|
||||||
|
await deps
|
||||||
|
entry = await loadExternalPlugin(item, meta(item), true)
|
||||||
|
}
|
||||||
|
if (!entry) continue
|
||||||
|
ready.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
||||||
|
if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
|
||||||
|
|
||||||
|
const meta = await PluginMeta.touchMany(
|
||||||
|
ready.map((item) => ({
|
||||||
|
spec: item.spec,
|
||||||
|
target: item.target,
|
||||||
|
id: item.id,
|
||||||
|
})),
|
||||||
|
).catch((error) => {
|
||||||
|
log.warn("failed to track tui plugins", { error })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const plugins: PluginEntry[] = []
|
||||||
|
let ok = true
|
||||||
|
for (let i = 0; i < ready.length; i++) {
|
||||||
|
const entry = ready[i]
|
||||||
|
if (!entry) continue
|
||||||
|
const hit = meta?.[i]
|
||||||
|
if (hit && hit.state !== "same") {
|
||||||
|
log.info("tui plugin metadata updated", {
|
||||||
|
path: entry.spec,
|
||||||
|
retry: entry.retry,
|
||||||
|
state: hit.state,
|
||||||
|
source: hit.entry.source,
|
||||||
|
version: hit.entry.version,
|
||||||
|
modified: hit.entry.modified,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||||
|
for (const plugin of collectPluginEntries(entry, row)) {
|
||||||
|
if (!addPluginEntry(state, plugin)) {
|
||||||
|
ok = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plugins.push(plugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plugins, ok }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
|
||||||
|
return {
|
||||||
|
scope: "local",
|
||||||
|
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installCause(err: unknown) {
|
||||||
|
if (!err || typeof err !== "object") return
|
||||||
|
if (!("cause" in err)) return
|
||||||
|
return (err as { cause?: unknown }).cause
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDetail(err: unknown) {
|
||||||
|
const hit = installCause(err) ?? err
|
||||||
|
if (!(hit instanceof Process.RunFailedError)) {
|
||||||
|
return {
|
||||||
|
message: errorMessage(hit),
|
||||||
|
missing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = hit.stderr
|
||||||
|
.toString()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||||
|
return {
|
||||||
|
message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
|
||||||
|
missing: lines.some((line) => line.includes("No version matching")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||||
|
if (!state) return false
|
||||||
|
const spec = raw.trim()
|
||||||
|
if (!spec) return false
|
||||||
|
|
||||||
|
const pending = state.pending.get(spec)
|
||||||
|
const item = pending?.item ?? spec
|
||||||
|
const nextSpec = Config.pluginSpecifier(item)
|
||||||
|
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
|
||||||
|
state.pending.delete(spec)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = pending?.meta ?? defaultPluginMeta(state)
|
||||||
|
|
||||||
|
const ready = await Instance.provide({
|
||||||
|
directory: state.directory,
|
||||||
|
fn: () =>
|
||||||
|
resolveExternalPlugins(
|
||||||
|
[item],
|
||||||
|
() => TuiConfig.waitForDependencies(),
|
||||||
|
() => meta,
|
||||||
|
),
|
||||||
|
}).catch((error) => {
|
||||||
|
fail("failed to add tui plugin", { path: nextSpec, error })
|
||||||
|
return [] as PluginLoad[]
|
||||||
|
})
|
||||||
|
if (!ready.length) {
|
||||||
|
fail("failed to add tui plugin", { path: nextSpec })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = ready[0]
|
||||||
|
if (!first) {
|
||||||
|
fail("failed to add tui plugin", { path: nextSpec })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (state.plugins_by_id.has(first.id)) {
|
||||||
|
state.pending.delete(spec)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await addExternalPluginEntries(state, [first])
|
||||||
|
let ok = out.ok && out.plugins.length > 0
|
||||||
|
for (const plugin of out.plugins) {
|
||||||
|
const active = await activatePluginEntry(state, plugin, false)
|
||||||
|
if (!active) ok = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) state.pending.delete(spec)
|
||||||
|
if (!ok) {
|
||||||
|
fail("failed to add tui plugin", { path: nextSpec })
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPluginBySpec(
|
||||||
|
state: RuntimeState | undefined,
|
||||||
|
raw: string,
|
||||||
|
global = false,
|
||||||
|
): Promise<TuiPluginInstallResult> {
|
||||||
|
if (!state) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Plugin runtime is not ready.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = raw.trim()
|
||||||
|
if (!spec) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Plugin package name is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = state.api.state.path
|
||||||
|
if (!dir.directory) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Paths are still syncing. Try again in a moment.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = await installModulePlugin(spec)
|
||||||
|
if (!install.ok) {
|
||||||
|
const out = installDetail(install.error)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: out.message,
|
||||||
|
missing: out.missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await readPluginManifest(install.target)
|
||||||
|
if (!manifest.ok) {
|
||||||
|
if (manifest.code === "manifest_no_targets") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `"${spec}" does not declare supported targets in package.json`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Installed "${spec}" but failed to read ${manifest.file}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = await patchPluginConfig({
|
||||||
|
spec,
|
||||||
|
targets: manifest.targets,
|
||||||
|
global,
|
||||||
|
vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
|
||||||
|
worktree: dir.worktree,
|
||||||
|
directory: dir.directory,
|
||||||
|
})
|
||||||
|
if (!patch.ok) {
|
||||||
|
if (patch.code === "invalid_json") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: errorMessage(patch.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||||
|
if (tui) {
|
||||||
|
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||||
|
state.pending.set(spec, {
|
||||||
|
item: tui.opts ? [spec, tui.opts] : spec,
|
||||||
|
meta: {
|
||||||
|
scope: global ? "global" : "local",
|
||||||
|
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dir: patch.dir,
|
||||||
|
tui: Boolean(tui),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace TuiPluginRuntime {
|
||||||
|
let dir = ""
|
||||||
|
let loaded: Promise<void> | undefined
|
||||||
|
let runtime: RuntimeState | undefined
|
||||||
|
export const Slot = View
|
||||||
|
|
||||||
|
export async function init(api: HostPluginApi) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (loaded) {
|
||||||
|
if (dir !== cwd) {
|
||||||
|
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = cwd
|
||||||
|
loaded = load(api)
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
export function list() {
|
||||||
|
if (!runtime) return []
|
||||||
|
return listPluginStatus(runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activatePlugin(id: string) {
|
||||||
|
return activatePluginById(runtime, id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivatePlugin(id: string) {
|
||||||
|
return deactivatePluginById(runtime, id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPlugin(spec: string) {
|
||||||
|
return addPluginBySpec(runtime, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||||
|
return installPluginBySpec(runtime, spec, options?.global)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispose() {
|
||||||
|
const task = loaded
|
||||||
|
loaded = undefined
|
||||||
|
dir = ""
|
||||||
|
if (task) await task
|
||||||
|
const state = runtime
|
||||||
|
runtime = undefined
|
||||||
|
if (!state) return
|
||||||
|
const queue = [...state.plugins].reverse()
|
||||||
|
for (const plugin of queue) {
|
||||||
|
await deactivatePluginEntry(state, plugin, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(api: Api) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const slots = setupSlots(api)
|
||||||
|
const next: RuntimeState = {
|
||||||
|
directory: cwd,
|
||||||
|
api,
|
||||||
|
slots,
|
||||||
|
plugins: [],
|
||||||
|
plugins_by_id: new Map(),
|
||||||
|
pending: new Map(),
|
||||||
|
}
|
||||||
|
runtime = next
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: cwd,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
|
||||||
|
if (Flag.OPENCODE_PURE && config.plugin?.length) {
|
||||||
|
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||||
|
log.info("loading internal tui plugin", { id: item.id })
|
||||||
|
const entry = loadInternalPlugin(item)
|
||||||
|
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||||
|
for (const plugin of collectPluginEntries(entry, meta)) {
|
||||||
|
addPluginEntry(next, plugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = await resolveExternalPlugins(
|
||||||
|
plugins,
|
||||||
|
() => TuiConfig.waitForDependencies(),
|
||||||
|
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
|
||||||
|
)
|
||||||
|
await addExternalPluginEntries(next, ready)
|
||||||
|
|
||||||
|
applyInitialPluginEnabledState(next, config)
|
||||||
|
for (const plugin of next.plugins) {
|
||||||
|
if (!plugin.enabled) continue
|
||||||
|
// Keep plugin execution sequential for deterministic side effects:
|
||||||
|
// command registration order affects keybind/command precedence,
|
||||||
|
// route registration is last-wins when ids collide,
|
||||||
|
// and hook chains rely on stable plugin ordering.
|
||||||
|
await activatePluginEntry(next, plugin, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
fail("failed to load tui plugins", { directory: cwd, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
Normal file
61
packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
|
||||||
|
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
|
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||||
|
name: K
|
||||||
|
mode?: SlotMode
|
||||||
|
children?: JSX.Element
|
||||||
|
} & TuiSlotMap[K]
|
||||||
|
|
||||||
|
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||||
|
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||||
|
|
||||||
|
export type HostPluginApi = TuiPluginApi
|
||||||
|
export type HostSlots = {
|
||||||
|
register: (plugin: HostSlotPlugin) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let view: Slot = empty
|
||||||
|
|
||||||
|
export const Slot: Slot = (props) => view(props)
|
||||||
|
|
||||||
|
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
if (typeof value.id !== "string") return false
|
||||||
|
if (!isRecord(value.slots)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSlots(api: HostPluginApi): HostSlots {
|
||||||
|
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
|
||||||
|
api.renderer,
|
||||||
|
{
|
||||||
|
theme: api.theme,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onPluginError(event) {
|
||||||
|
console.error("[tui.slot] plugin error", {
|
||||||
|
plugin: event.pluginId,
|
||||||
|
slot: event.slot,
|
||||||
|
phase: event.phase,
|
||||||
|
source: event.source,
|
||||||
|
message: event.error.message,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
|
||||||
|
view = (props) => slot(props)
|
||||||
|
return {
|
||||||
|
register(plugin) {
|
||||||
|
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||||
|
return reg.register(plugin)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
import { Tips } from "../component/tips"
|
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
@@ -12,20 +10,17 @@ import { useDirectory } from "../context/directory"
|
|||||||
import { useRouteData } from "@tui/context/route"
|
import { useRouteData } from "@tui/context/route"
|
||||||
import { usePromptRef } from "../context/prompt"
|
import { usePromptRef } from "../context/prompt"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { useKV } from "../context/kv"
|
|
||||||
import { useCommandDialog } from "../component/dialog-command"
|
|
||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
|
import { TuiPluginRuntime } from "../plugin"
|
||||||
|
|
||||||
// TODO: what is the best way to do this?
|
// TODO: what is the best way to do this?
|
||||||
let once = false
|
let once = false
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const kv = useKV()
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const route = useRouteData("home")
|
const route = useRouteData("home")
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const command = useCommandDialog()
|
|
||||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||||
const mcpError = createMemo(() => {
|
const mcpError = createMemo(() => {
|
||||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||||
@@ -35,30 +30,9 @@ export function Home() {
|
|||||||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
|
||||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
|
||||||
const showTips = createMemo(() => {
|
|
||||||
// Don't show tips for first-time users
|
|
||||||
if (isFirstTimeUser()) return false
|
|
||||||
return !tipsHidden()
|
|
||||||
})
|
|
||||||
|
|
||||||
command.register(() => [
|
|
||||||
{
|
|
||||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
|
||||||
value: "tips.toggle",
|
|
||||||
keybind: "tips_toggle",
|
|
||||||
category: "System",
|
|
||||||
onSelect: (dialog) => {
|
|
||||||
kv.set("tips_hidden", !tipsHidden())
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const Hint = (
|
const Hint = (
|
||||||
<Show when={connectedMcpCount() > 0}>
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
<Show when={connectedMcpCount() > 0}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={mcpError()}>
|
<Match when={mcpError()}>
|
||||||
@@ -71,8 +45,8 @@ export function Home() {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</Show>
|
||||||
</Show>
|
</box>
|
||||||
)
|
)
|
||||||
|
|
||||||
let prompt: PromptRef
|
let prompt: PromptRef
|
||||||
@@ -103,15 +77,15 @@ export function Home() {
|
|||||||
)
|
)
|
||||||
const directory = useDirectory()
|
const directory = useDirectory()
|
||||||
|
|
||||||
const keybind = useKeybind()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||||
<box flexGrow={1} minHeight={0} />
|
<box flexGrow={1} minHeight={0} />
|
||||||
<box height={4} minHeight={0} flexShrink={1} />
|
<box height={4} minHeight={0} flexShrink={1} />
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<Logo />
|
<TuiPluginRuntime.Slot name="home_logo" mode="replace">
|
||||||
|
<Logo />
|
||||||
|
</TuiPluginRuntime.Slot>
|
||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
@@ -124,11 +98,7 @@ export function Home() {
|
|||||||
workspaceID={route.workspaceID}
|
workspaceID={route.workspaceID}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
<TuiPluginRuntime.Slot name="home_bottom" />
|
||||||
<Show when={showTips()}>
|
|
||||||
<Tips />
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<box flexGrow={1} minHeight={0} />
|
<box flexGrow={1} minHeight={0} />
|
||||||
<Toast />
|
<Toast />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
|
|||||||
import { useKV } from "../../context/kv.tsx"
|
import { useKV } from "../../context/kv.tsx"
|
||||||
import { Editor } from "../../util/editor"
|
import { Editor } from "../../util/editor"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { Footer } from "./footer.tsx"
|
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|||||||
@@ -1,72 +1,13 @@
|
|||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import { Locale } from "@/util/locale"
|
|
||||||
import path from "path"
|
|
||||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
import { useDirectory } from "../../context/directory"
|
|
||||||
import { useKV } from "../../context/kv"
|
|
||||||
import { TodoItem } from "../../component/todo-item"
|
|
||||||
|
|
||||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||||
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
|
|
||||||
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
|
|
||||||
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
|
||||||
|
|
||||||
const [expanded, setExpanded] = createStore({
|
|
||||||
mcp: true,
|
|
||||||
diff: true,
|
|
||||||
todo: true,
|
|
||||||
lsp: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort MCP servers alphabetically for consistent display order
|
|
||||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
|
||||||
|
|
||||||
// Count connected and error MCP servers for collapsed header display
|
|
||||||
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
|
|
||||||
const errorMcpCount = createMemo(
|
|
||||||
() =>
|
|
||||||
mcpEntries().filter(
|
|
||||||
([_, item]) =>
|
|
||||||
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
|
|
||||||
).length,
|
|
||||||
)
|
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
|
||||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(total)
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = createMemo(() => {
|
|
||||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
|
||||||
if (!last) return
|
|
||||||
const total =
|
|
||||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
|
||||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
|
||||||
return {
|
|
||||||
tokens: total.toLocaleString(),
|
|
||||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const directory = useDirectory()
|
|
||||||
const kv = useKV()
|
|
||||||
|
|
||||||
const hasProviders = createMemo(() =>
|
|
||||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
|
||||||
)
|
|
||||||
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
@@ -90,230 +31,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||||
<box paddingRight={1}>
|
<TuiPluginRuntime.Slot
|
||||||
<text fg={theme.text}>
|
name="sidebar_title"
|
||||||
<b>{session().title}</b>
|
mode="single_winner"
|
||||||
</text>
|
session_id={props.sessionID}
|
||||||
<Show when={session().share?.url}>
|
title={session()!.title}
|
||||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
share_url={session()!.share?.url}
|
||||||
</Show>
|
>
|
||||||
</box>
|
<box paddingRight={1}>
|
||||||
<box>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<b>Context</b>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
|
||||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
|
||||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
|
||||||
</box>
|
|
||||||
<Show when={mcpEntries().length > 0}>
|
|
||||||
<box>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
|
|
||||||
>
|
|
||||||
<Show when={mcpEntries().length > 2}>
|
|
||||||
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<b>MCP</b>
|
|
||||||
<Show when={!expanded.mcp}>
|
|
||||||
<span style={{ fg: theme.textMuted }}>
|
|
||||||
{" "}
|
|
||||||
({connectedMcpCount()} active
|
|
||||||
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
|
|
||||||
<For each={mcpEntries()}>
|
|
||||||
{([key, item]) => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
style={{
|
|
||||||
fg: (
|
|
||||||
{
|
|
||||||
connected: theme.success,
|
|
||||||
failed: theme.error,
|
|
||||||
disabled: theme.textMuted,
|
|
||||||
needs_auth: theme.warning,
|
|
||||||
needs_client_registration: theme.error,
|
|
||||||
} as Record<string, typeof theme.success>
|
|
||||||
)[item.status],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</text>
|
|
||||||
<text fg={theme.text} wrapMode="word">
|
|
||||||
{key}{" "}
|
|
||||||
<span style={{ fg: theme.textMuted }}>
|
|
||||||
<Switch fallback={item.status}>
|
|
||||||
<Match when={item.status === "connected"}>Connected</Match>
|
|
||||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
|
||||||
<Match when={item.status === "disabled"}>Disabled</Match>
|
|
||||||
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
|
|
||||||
<Match when={(item.status as string) === "needs_client_registration"}>
|
|
||||||
Needs client ID
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<box>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
|
|
||||||
>
|
|
||||||
<Show when={sync.data.lsp.length > 2}>
|
|
||||||
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<b>LSP</b>
|
<b>{session()!.title}</b>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
<Show when={session()!.share?.url}>
|
||||||
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
|
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||||
<Show when={sync.data.lsp.length === 0}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
{sync.data.config.lsp === false
|
|
||||||
? "LSPs have been disabled in settings"
|
|
||||||
: "LSPs will activate as files are read"}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<For each={sync.data.lsp}>
|
|
||||||
{(item) => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
style={{
|
|
||||||
fg: {
|
|
||||||
connected: theme.success,
|
|
||||||
error: theme.error,
|
|
||||||
}[item.status],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
{item.id} {item.root}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
|
|
||||||
<box>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
|
|
||||||
>
|
|
||||||
<Show when={todo().length > 2}>
|
|
||||||
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<b>Todo</b>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={todo().length <= 2 || expanded.todo}>
|
|
||||||
<For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</TuiPluginRuntime.Slot>
|
||||||
<Show when={diff().length > 0}>
|
<TuiPluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
|
||||||
<box>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
|
|
||||||
>
|
|
||||||
<Show when={diff().length > 2}>
|
|
||||||
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<b>Modified Files</b>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={diff().length <= 2 || expanded.diff}>
|
|
||||||
<For each={diff() || []}>
|
|
||||||
{(item) => {
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
|
||||||
<text fg={theme.textMuted} wrapMode="none">
|
|
||||||
{item.file}
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
|
||||||
<Show when={item.additions}>
|
|
||||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.deletions}>
|
|
||||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
|
|
||||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||||
<Show when={!hasProviders() && !gettingStartedDismissed()}>
|
<TuiPluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
|
||||||
<box
|
<text fg={theme.textMuted}>
|
||||||
backgroundColor={theme.backgroundElement}
|
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||||
paddingTop={1}
|
<span style={{ fg: theme.text }}>
|
||||||
paddingBottom={1}
|
<b>Code</b>
|
||||||
paddingLeft={2}
|
</span>{" "}
|
||||||
paddingRight={2}
|
<span>{Installation.VERSION}</span>
|
||||||
flexDirection="row"
|
</text>
|
||||||
gap={1}
|
</TuiPluginRuntime.Slot>
|
||||||
>
|
|
||||||
<text flexShrink={0} fg={theme.text}>
|
|
||||||
⬖
|
|
||||||
</text>
|
|
||||||
<box flexGrow={1} gap={1}>
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<b>Getting started</b>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
|
|
||||||
✕
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
|
||||||
<text fg={theme.text}>Connect provider</text>
|
|
||||||
<text fg={theme.textMuted}>/connect</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
|
|
||||||
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
|
||||||
<span style={{ fg: theme.text }}>
|
|
||||||
<b>Code</b>
|
|
||||||
</span>{" "}
|
|
||||||
<span>{Installation.VERSION}</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
@@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
const reload = () => {
|
const reload = () => {
|
||||||
client.call("reload", undefined).catch((err) => {
|
client.call("reload", undefined).catch((err) => {
|
||||||
Log.Default.warn("worker reload failed", {
|
Log.Default.warn("worker reload failed", {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: errorMessage(err),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
process.off("SIGUSR2", reload)
|
process.off("SIGUSR2", reload)
|
||||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
||||||
Log.Default.warn("worker shutdown failed", {
|
Log.Default.warn("worker shutdown failed", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorMessage(error),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
worker.terminate()
|
worker.terminate()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Selection } from "@tui/util/selection"
|
|||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
size?: "medium" | "large"
|
size?: "medium" | "large" | "xlarge"
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -18,6 +18,11 @@ export function Dialog(
|
|||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
let dismiss = false
|
let dismiss = false
|
||||||
|
const width = () => {
|
||||||
|
if (props.size === "xlarge") return 116
|
||||||
|
if (props.size === "large") return 88
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -35,6 +40,7 @@ export function Dialog(
|
|||||||
height={dimensions().height}
|
height={dimensions().height}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
|
zIndex={3000}
|
||||||
paddingTop={dimensions().height / 4}
|
paddingTop={dimensions().height / 4}
|
||||||
left={0}
|
left={0}
|
||||||
top={0}
|
top={0}
|
||||||
@@ -45,7 +51,7 @@ export function Dialog(
|
|||||||
dismiss = false
|
dismiss = false
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
width={props.size === "large" ? 80 : 60}
|
width={width()}
|
||||||
maxWidth={dimensions().width - 2}
|
maxWidth={dimensions().width - 2}
|
||||||
backgroundColor={theme.backgroundPanel}
|
backgroundColor={theme.backgroundPanel}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
@@ -62,7 +68,7 @@ function init() {
|
|||||||
element: JSX.Element
|
element: JSX.Element
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}[],
|
}[],
|
||||||
size: "medium" as "medium" | "large",
|
size: "medium" as "medium" | "large" | "xlarge",
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
@@ -72,6 +78,9 @@ function init() {
|
|||||||
if (evt.defaultPrevented) return
|
if (evt.defaultPrevented) return
|
||||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||||
|
if (renderer.getSelection()) {
|
||||||
|
renderer.clearSelection()
|
||||||
|
}
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
current.onClose?.()
|
current.onClose?.()
|
||||||
setStore("stack", store.stack.slice(0, -1))
|
setStore("stack", store.stack.slice(0, -1))
|
||||||
@@ -132,7 +141,7 @@ function init() {
|
|||||||
get size() {
|
get size() {
|
||||||
return store.size
|
return store.size
|
||||||
},
|
},
|
||||||
setSize(size: "medium" | "large") {
|
setSize(size: "medium" | "large" | "xlarge") {
|
||||||
setStore("size", size)
|
setStore("size", size)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -151,6 +160,7 @@ export function DialogProvider(props: ParentProps) {
|
|||||||
{props.children}
|
{props.children}
|
||||||
<box
|
<box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
|
zIndex={3000}
|
||||||
onMouseDown={(evt) => {
|
onMouseDown={(evt) => {
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
if (evt.button !== MouseButton.RIGHT) return
|
if (evt.button !== MouseButton.RIGHT) return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConfigMarkdown } from "@/config/markdown"
|
import { ConfigMarkdown } from "@/config/markdown"
|
||||||
|
import { errorFormat } from "@/util/error"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
@@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FormatUnknownError(input: unknown): string {
|
export function FormatUnknownError(input: unknown): string {
|
||||||
if (input instanceof Error) {
|
return errorFormat(input)
|
||||||
return input.stack ?? `${input.name}: ${input.message}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === "object" && input !== null) {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(input, null, 2)
|
|
||||||
} catch {
|
|
||||||
return "Unexpected error (unserializable)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(input)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,20 +30,27 @@ import { GlobalBus } from "@/bus/global"
|
|||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
import { proxied } from "@/util/proxied"
|
import { online, proxied } from "@/util/network"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "./paths"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { Lock } from "@/util/lock"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
||||||
|
import { Flock } from "@/util/flock"
|
||||||
|
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||||
|
const PluginOptions = z.record(z.string(), z.unknown())
|
||||||
|
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
|
||||||
|
|
||||||
|
export type PluginOptions = z.infer<typeof PluginOptions>
|
||||||
|
export type PluginSpec = z.infer<typeof PluginSpec>
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
@@ -78,34 +85,65 @@ export namespace Config {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDependencies(dir: string) {
|
export type InstallInput = {
|
||||||
|
signal?: AbortSignal
|
||||||
|
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installDependencies(dir: string, input?: InstallInput) {
|
||||||
|
if (!(await needsInstall(dir))) return
|
||||||
|
|
||||||
|
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
|
||||||
|
signal: input?.signal,
|
||||||
|
onWait: (tick) =>
|
||||||
|
input?.waitTick?.({
|
||||||
|
dir,
|
||||||
|
attempt: tick.attempt,
|
||||||
|
delay: tick.delay,
|
||||||
|
waited: tick.waited,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
input?.signal?.throwIfAborted()
|
||||||
|
if (!(await needsInstall(dir))) return
|
||||||
|
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
|
||||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
}))
|
}))
|
||||||
json.dependencies = {
|
json.dependencies = {
|
||||||
...json.dependencies,
|
...json.dependencies,
|
||||||
"@opencode-ai/plugin": targetVersion,
|
"@opencode-ai/plugin": target,
|
||||||
}
|
}
|
||||||
await Filesystem.writeJson(pkg, json)
|
await Filesystem.writeJson(pkg, json)
|
||||||
|
|
||||||
const gitignore = path.join(dir, ".gitignore")
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
const ignore = await Filesystem.exists(gitignore)
|
||||||
if (!hasGitIgnore)
|
if (!ignore) {
|
||||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
||||||
|
// Serialize installs globally on win32, but keep parallel installs on other platforms.
|
||||||
|
await using __ =
|
||||||
|
process.platform === "win32"
|
||||||
|
? await Flock.acquire("config-install:bun", {
|
||||||
|
signal: input?.signal,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
// Install any additional dependencies defined in the package.json
|
|
||||||
// This allows local plugins and custom tools to use external packages
|
|
||||||
using _ = await Lock.write("bun-install")
|
|
||||||
await BunProc.run(
|
await BunProc.run(
|
||||||
[
|
[
|
||||||
"install",
|
"install",
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||||
],
|
],
|
||||||
{ cwd: dir },
|
{
|
||||||
|
cwd: dir,
|
||||||
|
abort: input?.signal,
|
||||||
|
},
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
if (err instanceof Process.RunFailedError) {
|
if (err instanceof Process.RunFailedError) {
|
||||||
const detail = {
|
const detail = {
|
||||||
@@ -149,8 +187,8 @@ export namespace Config {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeModules = path.join(dir, "node_modules")
|
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
|
||||||
if (!existsSync(nodeModules)) return true
|
if (!existsSync(mod)) return true
|
||||||
|
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const pkgExists = await Filesystem.exists(pkg)
|
const pkgExists = await Filesystem.exists(pkg)
|
||||||
@@ -163,8 +201,9 @@ export namespace Config {
|
|||||||
|
|
||||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||||
if (targetVersion === "latest") {
|
if (targetVersion === "latest") {
|
||||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
if (!online()) return false
|
||||||
if (!isOutdated) return false
|
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||||
|
if (!stale) return false
|
||||||
log.info("Cached version is outdated, proceeding with install", {
|
log.info("Cached version is outdated, proceeding with install", {
|
||||||
pkg: "@opencode-ai/plugin",
|
pkg: "@opencode-ai/plugin",
|
||||||
cachedVersion: depVersion,
|
cachedVersion: depVersion,
|
||||||
@@ -303,7 +342,7 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPlugin(dir: string) {
|
async function loadPlugin(dir: string) {
|
||||||
const plugins: string[] = []
|
const plugins: PluginSpec[] = []
|
||||||
|
|
||||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
@@ -316,25 +355,44 @@ export namespace Config {
|
|||||||
return plugins
|
return plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function pluginSpecifier(plugin: PluginSpec): string {
|
||||||
* Extracts a canonical plugin name from a plugin specifier.
|
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||||
* - For file:// URLs: extracts filename without extension
|
}
|
||||||
* - For npm packages: extracts package name without version
|
|
||||||
*
|
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
||||||
* @example
|
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||||
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
|
}
|
||||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
|
||||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
|
||||||
*/
|
const spec = pluginSpecifier(plugin)
|
||||||
export function getPluginName(plugin: string): string {
|
if (!isPathPluginSpec(spec)) return plugin
|
||||||
if (plugin.startsWith("file://")) {
|
if (spec.startsWith("file://")) {
|
||||||
return path.parse(new URL(plugin).pathname).name
|
const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
}
|
}
|
||||||
const lastAt = plugin.lastIndexOf("@")
|
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
|
||||||
if (lastAt > 0) {
|
const base = pathToFileURL(spec).href
|
||||||
return plugin.substring(0, lastAt)
|
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const base = import.meta.resolve!(spec, configFilepath)
|
||||||
|
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const require = createRequire(configFilepath)
|
||||||
|
const base = pathToFileURL(require.resolve(spec)).href
|
||||||
|
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
|
} catch {
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return plugin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,17 +406,13 @@ export namespace Config {
|
|||||||
* Since plugins are added in low-to-high priority order,
|
* Since plugins are added in low-to-high priority order,
|
||||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||||
*/
|
*/
|
||||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
||||||
// seenNames: canonical plugin names for duplicate detection
|
|
||||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
|
||||||
const seenNames = new Set<string>()
|
const seenNames = new Set<string>()
|
||||||
|
const uniqueSpecifiers: PluginSpec[] = []
|
||||||
// uniqueSpecifiers: full plugin specifiers to return
|
|
||||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
|
||||||
const uniqueSpecifiers: string[] = []
|
|
||||||
|
|
||||||
for (const specifier of plugins.toReversed()) {
|
for (const specifier of plugins.toReversed()) {
|
||||||
const name = getPluginName(specifier)
|
const spec = pluginSpecifier(specifier)
|
||||||
|
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||||
if (!seenNames.has(name)) {
|
if (!seenNames.has(name)) {
|
||||||
seenNames.add(name)
|
seenNames.add(name)
|
||||||
uniqueSpecifiers.push(specifier)
|
uniqueSpecifiers.push(specifier)
|
||||||
@@ -757,6 +811,7 @@ export namespace Config {
|
|||||||
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
|
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
|
||||||
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
||||||
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
||||||
|
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
|
||||||
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
|
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -858,13 +913,13 @@ export namespace Config {
|
|||||||
ignore: z.array(z.string()).optional(),
|
ignore: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
plugin: z.string().array().optional(),
|
|
||||||
snapshot: z
|
snapshot: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||||
),
|
),
|
||||||
|
plugin: PluginSpec.array().optional(),
|
||||||
share: z
|
share: z
|
||||||
.enum(["manual", "auto", "disabled"])
|
.enum(["manual", "auto", "disabled"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -1070,10 +1125,6 @@ export namespace Config {
|
|||||||
return candidates[0]
|
return candidates[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||||
if (!isRecord(patch)) {
|
if (!isRecord(patch)) {
|
||||||
const edits = modify(input, path, patch, {
|
const edits = modify(input, path, patch, {
|
||||||
@@ -1189,19 +1240,9 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
const data = parsed.data
|
const data = parsed.data
|
||||||
if (data.plugin && isFile) {
|
if (data.plugin && isFile) {
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
const list = data.plugin
|
||||||
const plugin = data.plugin[i]
|
for (let i = 0; i < list.length; i++) {
|
||||||
try {
|
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
||||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
|
||||||
} catch (e) {
|
|
||||||
try {
|
|
||||||
const require = createRequire(options.path)
|
|
||||||
const resolvedPath = require.resolve(plugin)
|
|
||||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
|
||||||
} catch {
|
|
||||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
@@ -1326,12 +1367,14 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.push(
|
const dep = iife(async () => {
|
||||||
iife(async () => {
|
const stale = await needsInstall(dir)
|
||||||
const shouldInstall = await needsInstall(dir)
|
if (stale) await installDependencies(dir)
|
||||||
if (shouldInstall) await installDependencies(dir)
|
})
|
||||||
}),
|
void dep.catch((err) => {
|
||||||
)
|
log.warn("background dependency install failed", { dir, error: err })
|
||||||
|
})
|
||||||
|
deps.push(dep)
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const TuiInfo = z
|
|||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
theme: z.string().optional(),
|
theme: z.string().optional(),
|
||||||
keybinds: KeybindOverride.optional(),
|
keybinds: KeybindOverride.optional(),
|
||||||
|
plugin: Config.PluginSpec.array().optional(),
|
||||||
|
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||||
})
|
})
|
||||||
.extend(TuiOptions.shape)
|
.extend(TuiOptions.shape)
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
|
|||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
import { parsePluginSpecifier } from "@/plugin/shared"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
export namespace TuiConfig {
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
export const Info = TuiInfo
|
export const Info = TuiInfo
|
||||||
|
|
||||||
export type Info = z.output<typeof Info>
|
export type PluginMeta = {
|
||||||
|
scope: "global" | "local"
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginEntry = {
|
||||||
|
item: Config.PluginSpec
|
||||||
|
meta: PluginMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
type Acc = {
|
||||||
|
result: Info
|
||||||
|
entries: PluginEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Info = z.output<typeof Info> & {
|
||||||
|
plugin_meta?: Record<string, PluginMeta>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginScope(file: string): PluginMeta["scope"] {
|
||||||
|
if (Instance.containsPath(file)) return "local"
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePlugins(list: PluginEntry[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: PluginEntry[] = []
|
||||||
|
for (const item of list.toReversed()) {
|
||||||
|
const spec = Config.pluginSpecifier(item.item)
|
||||||
|
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||||
|
if (seen.has(name)) continue
|
||||||
|
seen.add(name)
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
return result.toReversed()
|
||||||
|
}
|
||||||
|
|
||||||
function mergeInfo(target: Info, source: Info): Info {
|
function mergeInfo(target: Info, source: Info): Info {
|
||||||
return mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
|
if (target.plugin && source.plugin) {
|
||||||
|
merged.plugin = [...target.plugin, ...source.plugin]
|
||||||
|
}
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
function customPath() {
|
function customPath() {
|
||||||
return Flag.OPENCODE_TUI_CONFIG
|
return Flag.OPENCODE_TUI_CONFIG
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize(raw: Record<string, unknown>) {
|
||||||
|
const data = { ...raw }
|
||||||
|
if (!("tui" in data)) return data
|
||||||
|
if (!isRecord(data.tui)) {
|
||||||
|
delete data.tui
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = data.tui
|
||||||
|
delete data.tui
|
||||||
|
return {
|
||||||
|
...tui,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDeps(dir: string): Promise<void> {
|
||||||
|
return Config.installDependencies(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeFile(acc: Acc, file: string) {
|
||||||
|
const data = await loadFile(file)
|
||||||
|
acc.result = mergeInfo(acc.result, data)
|
||||||
|
if (!data.plugin?.length) return
|
||||||
|
|
||||||
|
const scope = pluginScope(file)
|
||||||
|
for (const item of data.plugin) {
|
||||||
|
acc.entries.push({
|
||||||
|
item,
|
||||||
|
meta: {
|
||||||
|
scope,
|
||||||
|
source: file,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
? []
|
? []
|
||||||
@@ -38,38 +116,55 @@ export namespace TuiConfig {
|
|||||||
? []
|
? []
|
||||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||||
|
|
||||||
let result: Info = {}
|
const acc: Acc = {
|
||||||
|
result: {},
|
||||||
|
entries: [],
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
result = mergeInfo(result, await loadFile(file))
|
await mergeFile(acc, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (custom) {
|
if (custom) {
|
||||||
result = mergeInfo(result, await loadFile(custom))
|
await mergeFile(acc, custom)
|
||||||
log.debug("loaded custom tui config", { path: custom })
|
log.debug("loaded custom tui config", { path: custom })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of projectFiles) {
|
for (const file of projectFiles) {
|
||||||
result = mergeInfo(result, await loadFile(file))
|
await mergeFile(acc, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
for (const dir of unique(directories)) {
|
||||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||||
result = mergeInfo(result, await loadFile(file))
|
await mergeFile(acc, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(managed)) {
|
if (existsSync(managed)) {
|
||||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||||
result = mergeInfo(result, await loadFile(file))
|
await mergeFile(acc, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
const merged = dedupePlugins(acc.entries)
|
||||||
|
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||||
|
acc.result.plugin = merged.map((item) => item.item)
|
||||||
|
acc.result.plugin_meta = merged.length
|
||||||
|
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const deps: Promise<void>[] = []
|
||||||
|
if (acc.result.plugin?.length) {
|
||||||
|
for (const dir of unique(directories)) {
|
||||||
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
|
deps.push(installDeps(dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: result,
|
config: acc.result,
|
||||||
|
deps,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,6 +172,11 @@ export namespace TuiConfig {
|
|||||||
return state().then((x) => x.config)
|
return state().then((x) => x.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForDependencies() {
|
||||||
|
const deps = await state().then((x) => x.deps)
|
||||||
|
await Promise.all(deps)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFile(filepath: string): Promise<Info> {
|
async function loadFile(filepath: string): Promise<Info> {
|
||||||
const text = await ConfigPaths.readFile(filepath)
|
const text = await ConfigPaths.readFile(filepath)
|
||||||
if (!text) return {}
|
if (!text) return {}
|
||||||
@@ -87,25 +187,12 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
|
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||||
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
|
if (!isRecord(raw)) return {}
|
||||||
|
|
||||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||||
const normalized = (() => {
|
const normalized = normalize(raw)
|
||||||
const copy = { ...(data as Record<string, unknown>) }
|
|
||||||
if (!("tui" in copy)) return copy
|
|
||||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
|
||||||
delete copy.tui
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
const tui = copy.tui as Record<string, unknown>
|
|
||||||
delete copy.tui
|
|
||||||
return {
|
|
||||||
...tui,
|
|
||||||
...copy,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const parsed = Info.safeParse(normalized)
|
const parsed = Info.safeParse(normalized)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -113,6 +200,13 @@ export namespace TuiConfig {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed.data
|
const data = parsed.data
|
||||||
|
if (data.plugin) {
|
||||||
|
for (let i = 0; i < data.plugin.length; i++) {
|
||||||
|
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ export namespace Flag {
|
|||||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||||
|
export declare const OPENCODE_PURE: boolean
|
||||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||||
|
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
|
||||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||||
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
||||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||||
|
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
|
||||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||||
@@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
|||||||
configurable: false,
|
configurable: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Dynamic getter for OPENCODE_PURE
|
||||||
|
// This must be evaluated at access time, not module load time,
|
||||||
|
// because the CLI can set this flag at runtime
|
||||||
|
Object.defineProperty(Flag, "OPENCODE_PURE", {
|
||||||
|
get() {
|
||||||
|
return truthy("OPENCODE_PURE")
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
|
||||||
|
// This must be evaluated at access time, not module load time,
|
||||||
|
// because tests and external tooling may set this env var at runtime
|
||||||
|
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
|
||||||
|
get() {
|
||||||
|
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Dynamic getter for OPENCODE_CLIENT
|
// Dynamic getter for OPENCODE_CLIENT
|
||||||
// This must be evaluated at access time, not module load time,
|
// This must be evaluated at access time, not module load time,
|
||||||
// because some commands override the client at runtime
|
// because some commands override the client at runtime
|
||||||
|
|||||||
@@ -33,16 +33,18 @@ import path from "path"
|
|||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { JsonMigration } from "./storage/json-migration"
|
import { JsonMigration } from "./storage/json-migration"
|
||||||
import { Database } from "./storage/db"
|
import { Database } from "./storage/db"
|
||||||
|
import { errorMessage } from "./util/error"
|
||||||
|
import { PluginCommand } from "./cli/cmd/plug"
|
||||||
|
|
||||||
process.on("unhandledRejection", (e) => {
|
process.on("unhandledRejection", (e) => {
|
||||||
Log.Default.error("rejection", {
|
Log.Default.error("rejection", {
|
||||||
e: e instanceof Error ? e.message : e,
|
e: errorMessage(e),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
process.on("uncaughtException", (e) => {
|
process.on("uncaughtException", (e) => {
|
||||||
Log.Default.error("exception", {
|
Log.Default.error("exception", {
|
||||||
e: e instanceof Error ? e.message : e,
|
e: errorMessage(e),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
type: "string",
|
type: "string",
|
||||||
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
||||||
})
|
})
|
||||||
|
.option("pure", {
|
||||||
|
describe: "run without external plugins",
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
.middleware(async (opts) => {
|
.middleware(async (opts) => {
|
||||||
|
if (opts.pure) {
|
||||||
|
process.env.OPENCODE_PURE = "1"
|
||||||
|
}
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
print: process.argv.includes("--print-logs"),
|
print: process.argv.includes("--print-logs"),
|
||||||
dev: Installation.isLocal(),
|
dev: Installation.isLocal(),
|
||||||
@@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
.command(GithubCommand)
|
.command(GithubCommand)
|
||||||
.command(PrCommand)
|
.command(PrCommand)
|
||||||
.command(SessionCommand)
|
.command(SessionCommand)
|
||||||
|
.command(PluginCommand)
|
||||||
.command(DbCommand)
|
.command(DbCommand)
|
||||||
.fail((msg, err) => {
|
.fail((msg, err) => {
|
||||||
if (
|
if (
|
||||||
@@ -194,7 +205,7 @@ try {
|
|||||||
if (formatted) UI.error(formatted)
|
if (formatted) UI.error(formatted)
|
||||||
if (formatted === undefined) {
|
if (formatted === undefined) {
|
||||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||||
process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
|
process.stderr.write(errorMessage(e) + EOL)
|
||||||
}
|
}
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { BunProc } from "../bun"
|
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
@@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
|
|||||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
|
import {
|
||||||
|
checkPluginCompatibility,
|
||||||
|
getDefaultPlugin,
|
||||||
|
isDeprecatedPlugin,
|
||||||
|
parsePluginSpecifier,
|
||||||
|
pluginSource,
|
||||||
|
resolvePluginEntrypoint,
|
||||||
|
resolvePluginTarget,
|
||||||
|
} from "./shared"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
@@ -22,6 +32,12 @@ export namespace Plugin {
|
|||||||
hooks: Hooks[]
|
hooks: Hooks[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Loaded = {
|
||||||
|
item: Config.PluginSpec
|
||||||
|
spec: string
|
||||||
|
mod: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||||
type TriggerName = {
|
type TriggerName = {
|
||||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||||
@@ -46,8 +62,115 @@ export namespace Plugin {
|
|||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||||
|
|
||||||
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
return typeof value === "function"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerPlugin(value: unknown) {
|
||||||
|
if (isServerPlugin(value)) return value
|
||||||
|
if (!value || typeof value !== "object" || !("server" in value)) return
|
||||||
|
if (!isServerPlugin(value.server)) return
|
||||||
|
return value.server
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyPlugins(mod: Record<string, unknown>) {
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
const result: PluginInstance[] = []
|
||||||
|
|
||||||
|
for (const entry of Object.values(mod)) {
|
||||||
|
if (seen.has(entry)) continue
|
||||||
|
seen.add(entry)
|
||||||
|
const plugin = getServerPlugin(entry)
|
||||||
|
if (!plugin) throw new TypeError("Plugin export is not a function")
|
||||||
|
result.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePlugin(spec: string) {
|
||||||
|
const parsed = parsePluginSpecifier(spec)
|
||||||
|
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||||
|
const cause = err instanceof Error ? err.cause : err
|
||||||
|
const detail = errorMessage(cause ?? err)
|
||||||
|
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if (!target) return
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
|
||||||
|
const spec = Config.pluginSpecifier(item)
|
||||||
|
if (isDeprecatedPlugin(spec)) return
|
||||||
|
log.info("loading plugin", { path: spec })
|
||||||
|
const resolved = await resolvePlugin(spec)
|
||||||
|
if (!resolved) return
|
||||||
|
|
||||||
|
if (pluginSource(spec) === "npm") {
|
||||||
|
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||||
|
.then(() => false)
|
||||||
|
.catch((err) => {
|
||||||
|
const message = errorMessage(err)
|
||||||
|
log.warn("plugin incompatible", { path: spec, error: message })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Plugin ${spec} skipped: ${message}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (incompatible) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolved
|
||||||
|
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
|
||||||
|
const message = errorMessage(err)
|
||||||
|
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to load plugin ${spec}: ${message}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const mod = await import(entry).catch((err) => {
|
||||||
|
const message = errorMessage(err)
|
||||||
|
log.error("failed to load plugin", { path: spec, target: entry, error: message })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to load plugin ${spec}: ${message}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
})
|
||||||
|
if (!mod) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
spec,
|
||||||
|
mod,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||||
|
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
|
||||||
|
if (plugin?.server) {
|
||||||
|
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of getLegacyPlugins(load.mod)) {
|
||||||
|
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -91,51 +214,27 @@ export namespace Plugin {
|
|||||||
if (init) hooks.push(init)
|
if (init) hooks.push(init)
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugins = cfg.plugin ?? []
|
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
|
||||||
|
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
|
||||||
|
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
|
||||||
|
}
|
||||||
if (plugins.length) await Config.waitForDependencies()
|
if (plugins.length) await Config.waitForDependencies()
|
||||||
|
|
||||||
for (let plugin of plugins) {
|
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
|
||||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
for (const load of loaded) {
|
||||||
log.info("loading plugin", { path: plugin })
|
if (!load) continue
|
||||||
if (!plugin.startsWith("file://")) {
|
|
||||||
const idx = plugin.lastIndexOf("@")
|
|
||||||
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
|
||||||
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
|
||||||
const cause = err instanceof Error ? err.cause : err
|
|
||||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
|
||||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
if (!plugin) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent duplicate initialization when plugins export the same function
|
// Keep plugin execution sequential so hook registration and execution
|
||||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
// order remains deterministic across plugin runs.
|
||||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
await applyPlugin(load, input, hooks).catch((err) => {
|
||||||
await import(plugin)
|
const message = errorMessage(err)
|
||||||
.then(async (mod) => {
|
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||||
const seen = new Set<PluginInstance>()
|
Bus.publish(Session.Event.Error, {
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
error: new NamedError.Unknown({
|
||||||
if (seen.has(fn)) continue
|
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
seen.add(fn)
|
}).toObject(),
|
||||||
hooks.push(await fn(input))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
log.error("failed to load plugin", { path: plugin, error: message })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify plugins of current config
|
// Notify plugins of current config
|
||||||
|
|||||||
351
packages/opencode/src/plugin/install.ts
Normal file
351
packages/opencode/src/plugin/install.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import path from "path"
|
||||||
|
import {
|
||||||
|
type ParseError as JsoncParseError,
|
||||||
|
applyEdits,
|
||||||
|
modify,
|
||||||
|
parse as parseJsonc,
|
||||||
|
printParseErrorCode,
|
||||||
|
} from "jsonc-parser"
|
||||||
|
|
||||||
|
import { ConfigPaths } from "@/config/paths"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Flock } from "@/util/flock"
|
||||||
|
|
||||||
|
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
||||||
|
|
||||||
|
type Mode = "noop" | "add" | "replace"
|
||||||
|
type Kind = "server" | "tui"
|
||||||
|
|
||||||
|
export type Target = {
|
||||||
|
kind: Kind
|
||||||
|
opts?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstallDeps = {
|
||||||
|
resolve: (spec: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchDeps = {
|
||||||
|
readText: (file: string) => Promise<string>
|
||||||
|
write: (file: string, text: string) => Promise<void>
|
||||||
|
exists: (file: string) => Promise<boolean>
|
||||||
|
files: (dir: string, name: "opencode" | "tui") => string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchInput = {
|
||||||
|
spec: string
|
||||||
|
targets: Target[]
|
||||||
|
force?: boolean
|
||||||
|
global?: boolean
|
||||||
|
vcs?: string
|
||||||
|
worktree: string
|
||||||
|
directory: string
|
||||||
|
config?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ok<T> = {
|
||||||
|
ok: true
|
||||||
|
} & T
|
||||||
|
|
||||||
|
type Err<C extends string, T> = {
|
||||||
|
ok: false
|
||||||
|
code: C
|
||||||
|
} & T
|
||||||
|
|
||||||
|
export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }>
|
||||||
|
|
||||||
|
export type ManifestResult =
|
||||||
|
| Ok<{ targets: Target[] }>
|
||||||
|
| Err<"manifest_read_failed", { file: string; error: unknown }>
|
||||||
|
| Err<"manifest_no_targets", { file: string }>
|
||||||
|
|
||||||
|
export type PatchItem = {
|
||||||
|
kind: Kind
|
||||||
|
mode: Mode
|
||||||
|
file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatchErr =
|
||||||
|
| Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }>
|
||||||
|
| Err<"patch_failed", { kind: Kind; error: unknown }>
|
||||||
|
|
||||||
|
type PatchOne = Ok<{ item: PatchItem }> | PatchErr
|
||||||
|
|
||||||
|
export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })
|
||||||
|
|
||||||
|
const defaultInstallDeps: InstallDeps = {
|
||||||
|
resolve: (spec) => resolvePluginTarget(spec),
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPatchDeps: PatchDeps = {
|
||||||
|
readText: (file) => Filesystem.readText(file),
|
||||||
|
write: async (file, text) => {
|
||||||
|
await Filesystem.write(file, text)
|
||||||
|
},
|
||||||
|
exists: (file) => Filesystem.exists(file),
|
||||||
|
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginSpec(item: unknown) {
|
||||||
|
if (typeof item === "string") return item
|
||||||
|
if (!Array.isArray(item)) return
|
||||||
|
if (typeof item[0] !== "string") return
|
||||||
|
return item[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(item: unknown): Target | undefined {
|
||||||
|
if (item === "server" || item === "tui") return { kind: item }
|
||||||
|
if (!Array.isArray(item)) return
|
||||||
|
if (item[0] !== "server" && item[0] !== "tui") return
|
||||||
|
if (item.length < 2) return { kind: item[0] }
|
||||||
|
const opt = item[1]
|
||||||
|
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
|
||||||
|
return {
|
||||||
|
kind: item[0],
|
||||||
|
opts: opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargets(raw: unknown) {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const map = new Map<Kind, Target>()
|
||||||
|
for (const item of raw) {
|
||||||
|
const hit = parseTarget(item)
|
||||||
|
if (!hit) continue
|
||||||
|
map.set(hit.kind, hit)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
|
||||||
|
const pkg = parsePluginSpecifier(spec).pkg
|
||||||
|
const rows = list.map((item, i) => ({
|
||||||
|
item,
|
||||||
|
i,
|
||||||
|
spec: pluginSpec(item),
|
||||||
|
}))
|
||||||
|
const dup = rows.filter((item) => {
|
||||||
|
if (!item.spec) return false
|
||||||
|
if (item.spec === spec) return true
|
||||||
|
if (item.spec.startsWith("file://")) return false
|
||||||
|
return parsePluginSpecifier(item.spec).pkg === pkg
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dup.length) {
|
||||||
|
return {
|
||||||
|
mode: "add",
|
||||||
|
list: [...list, next],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
return {
|
||||||
|
mode: "noop",
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keep = dup[0]
|
||||||
|
if (!keep) {
|
||||||
|
return {
|
||||||
|
mode: "noop",
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dup.length === 1 && keep.spec === spec) {
|
||||||
|
return {
|
||||||
|
mode: "noop",
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = new Set(dup.map((item) => item.i))
|
||||||
|
return {
|
||||||
|
mode: "replace",
|
||||||
|
list: rows.flatMap((row) => {
|
||||||
|
if (!idx.has(row.i)) return [row.item]
|
||||||
|
if (row.i !== keep.i) return []
|
||||||
|
if (typeof row.item === "string") return [next]
|
||||||
|
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
|
||||||
|
return [[spec, ...row.item.slice(1)]]
|
||||||
|
}
|
||||||
|
return [row.item]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
|
||||||
|
const target = await dep.resolve(spec).then(
|
||||||
|
(item) => ({
|
||||||
|
ok: true as const,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
|
(error: unknown) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!target.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "install_failed",
|
||||||
|
error: target.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: target.item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPluginManifest(target: string): Promise<ManifestResult> {
|
||||||
|
const pkg = await readPluginPackage(target).then(
|
||||||
|
(item) => ({
|
||||||
|
ok: true as const,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
|
(error: unknown) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!pkg.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "manifest_read_failed",
|
||||||
|
file: target,
|
||||||
|
error: pkg.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = parseTargets(pkg.item.json["oc-plugin"])
|
||||||
|
if (!targets.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "manifest_no_targets",
|
||||||
|
file: pkg.item.pkg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
targets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchDir(input: PatchInput) {
|
||||||
|
if (input.global) return input.config ?? Global.Path.config
|
||||||
|
const git = input.vcs === "git" && input.worktree !== "/"
|
||||||
|
const root = git ? input.worktree : input.directory
|
||||||
|
return path.join(root, ".opencode")
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchName(kind: Kind): "opencode" | "tui" {
|
||||||
|
if (kind === "server") return "opencode"
|
||||||
|
return "tui"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
|
||||||
|
const name = patchName(target.kind)
|
||||||
|
await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
|
||||||
|
|
||||||
|
const files = dep.files(dir, name)
|
||||||
|
let cfg = files[0]
|
||||||
|
for (const file of files) {
|
||||||
|
if (!(await dep.exists(file))) continue
|
||||||
|
cfg = file
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "ENOENT") return "{}"
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if (src instanceof Error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "patch_failed",
|
||||||
|
kind: target.kind,
|
||||||
|
error: src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = src.trim() ? src : "{}"
|
||||||
|
|
||||||
|
const errs: JsoncParseError[] = []
|
||||||
|
const data = parseJsonc(text, errs, { allowTrailingComma: true })
|
||||||
|
if (errs.length) {
|
||||||
|
const err = errs[0]
|
||||||
|
const lines = text.substring(0, err.offset).split("\n")
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "invalid_json",
|
||||||
|
kind: target.kind,
|
||||||
|
file: cfg,
|
||||||
|
line: lines.length,
|
||||||
|
col: lines[lines.length - 1].length + 1,
|
||||||
|
parse: printParseErrorCode(err.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: unknown[] =
|
||||||
|
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
|
||||||
|
const item = target.opts ? [spec, target.opts] : spec
|
||||||
|
const out = patchPluginList(list, spec, item, force)
|
||||||
|
if (out.mode === "noop") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: {
|
||||||
|
kind: target.kind,
|
||||||
|
mode: out.mode,
|
||||||
|
file: cfg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const edits = modify(text, ["plugin"], out.list, {
|
||||||
|
formattingOptions: {
|
||||||
|
tabSize: 2,
|
||||||
|
insertSpaces: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
|
||||||
|
if (write instanceof Error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "patch_failed",
|
||||||
|
kind: target.kind,
|
||||||
|
error: write,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: {
|
||||||
|
kind: target.kind,
|
||||||
|
mode: out.mode,
|
||||||
|
file: cfg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
|
||||||
|
const dir = patchDir(input)
|
||||||
|
const items: PatchItem[] = []
|
||||||
|
for (const target of input.targets) {
|
||||||
|
const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep)
|
||||||
|
if (!hit.ok) {
|
||||||
|
return {
|
||||||
|
...hit,
|
||||||
|
dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push(hit.item)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dir,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
165
packages/opencode/src/plugin/meta.ts
Normal file
165
packages/opencode/src/plugin/meta.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Flock } from "@/util/flock"
|
||||||
|
|
||||||
|
import { parsePluginSpecifier, pluginSource } from "./shared"
|
||||||
|
|
||||||
|
export namespace PluginMeta {
|
||||||
|
type Source = "file" | "npm"
|
||||||
|
|
||||||
|
export type Entry = {
|
||||||
|
id: string
|
||||||
|
source: Source
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
requested?: string
|
||||||
|
version?: string
|
||||||
|
modified?: number
|
||||||
|
first_time: number
|
||||||
|
last_time: number
|
||||||
|
time_changed: number
|
||||||
|
load_count: number
|
||||||
|
fingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type State = "first" | "updated" | "same"
|
||||||
|
|
||||||
|
export type Touch = {
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store = Record<string, Entry>
|
||||||
|
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||||
|
type Row = Touch & { core: Core }
|
||||||
|
|
||||||
|
function storePath() {
|
||||||
|
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock(file: string) {
|
||||||
|
return `plugin-meta:${file}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTarget(spec: string, target: string) {
|
||||||
|
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
||||||
|
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifiedAt(file: string) {
|
||||||
|
const stat = Filesystem.stat(file)
|
||||||
|
if (!stat) return
|
||||||
|
const value = stat.mtimeMs
|
||||||
|
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedTarget(target: string) {
|
||||||
|
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
async function npmVersion(target: string) {
|
||||||
|
const resolved = resolvedTarget(target)
|
||||||
|
const stat = Filesystem.stat(resolved)
|
||||||
|
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||||
|
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||||
|
.then((item) => item.version)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entryCore(item: Touch): Promise<Core> {
|
||||||
|
const spec = item.spec
|
||||||
|
const target = item.target
|
||||||
|
const source = pluginSource(spec)
|
||||||
|
if (source === "file") {
|
||||||
|
const file = fileTarget(spec, target)
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
source,
|
||||||
|
spec,
|
||||||
|
target,
|
||||||
|
modified: file ? modifiedAt(file) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
source,
|
||||||
|
spec,
|
||||||
|
target,
|
||||||
|
requested: parsePluginSpecifier(spec).version,
|
||||||
|
version: await npmVersion(target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(value: Core) {
|
||||||
|
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
||||||
|
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(file: string): Promise<Store> {
|
||||||
|
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function row(item: Touch): Promise<Row> {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
core: await entryCore(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
|
||||||
|
const entry: Entry = {
|
||||||
|
...core,
|
||||||
|
first_time: prev?.first_time ?? now,
|
||||||
|
last_time: now,
|
||||||
|
time_changed: prev?.time_changed ?? now,
|
||||||
|
load_count: (prev?.load_count ?? 0) + 1,
|
||||||
|
fingerprint: fingerprint(core),
|
||||||
|
}
|
||||||
|
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||||
|
if (state === "updated") entry.time_changed = now
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
|
||||||
|
if (!items.length) return []
|
||||||
|
const file = storePath()
|
||||||
|
const rows = await Promise.all(items.map((item) => row(item)))
|
||||||
|
|
||||||
|
return Flock.withLock(lock(file), async () => {
|
||||||
|
const store = await read(file)
|
||||||
|
const now = Date.now()
|
||||||
|
const out: Array<{ state: State; entry: Entry }> = []
|
||||||
|
for (const item of rows) {
|
||||||
|
const hit = next(store[item.id], item.core, now)
|
||||||
|
store[item.id] = hit.entry
|
||||||
|
out.push(hit)
|
||||||
|
}
|
||||||
|
await Filesystem.writeJson(file, store)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
|
||||||
|
return touchMany([{ spec, target, id }]).then((item) => {
|
||||||
|
const hit = item[0]
|
||||||
|
if (hit) return hit
|
||||||
|
throw new Error("Failed to touch plugin metadata.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(): Promise<Store> {
|
||||||
|
const file = storePath()
|
||||||
|
return Flock.withLock(lock(file), async () => read(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
149
packages/opencode/src/plugin/shared.ts
Normal file
149
packages/opencode/src/plugin/shared.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
|
|||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
hooks: Record<ProviderID, Hook>
|
hooks: Record<ProviderID, Hook>
|
||||||
pending: Map<ProviderID, AuthOuathResult>
|
pending: Map<ProviderID, AuthOAuthResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||||
@@ -127,7 +127,7 @@ export namespace ProviderAuth {
|
|||||||
: Result.failVoid,
|
: Result.failVoid,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
|
|||||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||||
import { ProviderError } from "@/provider/error"
|
import { ProviderError } from "@/provider/error"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
import type { SystemError } from "bun"
|
import type { SystemError } from "bun"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
@@ -990,7 +991,7 @@ export namespace MessageV2 {
|
|||||||
{ cause: e },
|
{ cause: e },
|
||||||
).toObject()
|
).toObject()
|
||||||
case e instanceof Error:
|
case e instanceof Error:
|
||||||
return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
|
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
|
||||||
default:
|
default:
|
||||||
try {
|
try {
|
||||||
const parsed = ProviderError.parseStreamError(e)
|
const parsed = ProviderError.parseStreamError(e)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { ProviderID, ModelID } from "../provider/schema"
|
import { ProviderID, ModelID } from "../provider/schema"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
import DESCRIPTION from "./batch.txt"
|
import DESCRIPTION from "./batch.txt"
|
||||||
|
|
||||||
const DISALLOWED = new Set(["batch"])
|
const DISALLOWED = new Set(["batch"])
|
||||||
@@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: call.parameters,
|
input: call.parameters,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorMessage(error),
|
||||||
time: {
|
time: {
|
||||||
start: callStartTime,
|
start: callStartTime,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
|
|||||||
77
packages/opencode/src/util/error.ts
Normal file
77
packages/opencode/src/util/error.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { isRecord } from "./record"
|
||||||
|
|
||||||
|
export function errorFormat(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.stack ?? `${error.name}: ${error.message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "object" && error !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error, null, 2)
|
||||||
|
} catch {
|
||||||
|
return "Unexpected error (unserializable)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message) return error.message
|
||||||
|
if (error.name) return error.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(error) && typeof error.message === "string" && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(error)
|
||||||
|
if (text && text !== "[object Object]") return text
|
||||||
|
|
||||||
|
const formatted = errorFormat(error)
|
||||||
|
if (formatted && formatted !== "{}") return formatted
|
||||||
|
return "unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorData(error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: error.name,
|
||||||
|
message: errorMessage(error),
|
||||||
|
stack: error.stack,
|
||||||
|
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
|
||||||
|
formatted: errorFormatted(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(error)) {
|
||||||
|
return {
|
||||||
|
type: typeof error,
|
||||||
|
message: errorMessage(error),
|
||||||
|
formatted: errorFormatted(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
|
||||||
|
const value = error[key]
|
||||||
|
if (value === undefined) return acc
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
acc[key] = value
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
acc[key] = value instanceof Error ? value.message : String(value)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (typeof data.message !== "string") data.message = errorMessage(error)
|
||||||
|
if (typeof data.type !== "string") data.type = error.constructor?.name
|
||||||
|
data.formatted = errorFormatted(error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorFormatted(error: unknown) {
|
||||||
|
const formatted = errorFormat(error)
|
||||||
|
if (formatted !== "{}") return formatted
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
333
packages/opencode/src/util/flock.ts
Normal file
333
packages/opencode/src/util/flock.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { randomBytes, randomUUID } from "crypto"
|
||||||
|
import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Hash } from "@/util/hash"
|
||||||
|
|
||||||
|
export namespace Flock {
|
||||||
|
const root = path.join(Global.Path.state, "locks")
|
||||||
|
// Defaults for callers that do not provide timing options.
|
||||||
|
const defaultOpts = {
|
||||||
|
staleMs: 60_000,
|
||||||
|
timeoutMs: 5 * 60_000,
|
||||||
|
baseDelayMs: 100,
|
||||||
|
maxDelayMs: 2_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitEvent {
|
||||||
|
key: string
|
||||||
|
attempt: number
|
||||||
|
delay: number
|
||||||
|
waited: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Wait = (input: WaitEvent) => void | Promise<void>
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
dir?: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
staleMs?: number
|
||||||
|
timeoutMs?: number
|
||||||
|
baseDelayMs?: number
|
||||||
|
maxDelayMs?: number
|
||||||
|
onWait?: Wait
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opts = {
|
||||||
|
staleMs: number
|
||||||
|
timeoutMs: number
|
||||||
|
baseDelayMs: number
|
||||||
|
maxDelayMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Owned = {
|
||||||
|
acquired: true
|
||||||
|
startHeartbeat: (intervalMs?: number) => void
|
||||||
|
release: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lease {
|
||||||
|
release: () => Promise<void>
|
||||||
|
[Symbol.asyncDispose]: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function code(err: unknown) {
|
||||||
|
if (typeof err !== "object" || err === null || !("code" in err)) return
|
||||||
|
const value = err.code
|
||||||
|
if (typeof value !== "string") return
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(signal.reason ?? new Error("Aborted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined
|
||||||
|
|
||||||
|
const done = () => {
|
||||||
|
signal?.removeEventListener("abort", abort)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
signal?.removeEventListener("abort", abort)
|
||||||
|
reject(signal?.reason ?? new Error("Aborted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", abort, { once: true })
|
||||||
|
timer = setTimeout(done, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function jitter(ms: number) {
|
||||||
|
const j = Math.floor(ms * 0.3)
|
||||||
|
const d = Math.floor(Math.random() * (2 * j + 1)) - j
|
||||||
|
return Math.max(0, ms + d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mono() {
|
||||||
|
return performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function wall() {
|
||||||
|
return performance.timeOrigin + mono()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stats(file: string) {
|
||||||
|
try {
|
||||||
|
return await stat(file)
|
||||||
|
} catch (err) {
|
||||||
|
const errCode = code(err)
|
||||||
|
if (errCode === "ENOENT" || errCode === "ENOTDIR") return
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
|
||||||
|
// Stale detection allows automatic recovery after crashed owners.
|
||||||
|
const now = wall()
|
||||||
|
const heartbeat = await stats(heartbeatPath)
|
||||||
|
if (heartbeat) {
|
||||||
|
return now - heartbeat.mtimeMs > staleMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await stats(metaPath)
|
||||||
|
if (meta) {
|
||||||
|
return now - meta.mtimeMs > staleMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = await stats(lockDir)
|
||||||
|
if (!dir) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return now - dir.mtimeMs > staleMs
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
|
||||||
|
const token = randomUUID?.() ?? randomBytes(16).toString("hex")
|
||||||
|
const metaPath = path.join(lockDir, "meta.json")
|
||||||
|
const heartbeatPath = path.join(lockDir, "heartbeat")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(lockDir, { mode: 0o700 })
|
||||||
|
} catch (err) {
|
||||||
|
if (code(err) !== "EEXIST") {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
||||||
|
return { acquired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakerPath = lockDir + ".breaker"
|
||||||
|
try {
|
||||||
|
await mkdir(breakerPath, { mode: 0o700 })
|
||||||
|
} catch (claimErr) {
|
||||||
|
const errCode = code(claimErr)
|
||||||
|
if (errCode === "EEXIST") {
|
||||||
|
const breaker = await stats(breakerPath)
|
||||||
|
if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
|
||||||
|
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
return { acquired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
||||||
|
return { acquired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw claimErr
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Breaker ownership ensures only one contender performs stale cleanup.
|
||||||
|
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
||||||
|
return { acquired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(lockDir, { recursive: true, force: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(lockDir, { mode: 0o700 })
|
||||||
|
} catch (retryErr) {
|
||||||
|
const errCode = code(retryErr)
|
||||||
|
if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
|
||||||
|
return { acquired: false }
|
||||||
|
}
|
||||||
|
throw retryErr
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
token,
|
||||||
|
pid: process.pid,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
|
||||||
|
await rm(lockDir, { recursive: true, force: true })
|
||||||
|
throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
|
||||||
|
})
|
||||||
|
|
||||||
|
await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
|
||||||
|
await rm(lockDir, { recursive: true, force: true })
|
||||||
|
throw new Error("Lock acquired but meta.json already existed (possible compromise).")
|
||||||
|
})
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined
|
||||||
|
|
||||||
|
const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
|
||||||
|
if (timer) return
|
||||||
|
// Heartbeat prevents long critical sections from being evicted as stale.
|
||||||
|
timer = setInterval(() => {
|
||||||
|
const t = new Date()
|
||||||
|
void utimes(heartbeatPath, t, t).catch(() => undefined)
|
||||||
|
}, intervalMs)
|
||||||
|
timer.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = async () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await readFile(metaPath, "utf8")
|
||||||
|
.then((raw) => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") return {}
|
||||||
|
return {
|
||||||
|
token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const errCode = code(err)
|
||||||
|
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
||||||
|
throw new Error("Refusing to release: lock is compromised (metadata missing).")
|
||||||
|
}
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
throw new Error("Refusing to release: lock is compromised (metadata invalid).")
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
// Token check prevents deleting a lock that was re-acquired by another process.
|
||||||
|
if (current.token !== token) {
|
||||||
|
throw new Error("Refusing to release: lock token mismatch (not the owner).")
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(lockDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
acquired: true,
|
||||||
|
startHeartbeat,
|
||||||
|
release,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireLockDir(
|
||||||
|
lockDir: string,
|
||||||
|
input: { key: string; onWait?: Wait; signal?: AbortSignal },
|
||||||
|
opts: Opts,
|
||||||
|
) {
|
||||||
|
const stop = mono() + opts.timeoutMs
|
||||||
|
let attempt = 0
|
||||||
|
let waited = 0
|
||||||
|
let delay = opts.baseDelayMs
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
const res = await tryAcquireLockDir(lockDir, opts)
|
||||||
|
if (res.acquired) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mono() > stop) {
|
||||||
|
throw new Error(`Timed out waiting for lock: ${input.key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt += 1
|
||||||
|
const ms = jitter(delay)
|
||||||
|
await input.onWait?.({
|
||||||
|
key: input.key,
|
||||||
|
attempt,
|
||||||
|
delay: ms,
|
||||||
|
waited,
|
||||||
|
})
|
||||||
|
await sleep(ms, input.signal)
|
||||||
|
waited += ms
|
||||||
|
delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquire(key: string, input: Options = {}): Promise<Lease> {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
const cfg: Opts = {
|
||||||
|
staleMs: input.staleMs ?? defaultOpts.staleMs,
|
||||||
|
timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
|
||||||
|
baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
|
||||||
|
maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
|
||||||
|
}
|
||||||
|
const dir = input.dir ?? root
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
const lockfile = path.join(dir, Hash.fast(key) + ".lock")
|
||||||
|
const lock = await acquireLockDir(
|
||||||
|
lockfile,
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
onWait: input.onWait,
|
||||||
|
signal: input.signal,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
lock.startHeartbeat()
|
||||||
|
|
||||||
|
const release = () => lock.release()
|
||||||
|
return {
|
||||||
|
release,
|
||||||
|
[Symbol.asyncDispose]() {
|
||||||
|
return release()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
|
||||||
|
await using _ = await acquire(key, input)
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
return await fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export function online() {
|
||||||
|
const nav = globalThis.navigator
|
||||||
|
if (!nav || typeof nav.onLine !== "boolean") return true
|
||||||
|
return nav.onLine
|
||||||
|
}
|
||||||
|
|
||||||
export function proxied() {
|
export function proxied() {
|
||||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ChildProcess } from "child_process"
|
import { type ChildProcess } from "child_process"
|
||||||
import launch from "cross-spawn"
|
import launch from "cross-spawn"
|
||||||
import { buffer } from "node:stream/consumers"
|
import { buffer } from "node:stream/consumers"
|
||||||
|
import { errorMessage } from "./error"
|
||||||
|
|
||||||
export namespace Process {
|
export namespace Process {
|
||||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||||
@@ -136,7 +137,7 @@ export namespace Process {
|
|||||||
return {
|
return {
|
||||||
code: 1,
|
code: 1,
|
||||||
stdout: Buffer.alloc(0),
|
stdout: Buffer.alloc(0),
|
||||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
stderr: Buffer.from(errorMessage(err)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (out.code === 0 || opts.nothrow) return out
|
if (out.code === 0 || opts.nothrow) return out
|
||||||
|
|||||||
3
packages/opencode/src/util/record.ts
Normal file
3
packages/opencode/src/util/record.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql"
|
|||||||
import type { ProjectID } from "../project/schema"
|
import type { ProjectID } from "../project/schema"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Slug } from "@opencode-ai/util/slug"
|
import { Slug } from "@opencode-ai/util/slug"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||||
@@ -260,7 +261,7 @@ export namespace Worktree {
|
|||||||
})
|
})
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = errorMessage(error)
|
||||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
@@ -344,9 +345,12 @@ export namespace Worktree {
|
|||||||
|
|
||||||
function cleanDirectory(target: string) {
|
function cleanDirectory(target: string) {
|
||||||
return Effect.promise(() =>
|
return Effect.promise(() =>
|
||||||
import("fs/promises").then((fsp) =>
|
import("fs/promises")
|
||||||
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||||
),
|
.catch((error) => {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
90
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { ParsedKey } from "@opentui/core"
|
||||||
|
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||||
|
|
||||||
|
describe("createPluginKeybind", () => {
|
||||||
|
const defaults = {
|
||||||
|
open: "ctrl+o",
|
||||||
|
close: "escape",
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses defaults when overrides are missing", () => {
|
||||||
|
const api = {
|
||||||
|
match: () => false,
|
||||||
|
print: (key: string) => key,
|
||||||
|
}
|
||||||
|
const bind = createPluginKeybind(api, defaults)
|
||||||
|
|
||||||
|
expect(bind.all).toEqual(defaults)
|
||||||
|
expect(bind.get("open")).toBe("ctrl+o")
|
||||||
|
expect(bind.get("close")).toBe("escape")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies valid overrides", () => {
|
||||||
|
const api = {
|
||||||
|
match: () => false,
|
||||||
|
print: (key: string) => key,
|
||||||
|
}
|
||||||
|
const bind = createPluginKeybind(api, defaults, {
|
||||||
|
open: "ctrl+alt+o",
|
||||||
|
close: "q",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bind.all).toEqual({
|
||||||
|
open: "ctrl+alt+o",
|
||||||
|
close: "q",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores invalid overrides", () => {
|
||||||
|
const api = {
|
||||||
|
match: () => false,
|
||||||
|
print: (key: string) => key,
|
||||||
|
}
|
||||||
|
const bind = createPluginKeybind(api, defaults, {
|
||||||
|
open: " ",
|
||||||
|
close: 1,
|
||||||
|
extra: "ctrl+x",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bind.all).toEqual(defaults)
|
||||||
|
expect(bind.get("extra")).toBe("extra")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves names for match", () => {
|
||||||
|
const list: string[] = []
|
||||||
|
const api = {
|
||||||
|
match: (key: string) => {
|
||||||
|
list.push(key)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
print: (key: string) => key,
|
||||||
|
}
|
||||||
|
const bind = createPluginKeybind(api, defaults, {
|
||||||
|
open: "ctrl+shift+o",
|
||||||
|
})
|
||||||
|
|
||||||
|
bind.match("open", { name: "x" } as ParsedKey)
|
||||||
|
bind.match("ctrl+k", { name: "x" } as ParsedKey)
|
||||||
|
|
||||||
|
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves names for print", () => {
|
||||||
|
const list: string[] = []
|
||||||
|
const api = {
|
||||||
|
match: () => false,
|
||||||
|
print: (key: string) => {
|
||||||
|
list.push(key)
|
||||||
|
return `print:${key}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const bind = createPluginKeybind(api, defaults, {
|
||||||
|
close: "q",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bind.print("close")).toBe("print:q")
|
||||||
|
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
|
||||||
|
expect(list).toEqual(["q", "ctrl+p"])
|
||||||
|
})
|
||||||
|
})
|
||||||
61
packages/opencode/test/cli/tui/plugin-add.test.ts
Normal file
61
packages/opencode/test/cli/tui/plugin-add.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
|
import { TuiConfig } from "../../../src/config/tui"
|
||||||
|
|
||||||
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
test("adds tui plugin at runtime from spec", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "add-plugin.ts")
|
||||||
|
const spec = pathToFileURL(file).href
|
||||||
|
const marker = path.join(dir, "add.txt")
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
`export default {
|
||||||
|
id: "demo.add",
|
||||||
|
tui: async () => {
|
||||||
|
await Bun.write(${JSON.stringify(marker)}, "called")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { spec, marker }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [],
|
||||||
|
plugin_meta: undefined,
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
|
||||||
|
id: "demo.add",
|
||||||
|
source: "file",
|
||||||
|
spec: tmp.extra.spec,
|
||||||
|
target: tmp.extra.spec,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
95
packages/opencode/test/cli/tui/plugin-install.test.ts
Normal file
95
packages/opencode/test/cli/tui/plugin-install.test.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
225
packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
Normal file
225
packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
Normal 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 },
|
||||||
|
)
|
||||||
132
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
Normal file
132
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
|
import { TuiConfig } from "../../../src/config/tui"
|
||||||
|
import { BunProc } from "../../../src/bun"
|
||||||
|
|
||||||
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
test("loads npm tui plugin from package ./tui export", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const marker = path.join(dir, "tui-called.txt")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
||||||
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
||||||
|
await Bun.write(path.join(mod, "server.js"), "export default {}\n")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "tui.js"),
|
||||||
|
`export default {
|
||||||
|
id: "demo.tui.export",
|
||||||
|
tui: async (_api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
await Bun.write(${JSON.stringify(marker)}, "called")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mod, marker, spec: "acme-plugin@1.0.0" }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
|
plugin_meta: {
|
||||||
|
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
|
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
|
||||||
|
expect(hit?.enabled).toBe(true)
|
||||||
|
expect(hit?.active).toBe(true)
|
||||||
|
expect(hit?.source).toBe("npm")
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
install.mockRestore()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects npm tui export that resolves outside plugin directory", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const outside = path.join(dir, "outside")
|
||||||
|
const marker = path.join(dir, "outside-called.txt")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await fs.mkdir(outside, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(outside, "tui.js"),
|
||||||
|
`export default {
|
||||||
|
id: "demo.outside",
|
||||||
|
tui: async () => {
|
||||||
|
await Bun.write(${JSON.stringify(marker)}, "outside")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
|
||||||
|
|
||||||
|
return { mod, marker, spec: "acme-plugin@1.0.0" }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [tmp.extra.spec],
|
||||||
|
plugin_meta: {
|
||||||
|
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
// plugin code never ran
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
|
// plugin not listed
|
||||||
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
install.mockRestore()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
71
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
Normal file
71
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
563
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
563
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { beforeAll, describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
|
import { Global } from "../../../src/global"
|
||||||
|
import { TuiConfig } from "../../../src/config/tui"
|
||||||
|
import { Config } from "../../../src/config/config"
|
||||||
|
import { Filesystem } from "../../../src/util/filesystem"
|
||||||
|
|
||||||
|
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||||
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
local: Row
|
||||||
|
global: Row
|
||||||
|
invalid: Row
|
||||||
|
preloaded: Row
|
||||||
|
fn_called: boolean
|
||||||
|
local_installed: string
|
||||||
|
global_installed: string
|
||||||
|
preloaded_installed: string
|
||||||
|
leaked_local_to_global: boolean
|
||||||
|
leaked_global_to_local: boolean
|
||||||
|
local_theme: string
|
||||||
|
global_theme: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function row(file: string): Promise<Row> {
|
||||||
|
return Filesystem.readJson<Row>(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<Data> {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
||||||
|
const backup = await Bun.file(globalConfigPath)
|
||||||
|
.text()
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const localPluginPath = path.join(dir, "local-plugin.ts")
|
||||||
|
const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
|
||||||
|
const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
|
||||||
|
const globalPluginPath = path.join(dir, "global-plugin.ts")
|
||||||
|
const localSpec = pathToFileURL(localPluginPath).href
|
||||||
|
const invalidSpec = pathToFileURL(invalidPluginPath).href
|
||||||
|
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
|
||||||
|
const globalSpec = pathToFileURL(globalPluginPath).href
|
||||||
|
const localThemeFile = `local-theme-${stamp}.json`
|
||||||
|
const invalidThemeFile = `invalid-theme-${stamp}.json`
|
||||||
|
const globalThemeFile = `global-theme-${stamp}.json`
|
||||||
|
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
|
||||||
|
const localThemeName = localThemeFile.replace(/\.json$/, "")
|
||||||
|
const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
|
||||||
|
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
|
||||||
|
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
|
||||||
|
const localThemePath = path.join(dir, localThemeFile)
|
||||||
|
const invalidThemePath = path.join(dir, invalidThemeFile)
|
||||||
|
const globalThemePath = path.join(dir, globalThemeFile)
|
||||||
|
const preloadedThemePath = path.join(dir, preloadedThemeFile)
|
||||||
|
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
|
||||||
|
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
|
||||||
|
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
|
||||||
|
const fnMarker = path.join(dir, "function-called.txt")
|
||||||
|
const localMarker = path.join(dir, "local-called.json")
|
||||||
|
const invalidMarker = path.join(dir, "invalid-called.json")
|
||||||
|
const globalMarker = path.join(dir, "global-called.json")
|
||||||
|
const preloadedMarker = path.join(dir, "preloaded-called.json")
|
||||||
|
const localConfigPath = path.join(dir, "tui.json")
|
||||||
|
|
||||||
|
await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
|
||||||
|
await Bun.write(invalidThemePath, "{ invalid json }")
|
||||||
|
await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
|
||||||
|
await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
|
||||||
|
await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
localPluginPath,
|
||||||
|
`export const ignored = async (_input, options) => {
|
||||||
|
if (!options?.fn_marker) return
|
||||||
|
await Bun.write(options.fn_marker, "called")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "demo.local",
|
||||||
|
tui: async (api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
const cfg_theme = api.tuiConfig.theme
|
||||||
|
const cfg_diff = api.tuiConfig.diff_style
|
||||||
|
const cfg_speed = api.tuiConfig.scroll_speed
|
||||||
|
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
|
||||||
|
const cfg_submit = api.tuiConfig.keybinds?.input_submit
|
||||||
|
const key = api.keybind.create(
|
||||||
|
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
|
||||||
|
options.keybinds,
|
||||||
|
)
|
||||||
|
const kv_before = api.kv.get(options.kv_key, "missing")
|
||||||
|
api.kv.set(options.kv_key, "stored")
|
||||||
|
const kv_after = api.kv.get(options.kv_key, "missing")
|
||||||
|
const diff = api.state.session.diff(options.session_id)
|
||||||
|
const todo = api.state.session.todo(options.session_id)
|
||||||
|
const lsp = api.state.lsp()
|
||||||
|
const mcp = api.state.mcp()
|
||||||
|
const depth_before = api.ui.dialog.depth
|
||||||
|
const open_before = api.ui.dialog.open
|
||||||
|
const size_before = api.ui.dialog.size
|
||||||
|
api.ui.dialog.setSize("large")
|
||||||
|
const size_after = api.ui.dialog.size
|
||||||
|
api.ui.dialog.replace(() => null)
|
||||||
|
const depth_after = api.ui.dialog.depth
|
||||||
|
const open_after = api.ui.dialog.open
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
const open_clear = api.ui.dialog.open
|
||||||
|
const before = api.theme.has(options.theme_name)
|
||||||
|
const set_missing = api.theme.set(options.theme_name)
|
||||||
|
await api.theme.install(options.theme_path)
|
||||||
|
const after = api.theme.has(options.theme_name)
|
||||||
|
const set_installed = api.theme.set(options.theme_name)
|
||||||
|
const first = await Bun.file(options.dest).text()
|
||||||
|
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
|
||||||
|
await api.theme.install(options.theme_path)
|
||||||
|
const second = await Bun.file(options.dest).text()
|
||||||
|
await Bun.write(
|
||||||
|
options.marker,
|
||||||
|
JSON.stringify({
|
||||||
|
before,
|
||||||
|
set_missing,
|
||||||
|
after,
|
||||||
|
set_installed,
|
||||||
|
selected: api.theme.selected,
|
||||||
|
same: first === second,
|
||||||
|
key_modal: key.get("modal"),
|
||||||
|
key_close: key.get("close"),
|
||||||
|
key_unknown: key.get("ctrl+k"),
|
||||||
|
key_print: key.print("modal"),
|
||||||
|
kv_before,
|
||||||
|
kv_after,
|
||||||
|
kv_ready: api.kv.ready,
|
||||||
|
diff_count: diff.length,
|
||||||
|
diff_file: diff[0]?.file,
|
||||||
|
todo_count: todo.length,
|
||||||
|
todo_first: todo[0]?.content,
|
||||||
|
lsp_count: lsp.length,
|
||||||
|
mcp_count: mcp.length,
|
||||||
|
mcp_first: mcp[0]?.name,
|
||||||
|
depth_before,
|
||||||
|
open_before,
|
||||||
|
size_before,
|
||||||
|
size_after,
|
||||||
|
depth_after,
|
||||||
|
open_after,
|
||||||
|
open_clear,
|
||||||
|
cfg_theme,
|
||||||
|
cfg_diff,
|
||||||
|
cfg_speed,
|
||||||
|
cfg_accel,
|
||||||
|
cfg_submit,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
invalidPluginPath,
|
||||||
|
`export default {
|
||||||
|
id: "demo.invalid",
|
||||||
|
tui: async (api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
const before = api.theme.has(options.theme_name)
|
||||||
|
const set_missing = api.theme.set(options.theme_name)
|
||||||
|
await api.theme.install(options.theme_path)
|
||||||
|
const after = api.theme.has(options.theme_name)
|
||||||
|
const set_installed = api.theme.set(options.theme_name)
|
||||||
|
await Bun.write(
|
||||||
|
options.marker,
|
||||||
|
JSON.stringify({
|
||||||
|
before,
|
||||||
|
set_missing,
|
||||||
|
after,
|
||||||
|
set_installed,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
preloadedPluginPath,
|
||||||
|
`export default {
|
||||||
|
id: "demo.preloaded",
|
||||||
|
tui: async (api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
const before = api.theme.has(options.theme_name)
|
||||||
|
await api.theme.install(options.theme_path)
|
||||||
|
const after = api.theme.has(options.theme_name)
|
||||||
|
const text = await Bun.file(options.dest).text()
|
||||||
|
await Bun.write(
|
||||||
|
options.marker,
|
||||||
|
JSON.stringify({
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
text,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
globalPluginPath,
|
||||||
|
`export default {
|
||||||
|
id: "demo.global",
|
||||||
|
tui: async (api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
await api.theme.install(options.theme_path)
|
||||||
|
const has = api.theme.has(options.theme_name)
|
||||||
|
const set_installed = api.theme.set(options.theme_name)
|
||||||
|
await Bun.write(
|
||||||
|
options.marker,
|
||||||
|
JSON.stringify({
|
||||||
|
has,
|
||||||
|
set_installed,
|
||||||
|
selected: api.theme.selected,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
globalConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [
|
||||||
|
[globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
localConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [
|
||||||
|
[
|
||||||
|
localSpec,
|
||||||
|
{
|
||||||
|
fn_marker: fnMarker,
|
||||||
|
marker: localMarker,
|
||||||
|
source: localThemePath,
|
||||||
|
dest: localDest,
|
||||||
|
theme_path: `./${localThemeFile}`,
|
||||||
|
theme_name: localThemeName,
|
||||||
|
kv_key: "plugin_state_key",
|
||||||
|
session_id: "ses_test",
|
||||||
|
keybinds: {
|
||||||
|
modal: "ctrl+alt+m",
|
||||||
|
close: "q",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
invalidSpec,
|
||||||
|
{
|
||||||
|
marker: invalidMarker,
|
||||||
|
theme_path: `./${invalidThemeFile}`,
|
||||||
|
theme_name: invalidThemeName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
preloadedSpec,
|
||||||
|
{
|
||||||
|
marker: preloadedMarker,
|
||||||
|
dest: preloadedDest,
|
||||||
|
theme_path: `./${preloadedThemeFile}`,
|
||||||
|
theme_name: preloadedThemeName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
localThemeFile,
|
||||||
|
invalidThemeFile,
|
||||||
|
globalThemeFile,
|
||||||
|
preloadedThemeFile,
|
||||||
|
localThemeName,
|
||||||
|
invalidThemeName,
|
||||||
|
globalThemeName,
|
||||||
|
preloadedThemeName,
|
||||||
|
localDest,
|
||||||
|
globalDest,
|
||||||
|
preloadedDest,
|
||||||
|
localPluginPath,
|
||||||
|
invalidPluginPath,
|
||||||
|
globalPluginPath,
|
||||||
|
preloadedPluginPath,
|
||||||
|
localSpec,
|
||||||
|
invalidSpec,
|
||||||
|
globalSpec,
|
||||||
|
preloadedSpec,
|
||||||
|
fnMarker,
|
||||||
|
localMarker,
|
||||||
|
invalidMarker,
|
||||||
|
globalMarker,
|
||||||
|
preloadedMarker,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const install = spyOn(Config, "installDependencies").mockResolvedValue()
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
||||||
|
|
||||||
|
await TuiPluginRuntime.init(
|
||||||
|
createTuiPluginApi({
|
||||||
|
tuiConfig: {
|
||||||
|
theme: "smoke",
|
||||||
|
diff_style: "stacked",
|
||||||
|
scroll_speed: 1.5,
|
||||||
|
scroll_acceleration: { enabled: true },
|
||||||
|
keybinds: {
|
||||||
|
input_submit: "ctrl+enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keybind: {
|
||||||
|
print: (key) => `print:${key}`,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
session: {
|
||||||
|
diff(sessionID) {
|
||||||
|
if (sessionID !== "ses_test") return []
|
||||||
|
return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
|
||||||
|
},
|
||||||
|
todo(sessionID) {
|
||||||
|
if (sessionID !== "ses_test") return []
|
||||||
|
return [{ content: "ship it", status: "pending" }]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lsp() {
|
||||||
|
return [{ id: "ts", root: "/tmp/project", status: "connected" }]
|
||||||
|
},
|
||||||
|
mcp() {
|
||||||
|
return [{ name: "github", status: "connected" }]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
has(name) {
|
||||||
|
return allThemes()[name] !== undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const local = await row(tmp.extra.localMarker)
|
||||||
|
const global = await row(tmp.extra.globalMarker)
|
||||||
|
const invalid = await row(tmp.extra.invalidMarker)
|
||||||
|
const preloaded = await row(tmp.extra.preloadedMarker)
|
||||||
|
const fn_called = await fs
|
||||||
|
.readFile(tmp.extra.fnMarker, "utf8")
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
|
||||||
|
const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
|
||||||
|
const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
|
||||||
|
const leaked_local_to_global = await fs
|
||||||
|
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
const leaked_global_to_local = await fs
|
||||||
|
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
local,
|
||||||
|
global,
|
||||||
|
invalid,
|
||||||
|
preloaded,
|
||||||
|
fn_called,
|
||||||
|
local_installed,
|
||||||
|
global_installed,
|
||||||
|
preloaded_installed,
|
||||||
|
leaked_local_to_global,
|
||||||
|
leaked_global_to_local,
|
||||||
|
local_theme: tmp.extra.localThemeName,
|
||||||
|
global_theme: tmp.extra.globalThemeName,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
install.mockRestore()
|
||||||
|
if (backup === undefined) {
|
||||||
|
await fs.rm(globalConfigPath, { force: true })
|
||||||
|
} else {
|
||||||
|
await Bun.write(globalConfigPath, backup)
|
||||||
|
}
|
||||||
|
await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("continues loading when a plugin is missing config metadata", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const bad = path.join(dir, "missing-meta-plugin.ts")
|
||||||
|
const good = path.join(dir, "next-plugin.ts")
|
||||||
|
const bare = path.join(dir, "plain-plugin.ts")
|
||||||
|
const badSpec = pathToFileURL(bad).href
|
||||||
|
const goodSpec = pathToFileURL(good).href
|
||||||
|
const bareSpec = pathToFileURL(bare).href
|
||||||
|
const goodMarker = path.join(dir, "next-called.txt")
|
||||||
|
const bareMarker = path.join(dir, "plain-called.txt")
|
||||||
|
|
||||||
|
for (const [file, id] of [
|
||||||
|
[bad, "demo.missing-meta"],
|
||||||
|
[good, "demo.next"],
|
||||||
|
] as const) {
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
`export default {
|
||||||
|
id: "${id}",
|
||||||
|
tui: async (_api, options) => {
|
||||||
|
if (!options?.marker) return
|
||||||
|
await Bun.write(options.marker, "called")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
bare,
|
||||||
|
`export default {
|
||||||
|
id: "demo.plain",
|
||||||
|
tui: async (_api, options) => {
|
||||||
|
await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [
|
||||||
|
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
||||||
|
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||||
|
tmp.extra.bareSpec,
|
||||||
|
],
|
||||||
|
plugin_meta: {
|
||||||
|
[tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
[tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
// bad plugin was skipped (no metadata entry)
|
||||||
|
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
|
||||||
|
// good plugin loaded fine
|
||||||
|
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
|
||||||
|
// bare string spec gets undefined options
|
||||||
|
await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tui.plugin.loader", () => {
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
data = await load()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
|
||||||
|
expect(data.local.key_modal).toBe("ctrl+alt+m")
|
||||||
|
expect(data.local.key_close).toBe("q")
|
||||||
|
expect(data.local.key_unknown).toBe("ctrl+k")
|
||||||
|
expect(data.local.key_print).toBe("print:ctrl+alt+m")
|
||||||
|
expect(data.local.kv_before).toBe("missing")
|
||||||
|
expect(data.local.kv_after).toBe("stored")
|
||||||
|
expect(data.local.kv_ready).toBe(true)
|
||||||
|
expect(data.local.diff_count).toBe(1)
|
||||||
|
expect(data.local.diff_file).toBe("src/app.ts")
|
||||||
|
expect(data.local.todo_count).toBe(1)
|
||||||
|
expect(data.local.todo_first).toBe("ship it")
|
||||||
|
expect(data.local.lsp_count).toBe(1)
|
||||||
|
expect(data.local.mcp_count).toBe(1)
|
||||||
|
expect(data.local.mcp_first).toBe("github")
|
||||||
|
expect(data.local.depth_before).toBe(0)
|
||||||
|
expect(data.local.open_before).toBe(false)
|
||||||
|
expect(data.local.size_before).toBe("medium")
|
||||||
|
expect(data.local.size_after).toBe("large")
|
||||||
|
expect(data.local.depth_after).toBe(1)
|
||||||
|
expect(data.local.open_after).toBe(true)
|
||||||
|
expect(data.local.open_clear).toBe(false)
|
||||||
|
expect(data.local.cfg_theme).toBe("smoke")
|
||||||
|
expect(data.local.cfg_diff).toBe("stacked")
|
||||||
|
expect(data.local.cfg_speed).toBe(1.5)
|
||||||
|
expect(data.local.cfg_accel).toBe(true)
|
||||||
|
expect(data.local.cfg_submit).toBe("ctrl+enter")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("installs themes in the correct scope and remains resilient", () => {
|
||||||
|
expect(data.local.before).toBe(false)
|
||||||
|
expect(data.local.set_missing).toBe(false)
|
||||||
|
expect(data.local.after).toBe(true)
|
||||||
|
expect(data.local.set_installed).toBe(true)
|
||||||
|
expect(data.local.selected).toBe(data.local_theme)
|
||||||
|
expect(data.local.same).toBe(true)
|
||||||
|
|
||||||
|
expect(data.global.has).toBe(true)
|
||||||
|
expect(data.global.set_installed).toBe(true)
|
||||||
|
expect(data.global.selected).toBe(data.global_theme)
|
||||||
|
|
||||||
|
expect(data.invalid.before).toBe(false)
|
||||||
|
expect(data.invalid.set_missing).toBe(false)
|
||||||
|
expect(data.invalid.after).toBe(false)
|
||||||
|
expect(data.invalid.set_installed).toBe(false)
|
||||||
|
|
||||||
|
expect(data.preloaded.before).toBe(true)
|
||||||
|
expect(data.preloaded.after).toBe(true)
|
||||||
|
expect(data.preloaded.text).toContain("#303030")
|
||||||
|
expect(data.preloaded.text).not.toContain("#f0f0f0")
|
||||||
|
|
||||||
|
expect(data.fn_called).toBe(false)
|
||||||
|
expect(data.local_installed).toContain("#101010")
|
||||||
|
expect(data.local_installed).not.toContain("#fefefe")
|
||||||
|
expect(data.global_installed).toContain("#202020")
|
||||||
|
expect(data.preloaded_installed).toContain("#303030")
|
||||||
|
expect(data.preloaded_installed).not.toContain("#f0f0f0")
|
||||||
|
expect(data.leaked_local_to_global).toBe(false)
|
||||||
|
expect(data.leaked_global_to_local).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
157
packages/opencode/test/cli/tui/plugin-toggle.test.ts
Normal file
157
packages/opencode/test/cli/tui/plugin-toggle.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { expect, spyOn, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
|
import { TuiConfig } from "../../../src/config/tui"
|
||||||
|
|
||||||
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
test("toggles plugin runtime state by exported id", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "toggle-plugin.ts")
|
||||||
|
const spec = pathToFileURL(file).href
|
||||||
|
const marker = path.join(dir, "toggle.txt")
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
`export default {
|
||||||
|
id: "demo.toggle",
|
||||||
|
tui: async (api, options) => {
|
||||||
|
const text = await Bun.file(options.marker).text().catch(() => "")
|
||||||
|
await Bun.write(options.marker, text + "start\\n")
|
||||||
|
api.lifecycle.onDispose(async () => {
|
||||||
|
const next = await Bun.file(options.marker).text().catch(() => "")
|
||||||
|
await Bun.write(options.marker, next + "stop\\n")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
marker,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
|
plugin_enabled: {
|
||||||
|
"demo.toggle": false,
|
||||||
|
},
|
||||||
|
plugin_meta: {
|
||||||
|
[tmp.extra.spec]: {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const api = createTuiPluginApi()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(api)
|
||||||
|
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
|
||||||
|
id: "demo.toggle",
|
||||||
|
source: "file",
|
||||||
|
spec: tmp.extra.spec,
|
||||||
|
target: tmp.extra.spec,
|
||||||
|
enabled: false,
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
|
||||||
|
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||||
|
"demo.toggle": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
|
||||||
|
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||||
|
"demo.toggle": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "startup-plugin.ts")
|
||||||
|
const spec = pathToFileURL(file).href
|
||||||
|
const marker = path.join(dir, "startup.txt")
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
`export default {
|
||||||
|
id: "demo.startup",
|
||||||
|
tui: async (_api, options) => {
|
||||||
|
await Bun.write(options.marker, "on")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
marker,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
|
plugin_enabled: {
|
||||||
|
"demo.startup": false,
|
||||||
|
},
|
||||||
|
plugin_meta: {
|
||||||
|
[tmp.extra.spec]: {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const api = createTuiPluginApi()
|
||||||
|
api.kv.set("plugin_enabled", {
|
||||||
|
"demo.startup": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init(api)
|
||||||
|
|
||||||
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
|
||||||
|
id: "demo.startup",
|
||||||
|
source: "file",
|
||||||
|
spec: tmp.extra.spec,
|
||||||
|
target: tmp.extra.spec,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
})
|
||||||
50
packages/opencode/test/cli/tui/theme-store.test.ts
Normal file
50
packages/opencode/test/cli/tui/theme-store.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
|
||||||
|
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } =
|
||||||
|
await import("../../../src/cli/cmd/tui/context/theme")
|
||||||
|
|
||||||
|
test("addTheme writes into module theme store", () => {
|
||||||
|
const name = `plugin-theme-${Date.now()}`
|
||||||
|
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||||
|
|
||||||
|
expect(allThemes()[name]).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("addTheme keeps first theme for duplicate names", () => {
|
||||||
|
const name = `plugin-theme-keep-${Date.now()}`
|
||||||
|
const one = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
const two = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
one.theme.primary = "#101010"
|
||||||
|
two.theme.primary = "#fefefe"
|
||||||
|
|
||||||
|
expect(addTheme(name, one)).toBe(true)
|
||||||
|
expect(addTheme(name, two)).toBe(false)
|
||||||
|
|
||||||
|
expect(allThemes()[name]).toBeDefined()
|
||||||
|
expect(allThemes()[name]!.theme.primary).toBe("#101010")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("addTheme ignores entries without a theme object", () => {
|
||||||
|
const name = `plugin-theme-invalid-${Date.now()}`
|
||||||
|
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||||
|
expect(allThemes()[name]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hasTheme checks theme presence", () => {
|
||||||
|
const name = `plugin-theme-has-${Date.now()}`
|
||||||
|
expect(hasTheme(name)).toBe(false)
|
||||||
|
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||||
|
expect(hasTheme(name)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveTheme rejects circular color refs", () => {
|
||||||
|
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
item.defs = {
|
||||||
|
...(item.defs ?? {}),
|
||||||
|
one: "two",
|
||||||
|
two: "one",
|
||||||
|
}
|
||||||
|
item.theme.primary = "one"
|
||||||
|
|
||||||
|
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||||
|
})
|
||||||
@@ -20,6 +20,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { ProjectID } from "../../src/project/schema"
|
import { ProjectID } from "../../src/project/schema"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import * as Network from "../../src/util/network"
|
||||||
import { BunProc } from "../../src/bun"
|
import { BunProc } from "../../src/bun"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
const emptyAccount = Layer.mock(Account.Service)({
|
||||||
@@ -765,6 +766,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
|
|
||||||
const prev = process.env.OPENCODE_CONFIG_DIR
|
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||||
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
||||||
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
|
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||||
|
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -778,25 +793,43 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
|
online.mockRestore()
|
||||||
|
run.mockRestore()
|
||||||
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||||
else process.env.OPENCODE_CONFIG_DIR = prev
|
else process.env.OPENCODE_CONFIG_DIR = prev
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("serializes concurrent config dependency installs", async () => {
|
test("dedupes concurrent config dependency installs for the same dir", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
|
const dir = path.join(tmp.path, "a")
|
||||||
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
|
||||||
const seen: string[] = []
|
const ticks: number[] = []
|
||||||
let active = 0
|
let calls = 0
|
||||||
let max = 0
|
let start = () => {}
|
||||||
|
let done = () => {}
|
||||||
|
let blocked = () => {}
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
start = resolve
|
||||||
|
})
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
done = resolve
|
||||||
|
})
|
||||||
|
const waiting = new Promise<void>((resolve) => {
|
||||||
|
blocked = resolve
|
||||||
|
})
|
||||||
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||||
active++
|
calls += 1
|
||||||
max = Math.max(max, active)
|
start()
|
||||||
seen.push(opts?.cwd ?? "")
|
await gate
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||||
active--
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
code: 0,
|
code: 0,
|
||||||
stdout: Buffer.alloc(0),
|
stdout: Buffer.alloc(0),
|
||||||
@@ -805,15 +838,85 @@ test("serializes concurrent config dependency installs", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
|
const first = Config.installDependencies(dir)
|
||||||
|
await ready
|
||||||
|
const second = Config.installDependencies(dir, {
|
||||||
|
waitTick: (tick) => {
|
||||||
|
ticks.push(tick.attempt)
|
||||||
|
blocked()
|
||||||
|
blocked = () => {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await waiting
|
||||||
|
done()
|
||||||
|
await Promise.all([first, second])
|
||||||
} finally {
|
} finally {
|
||||||
|
online.mockRestore()
|
||||||
run.mockRestore()
|
run.mockRestore()
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(max).toBe(1)
|
expect(calls).toBe(1)
|
||||||
expect(seen.toSorted()).toEqual(dirs.toSorted())
|
expect(ticks.length).toBeGreaterThan(0)
|
||||||
expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
|
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
|
||||||
expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
|
})
|
||||||
|
|
||||||
|
test("serializes config dependency installs across dirs", async () => {
|
||||||
|
if (process.platform !== "win32") return
|
||||||
|
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const a = path.join(tmp.path, "a")
|
||||||
|
const b = path.join(tmp.path, "b")
|
||||||
|
await fs.mkdir(a, { recursive: true })
|
||||||
|
await fs.mkdir(b, { recursive: true })
|
||||||
|
|
||||||
|
let calls = 0
|
||||||
|
let open = 0
|
||||||
|
let peak = 0
|
||||||
|
let start = () => {}
|
||||||
|
let done = () => {}
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
start = resolve
|
||||||
|
})
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
done = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
|
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||||
|
calls += 1
|
||||||
|
open += 1
|
||||||
|
peak = Math.max(peak, open)
|
||||||
|
if (calls === 1) {
|
||||||
|
start()
|
||||||
|
await gate
|
||||||
|
}
|
||||||
|
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||||
|
)
|
||||||
|
open -= 1
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = Config.installDependencies(a)
|
||||||
|
await ready
|
||||||
|
const second = Config.installDependencies(b)
|
||||||
|
done()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
} finally {
|
||||||
|
online.mockRestore()
|
||||||
|
run.mockRestore()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(peak).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves scoped npm plugins in config", async () => {
|
test("resolves scoped npm plugins in config", async () => {
|
||||||
@@ -855,15 +958,7 @@ test("resolves scoped npm plugins in config", async () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const pluginEntries = config.plugin ?? []
|
const pluginEntries = config.plugin ?? []
|
||||||
|
expect(pluginEntries).toContain("@scope/plugin")
|
||||||
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
|
||||||
const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href
|
|
||||||
|
|
||||||
expect(pluginEntries.includes(expected)).toBe(true)
|
|
||||||
|
|
||||||
const scopedEntry = pluginEntries.find((entry) => entry === expected)
|
|
||||||
expect(scopedEntry).toBeDefined()
|
|
||||||
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1710,27 +1805,43 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getPluginName", () => {
|
describe("resolvePluginSpec", () => {
|
||||||
test("extracts name from file:// URL", () => {
|
test("keeps package specs unchanged", async () => {
|
||||||
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
await using tmp = await tmpdir()
|
||||||
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
|
expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
|
||||||
|
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts name from npm package with version", () => {
|
test("resolves relative file plugin paths to file urls", async () => {
|
||||||
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
|
await using tmp = await tmpdir({
|
||||||
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
|
init: async (dir) => {
|
||||||
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
|
await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
|
const hit = await Config.resolvePluginSpec("./plugin.ts", file)
|
||||||
|
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts name from scoped npm package", () => {
|
test("resolves plugin directory paths to package main files", async () => {
|
||||||
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
|
await using tmp = await tmpdir({
|
||||||
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
|
init: async (dir) => {
|
||||||
})
|
const plugin = path.join(dir, "plugin")
|
||||||
|
await fs.mkdir(plugin, { recursive: true })
|
||||||
|
await Filesystem.writeJson(path.join(plugin, "package.json"), {
|
||||||
|
name: "demo-plugin",
|
||||||
|
type: "module",
|
||||||
|
main: "./index.ts",
|
||||||
|
})
|
||||||
|
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
test("returns full string for package without version", () => {
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
|
const hit = await Config.resolvePluginSpec("./plugin", file)
|
||||||
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
|
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1747,13 +1858,20 @@ describe("deduplicatePlugins", () => {
|
|||||||
expect(result.length).toBe(3)
|
expect(result.length).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prefers local file over npm package with same name", () => {
|
test("keeps path plugins separate from package plugins", () => {
|
||||||
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
||||||
|
|
||||||
const result = Config.deduplicatePlugins(plugins)
|
const result = Config.deduplicatePlugins(plugins)
|
||||||
|
|
||||||
expect(result.length).toBe(1)
|
expect(result).toEqual(plugins)
|
||||||
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
|
})
|
||||||
|
|
||||||
|
test("deduplicates direct path plugins by exact spec", () => {
|
||||||
|
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
|
||||||
|
|
||||||
|
const result = Config.deduplicatePlugins(plugins)
|
||||||
|
|
||||||
|
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves order of remaining plugins", () => {
|
test("preserves order of remaining plugins", () => {
|
||||||
@@ -1764,7 +1882,7 @@ describe("deduplicatePlugins", () => {
|
|||||||
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("local plugin directory overrides global opencode.json plugin", async () => {
|
test("loads auto-discovered local plugins as file urls", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const projectDir = path.join(dir, "project")
|
const projectDir = path.join(dir, "project")
|
||||||
@@ -1790,9 +1908,8 @@ describe("deduplicatePlugins", () => {
|
|||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
||||||
expect(myPlugins.length).toBe(1)
|
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
|
||||||
expect(myPlugins[0].startsWith("file://")).toBe(true)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
|
|||||||
test("loads managed tui config and gives it highest precedence", async () => {
|
test("loads managed tui config and gives it highest precedence", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
|
||||||
|
)
|
||||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
|
await Bun.write(
|
||||||
|
path.join(managedConfigDir, "tui.json"),
|
||||||
|
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
expect(config.theme).toBe("managed-theme")
|
expect(config.theme).toBe("managed-theme")
|
||||||
|
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
||||||
|
expect(config.plugin_meta).toEqual({
|
||||||
|
"shared-plugin@2.0.0": {
|
||||||
|
scope: "global",
|
||||||
|
source: path.join(managedConfigDir, "tui.json"),
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -508,3 +521,147 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("supports tuple plugin specs with options in tui.json", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||||
|
expect(config.plugin_meta).toEqual({
|
||||||
|
"acme-plugin@1.2.3": {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(Global.Path.config, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin: [
|
||||||
|
["acme-plugin@2.0.0", { source: "project" }],
|
||||||
|
["second-plugin@3.0.0", { source: "project" }],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.plugin).toEqual([
|
||||||
|
["acme-plugin@2.0.0", { source: "project" }],
|
||||||
|
["second-plugin@3.0.0", { source: "project" }],
|
||||||
|
])
|
||||||
|
expect(config.plugin_meta).toEqual({
|
||||||
|
"acme-plugin@2.0.0": {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
"second-plugin@3.0.0": {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tracks global and local plugin metadata in merged tui config", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(Global.Path.config, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin: ["global-plugin@1.0.0"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin: ["local-plugin@2.0.0"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||||
|
expect(config.plugin_meta).toEqual({
|
||||||
|
"global-plugin@1.0.0": {
|
||||||
|
scope: "global",
|
||||||
|
source: path.join(Global.Path.config, "tui.json"),
|
||||||
|
},
|
||||||
|
"local-plugin@2.0.0": {
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges plugin_enabled flags across config layers", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(Global.Path.config, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin_enabled: {
|
||||||
|
"internal:sidebar-context": false,
|
||||||
|
"demo.plugin": true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugin_enabled: {
|
||||||
|
"demo.plugin": false,
|
||||||
|
"local.plugin": true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.plugin_enabled).toEqual({
|
||||||
|
"internal:sidebar-context": false,
|
||||||
|
"demo.plugin": false,
|
||||||
|
"local.plugin": true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
72
packages/opencode/test/fixture/flock-worker.ts
Normal file
72
packages/opencode/test/fixture/flock-worker.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "fs/promises"
|
||||||
|
import { Flock } from "../../src/util/flock"
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
key: string
|
||||||
|
dir: string
|
||||||
|
staleMs?: number
|
||||||
|
timeoutMs?: number
|
||||||
|
baseDelayMs?: number
|
||||||
|
maxDelayMs?: number
|
||||||
|
holdMs?: number
|
||||||
|
ready?: string
|
||||||
|
active?: string
|
||||||
|
done?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function input() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Missing flock worker input")
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw) as Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
async function job(input: Msg) {
|
||||||
|
if (input.ready) {
|
||||||
|
await fs.writeFile(input.ready, String(process.pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.active) {
|
||||||
|
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (input.holdMs && input.holdMs > 0) {
|
||||||
|
await sleep(input.holdMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.done) {
|
||||||
|
await fs.appendFile(input.done, "1\n")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (input.active) {
|
||||||
|
await fs.rm(input.active, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const msg = input()
|
||||||
|
|
||||||
|
await Flock.withLock(msg.key, () => job(msg), {
|
||||||
|
dir: msg.dir,
|
||||||
|
staleMs: msg.staleMs,
|
||||||
|
timeoutMs: msg.timeoutMs,
|
||||||
|
baseDelayMs: msg.baseDelayMs,
|
||||||
|
maxDelayMs: msg.maxDelayMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((err) => {
|
||||||
|
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||||
|
process.stderr.write(text)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
93
packages/opencode/test/fixture/plug-worker.ts
Normal file
93
packages/opencode/test/fixture/plug-worker.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
dir: string
|
||||||
|
target: string
|
||||||
|
mod: string
|
||||||
|
global?: boolean
|
||||||
|
force?: boolean
|
||||||
|
globalDir?: string
|
||||||
|
vcs?: string
|
||||||
|
worktree?: string
|
||||||
|
directory?: string
|
||||||
|
holdMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function input() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Missing plug worker input")
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = JSON.parse(raw) as Partial<Msg>
|
||||||
|
if (!msg.dir || !msg.target || !msg.mod) {
|
||||||
|
throw new Error("Invalid plug worker input")
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg as Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(msg: Msg): PlugDeps {
|
||||||
|
return {
|
||||||
|
spinner: () => ({
|
||||||
|
start() {},
|
||||||
|
stop() {},
|
||||||
|
}),
|
||||||
|
log: {
|
||||||
|
error() {},
|
||||||
|
info() {},
|
||||||
|
success() {},
|
||||||
|
},
|
||||||
|
resolve: async () => msg.target,
|
||||||
|
readText: (file) => Filesystem.readText(file),
|
||||||
|
write: async (file, text) => {
|
||||||
|
if (msg.holdMs && msg.holdMs > 0) {
|
||||||
|
await sleep(msg.holdMs)
|
||||||
|
}
|
||||||
|
await Filesystem.write(file, text)
|
||||||
|
},
|
||||||
|
exists: (file) => Filesystem.exists(file),
|
||||||
|
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
|
||||||
|
global: msg.globalDir ?? path.join(msg.dir, ".global"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctx(msg: Msg): PlugCtx {
|
||||||
|
return {
|
||||||
|
vcs: msg.vcs ?? "git",
|
||||||
|
worktree: msg.worktree ?? msg.dir,
|
||||||
|
directory: msg.directory ?? msg.dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const msg = input()
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: msg.mod,
|
||||||
|
global: msg.global,
|
||||||
|
force: msg.force,
|
||||||
|
},
|
||||||
|
deps(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(msg))
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error("Plug task failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((err) => {
|
||||||
|
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||||
|
process.stderr.write(text)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
26
packages/opencode/test/fixture/plugin-meta-worker.ts
Normal file
26
packages/opencode/test/fixture/plugin-meta-worker.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
type Msg = {
|
||||||
|
file: string
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) throw new Error("Missing worker payload")
|
||||||
|
|
||||||
|
const value = JSON.parse(raw)
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new Error("Invalid worker payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = Object.fromEntries(Object.entries(value))
|
||||||
|
if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
|
||||||
|
throw new Error("Invalid worker payload")
|
||||||
|
}
|
||||||
|
if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
||||||
|
|
||||||
|
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||||
|
|
||||||
|
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
||||||
334
packages/opencode/test/fixture/tui-plugin.ts
Normal file
334
packages/opencode/test/fixture/tui-plugin.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import { RGBA, type CliRenderer } from "@opentui/core"
|
||||||
|
import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||||
|
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
|
||||||
|
|
||||||
|
type Count = {
|
||||||
|
event_add: number
|
||||||
|
event_drop: number
|
||||||
|
route_add: number
|
||||||
|
route_drop: number
|
||||||
|
command_add: number
|
||||||
|
command_drop: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeCurrent(): HostPluginApi["theme"]["current"] {
|
||||||
|
const a = RGBA.fromInts(0, 120, 240)
|
||||||
|
const b = RGBA.fromInts(120, 120, 120)
|
||||||
|
const c = RGBA.fromInts(230, 230, 230)
|
||||||
|
const d = RGBA.fromInts(120, 30, 30)
|
||||||
|
const e = RGBA.fromInts(140, 100, 40)
|
||||||
|
const f = RGBA.fromInts(20, 140, 80)
|
||||||
|
const g = RGBA.fromInts(20, 80, 160)
|
||||||
|
const h = RGBA.fromInts(40, 40, 40)
|
||||||
|
const i = RGBA.fromInts(60, 60, 60)
|
||||||
|
const j = RGBA.fromInts(80, 80, 80)
|
||||||
|
return {
|
||||||
|
primary: a,
|
||||||
|
secondary: b,
|
||||||
|
accent: a,
|
||||||
|
error: d,
|
||||||
|
warning: e,
|
||||||
|
success: f,
|
||||||
|
info: g,
|
||||||
|
text: c,
|
||||||
|
textMuted: b,
|
||||||
|
selectedListItemText: h,
|
||||||
|
background: h,
|
||||||
|
backgroundPanel: h,
|
||||||
|
backgroundElement: i,
|
||||||
|
backgroundMenu: i,
|
||||||
|
border: j,
|
||||||
|
borderActive: c,
|
||||||
|
borderSubtle: i,
|
||||||
|
diffAdded: f,
|
||||||
|
diffRemoved: d,
|
||||||
|
diffContext: b,
|
||||||
|
diffHunkHeader: b,
|
||||||
|
diffHighlightAdded: f,
|
||||||
|
diffHighlightRemoved: d,
|
||||||
|
diffAddedBg: h,
|
||||||
|
diffRemovedBg: h,
|
||||||
|
diffContextBg: h,
|
||||||
|
diffLineNumber: b,
|
||||||
|
diffAddedLineNumberBg: h,
|
||||||
|
diffRemovedLineNumberBg: h,
|
||||||
|
markdownText: c,
|
||||||
|
markdownHeading: c,
|
||||||
|
markdownLink: a,
|
||||||
|
markdownLinkText: g,
|
||||||
|
markdownCode: f,
|
||||||
|
markdownBlockQuote: e,
|
||||||
|
markdownEmph: e,
|
||||||
|
markdownStrong: c,
|
||||||
|
markdownHorizontalRule: b,
|
||||||
|
markdownListItem: a,
|
||||||
|
markdownListEnumeration: g,
|
||||||
|
markdownImage: a,
|
||||||
|
markdownImageText: g,
|
||||||
|
markdownCodeBlock: c,
|
||||||
|
syntaxComment: b,
|
||||||
|
syntaxKeyword: a,
|
||||||
|
syntaxFunction: g,
|
||||||
|
syntaxVariable: c,
|
||||||
|
syntaxString: f,
|
||||||
|
syntaxNumber: e,
|
||||||
|
syntaxType: a,
|
||||||
|
syntaxOperator: a,
|
||||||
|
syntaxPunctuation: c,
|
||||||
|
thinkingOpacity: 0.6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opts = {
|
||||||
|
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
|
||||||
|
scopedClient?: HostPluginApi["scopedClient"]
|
||||||
|
workspace?: Partial<HostPluginApi["workspace"]>
|
||||||
|
renderer?: HostPluginApi["renderer"]
|
||||||
|
count?: Count
|
||||||
|
keybind?: Partial<HostPluginApi["keybind"]>
|
||||||
|
tuiConfig?: HostPluginApi["tuiConfig"]
|
||||||
|
app?: Partial<HostPluginApi["app"]>
|
||||||
|
state?: {
|
||||||
|
ready?: HostPluginApi["state"]["ready"]
|
||||||
|
config?: HostPluginApi["state"]["config"]
|
||||||
|
provider?: HostPluginApi["state"]["provider"]
|
||||||
|
path?: HostPluginApi["state"]["path"]
|
||||||
|
vcs?: HostPluginApi["state"]["vcs"]
|
||||||
|
workspace?: Partial<HostPluginApi["state"]["workspace"]>
|
||||||
|
session?: Partial<HostPluginApi["state"]["session"]>
|
||||||
|
part?: HostPluginApi["state"]["part"]
|
||||||
|
lsp?: HostPluginApi["state"]["lsp"]
|
||||||
|
mcp?: HostPluginApi["state"]["mcp"]
|
||||||
|
}
|
||||||
|
theme?: {
|
||||||
|
selected?: string
|
||||||
|
has?: HostPluginApi["theme"]["has"]
|
||||||
|
set?: HostPluginApi["theme"]["set"]
|
||||||
|
install?: HostPluginApi["theme"]["install"]
|
||||||
|
mode?: HostPluginApi["theme"]["mode"]
|
||||||
|
ready?: boolean
|
||||||
|
current?: HostPluginApi["theme"]["current"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||||
|
const kv: Record<string, unknown> = {}
|
||||||
|
const count = opts.count
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const own = createOpencodeClient({
|
||||||
|
baseUrl: "http://localhost:4096",
|
||||||
|
})
|
||||||
|
const fallback = () => own
|
||||||
|
const read =
|
||||||
|
typeof opts.client === "function"
|
||||||
|
? opts.client
|
||||||
|
: opts.client
|
||||||
|
? () => opts.client as HostPluginApi["client"]
|
||||||
|
: fallback
|
||||||
|
const client = () => read()
|
||||||
|
const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
|
||||||
|
const workspace: HostPluginApi["workspace"] = {
|
||||||
|
current: opts.workspace?.current ?? (() => undefined),
|
||||||
|
set: opts.workspace?.set ?? (() => {}),
|
||||||
|
}
|
||||||
|
let depth = 0
|
||||||
|
let size: "medium" | "large" | "xlarge" = "medium"
|
||||||
|
const has = opts.theme?.has ?? (() => false)
|
||||||
|
let selected = opts.theme?.selected ?? "opencode"
|
||||||
|
const key = {
|
||||||
|
match: opts.keybind?.match ?? (() => false),
|
||||||
|
print: opts.keybind?.print ?? ((name: string) => name),
|
||||||
|
}
|
||||||
|
const set =
|
||||||
|
opts.theme?.set ??
|
||||||
|
((name: string) => {
|
||||||
|
if (!has(name)) return false
|
||||||
|
selected = name
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const renderer: CliRenderer = opts.renderer ?? {
|
||||||
|
...Object.create(null),
|
||||||
|
once(this: CliRenderer) {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvGet(name: string): unknown
|
||||||
|
function kvGet<Value>(name: string, fallback: Value): Value
|
||||||
|
function kvGet(name: string, fallback?: unknown) {
|
||||||
|
const value = kv[name]
|
||||||
|
if (value === undefined) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
get version() {
|
||||||
|
return opts.app?.version ?? "0.0.0-test"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
get client() {
|
||||||
|
return client()
|
||||||
|
},
|
||||||
|
scopedClient,
|
||||||
|
workspace,
|
||||||
|
event: {
|
||||||
|
on: () => {
|
||||||
|
if (count) count.event_add += 1
|
||||||
|
return () => {
|
||||||
|
if (!count) return
|
||||||
|
count.event_drop += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer,
|
||||||
|
slots: {
|
||||||
|
register: () => "fixture-slot",
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
list: () => [],
|
||||||
|
activate: async () => false,
|
||||||
|
deactivate: async () => false,
|
||||||
|
add: async () => false,
|
||||||
|
install: async () => ({
|
||||||
|
ok: false,
|
||||||
|
message: "not implemented in fixture",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
onDispose() {
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
register: () => {
|
||||||
|
if (count) count.command_add += 1
|
||||||
|
return () => {
|
||||||
|
if (!count) return
|
||||||
|
count.command_drop += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: () => {},
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
register: () => {
|
||||||
|
if (count) count.route_add += 1
|
||||||
|
return () => {
|
||||||
|
if (!count) return
|
||||||
|
count.route_drop += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigate: () => {},
|
||||||
|
get current() {
|
||||||
|
return { name: "home" }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
Dialog: () => null,
|
||||||
|
DialogAlert: () => null,
|
||||||
|
DialogConfirm: () => null,
|
||||||
|
DialogPrompt: () => null,
|
||||||
|
DialogSelect: () => null,
|
||||||
|
toast: () => {},
|
||||||
|
dialog: {
|
||||||
|
replace: () => {
|
||||||
|
depth = 1
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
depth = 0
|
||||||
|
size = "medium"
|
||||||
|
},
|
||||||
|
setSize: (next) => {
|
||||||
|
size = next
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return size
|
||||||
|
},
|
||||||
|
get depth() {
|
||||||
|
return depth
|
||||||
|
},
|
||||||
|
get open() {
|
||||||
|
return depth > 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keybind: {
|
||||||
|
...key,
|
||||||
|
create:
|
||||||
|
opts.keybind?.create ??
|
||||||
|
((defaults, over) => {
|
||||||
|
return createPluginKeybind(key, defaults, over)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tuiConfig: opts.tuiConfig ?? {},
|
||||||
|
kv: {
|
||||||
|
get: kvGet,
|
||||||
|
set(name, value) {
|
||||||
|
kv[name] = value
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
get ready() {
|
||||||
|
return opts.state?.ready ?? true
|
||||||
|
},
|
||||||
|
get config() {
|
||||||
|
return opts.state?.config ?? {}
|
||||||
|
},
|
||||||
|
get provider() {
|
||||||
|
return opts.state?.provider ?? []
|
||||||
|
},
|
||||||
|
get path() {
|
||||||
|
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
|
||||||
|
},
|
||||||
|
get vcs() {
|
||||||
|
return opts.state?.vcs
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
list: opts.state?.workspace?.list ?? (() => []),
|
||||||
|
get: opts.state?.workspace?.get ?? (() => undefined),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
count: opts.state?.session?.count ?? (() => 0),
|
||||||
|
diff: opts.state?.session?.diff ?? (() => []),
|
||||||
|
todo: opts.state?.session?.todo ?? (() => []),
|
||||||
|
messages: opts.state?.session?.messages ?? (() => []),
|
||||||
|
status: opts.state?.session?.status ?? (() => undefined),
|
||||||
|
permission: opts.state?.session?.permission ?? (() => []),
|
||||||
|
question: opts.state?.session?.question ?? (() => []),
|
||||||
|
},
|
||||||
|
part: opts.state?.part ?? (() => []),
|
||||||
|
lsp: opts.state?.lsp ?? (() => []),
|
||||||
|
mcp: opts.state?.mcp ?? (() => []),
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
get current() {
|
||||||
|
return opts.theme?.current ?? themeCurrent()
|
||||||
|
},
|
||||||
|
get selected() {
|
||||||
|
return selected
|
||||||
|
},
|
||||||
|
has(name) {
|
||||||
|
return has(name)
|
||||||
|
},
|
||||||
|
set(name) {
|
||||||
|
return set(name)
|
||||||
|
},
|
||||||
|
async install(file) {
|
||||||
|
if (opts.theme?.install) return opts.theme.install(file)
|
||||||
|
throw new Error("base theme.install should not run")
|
||||||
|
},
|
||||||
|
mode() {
|
||||||
|
if (opts.theme?.mode) return opts.theme.mode()
|
||||||
|
return "dark"
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return opts.theme?.ready ?? true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/opencode/test/fixture/tui-runtime.ts
Normal file
34
packages/opencode/test/fixture/tui-runtime.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { spyOn } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { TuiConfig } from "../../src/config/tui"
|
||||||
|
|
||||||
|
type PluginSpec = string | [string, Record<string, unknown>]
|
||||||
|
|
||||||
|
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||||
|
const meta = Object.fromEntries(
|
||||||
|
plugin.map((item) => {
|
||||||
|
const spec = Array.isArray(item) ? item[0] : item
|
||||||
|
return [
|
||||||
|
spec,
|
||||||
|
{
|
||||||
|
scope: "local" as const,
|
||||||
|
source: path.join(dir, "tui.json"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||||
|
plugin,
|
||||||
|
plugin_meta: meta,
|
||||||
|
})
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cwd.mockRestore()
|
||||||
|
get.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@ describe("plugin.auth-override", () => {
|
|||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(pluginDir, "custom-copilot-auth.ts"),
|
path.join(pluginDir, "custom-copilot-auth.ts"),
|
||||||
[
|
[
|
||||||
"export default async () => ({",
|
"export default {",
|
||||||
" auth: {",
|
' id: "demo.custom-copilot-auth",',
|
||||||
' provider: "github-copilot",',
|
" server: async () => ({",
|
||||||
" methods: [",
|
" auth: {",
|
||||||
' { type: "api", label: "Test Override Auth" },',
|
' provider: "github-copilot",',
|
||||||
" ],",
|
" methods: [",
|
||||||
" loader: async () => ({ access: 'test-token' }),",
|
' { type: "api", label: "Test Override Auth" },',
|
||||||
" },",
|
" ],",
|
||||||
"})",
|
" loader: async () => ({ access: 'test-token' }),",
|
||||||
|
" },",
|
||||||
|
" }),",
|
||||||
|
"}",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
|
|||||||
134
packages/opencode/test/plugin/install-concurrency.test.ts
Normal file
134
packages/opencode/test/plugin/install-concurrency.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import { Process } from "../../src/util/process"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const root = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts")
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
dir: string
|
||||||
|
target: string
|
||||||
|
mod: string
|
||||||
|
holdMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(msg: Msg) {
|
||||||
|
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
|
||||||
|
cwd: root,
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
||||||
|
const p = path.join(dir, "plugin")
|
||||||
|
await fs.mkdir(p, { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(p, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme",
|
||||||
|
version: "1.0.0",
|
||||||
|
"oc-plugin": kinds,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(file: string) {
|
||||||
|
return Filesystem.readJson<{ plugin?: unknown[] }>(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mods(prefix: string, n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPlugins(list: unknown[] | undefined, expectMods: string[]) {
|
||||||
|
expect(Array.isArray(list)).toBe(true)
|
||||||
|
const hit = (list ?? []).filter((item): item is string => typeof item === "string")
|
||||||
|
expect(hit.length).toBe(expectMods.length)
|
||||||
|
expect(new Set(hit)).toEqual(new Set(expectMods))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin.install.concurrent", () => {
|
||||||
|
test("serializes concurrent server config updates across processes", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const all = mods("mod-server", 12)
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
all.map((mod) =>
|
||||||
|
run({
|
||||||
|
dir: tmp.path,
|
||||||
|
target,
|
||||||
|
mod,
|
||||||
|
holdMs: 30,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
|
||||||
|
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||||
|
|
||||||
|
const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
|
||||||
|
expectPlugins(cfg.plugin, all)
|
||||||
|
}, 25_000)
|
||||||
|
|
||||||
|
test("serializes concurrent server+tui config updates across processes", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server", "tui"])
|
||||||
|
const all = mods("mod-both", 10)
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
all.map((mod) =>
|
||||||
|
run({
|
||||||
|
dir: tmp.path,
|
||||||
|
target,
|
||||||
|
mod,
|
||||||
|
holdMs: 30,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
|
||||||
|
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||||
|
|
||||||
|
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
|
||||||
|
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
|
||||||
|
expectPlugins(server.plugin, all)
|
||||||
|
expectPlugins(tui.plugin, all)
|
||||||
|
}, 25_000)
|
||||||
|
|
||||||
|
test("preserves updates when existing config uses .json", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
const next = mods("mod-json", 8)
|
||||||
|
const out = await Promise.all(
|
||||||
|
next.map((mod) =>
|
||||||
|
run({
|
||||||
|
dir: tmp.path,
|
||||||
|
target,
|
||||||
|
mod,
|
||||||
|
holdMs: 30,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out.map((x) => x.code)).toEqual(Array.from({ length: next.length }, () => 0))
|
||||||
|
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||||
|
|
||||||
|
const json = await read(cfg)
|
||||||
|
expectPlugins(json.plugin, ["seed@1.0.0", ...next])
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
}, 25_000)
|
||||||
|
})
|
||||||
410
packages/opencode/test/plugin/install.test.ts
Normal file
410
packages/opencode/test/plugin/install.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
function deps(global: string, target: string | Error): PlugDeps {
|
||||||
|
return {
|
||||||
|
spinner: () => ({
|
||||||
|
start() {},
|
||||||
|
stop() {},
|
||||||
|
}),
|
||||||
|
log: {
|
||||||
|
error() {},
|
||||||
|
info() {},
|
||||||
|
success() {},
|
||||||
|
},
|
||||||
|
resolve: async () => {
|
||||||
|
if (target instanceof Error) throw target
|
||||||
|
return target
|
||||||
|
},
|
||||||
|
readText: (file) => Filesystem.readText(file),
|
||||||
|
write: async (file, text) => {
|
||||||
|
await Filesystem.write(file, text)
|
||||||
|
},
|
||||||
|
exists: (file) => Filesystem.exists(file),
|
||||||
|
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
|
||||||
|
global,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctx(dir: string): PlugCtx {
|
||||||
|
return {
|
||||||
|
vcs: "git",
|
||||||
|
worktree: dir,
|
||||||
|
directory: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxDir(dir: string, worktree: string): PlugCtx {
|
||||||
|
return {
|
||||||
|
vcs: "none",
|
||||||
|
worktree,
|
||||||
|
directory: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxRoot(dir: string): PlugCtx {
|
||||||
|
return {
|
||||||
|
vcs: "git",
|
||||||
|
worktree: "/",
|
||||||
|
directory: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plugin(dir: string, kinds?: unknown) {
|
||||||
|
const p = path.join(dir, "plugin")
|
||||||
|
await fs.mkdir(p, { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(p, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme",
|
||||||
|
version: "1.0.0",
|
||||||
|
...(kinds === undefined ? {} : { "oc-plugin": kinds }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(file: string) {
|
||||||
|
return Filesystem.readJson<{
|
||||||
|
plugin?: unknown[]
|
||||||
|
}>(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin.install.task", () => {
|
||||||
|
test("writes both server and tui config entries", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server", "tui"])
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
|
||||||
|
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
|
||||||
|
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
|
||||||
|
expect(server.plugin).toEqual(["acme@1.2.3"])
|
||||||
|
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes default options from tuple manifest targets", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, [
|
||||||
|
["server", { custom: true, other: false }],
|
||||||
|
["tui", { compact: true }],
|
||||||
|
])
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
|
||||||
|
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
|
||||||
|
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
|
||||||
|
expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]])
|
||||||
|
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports resolver target pointing to a file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const file = path.join(target, "index.js")
|
||||||
|
await Bun.write(file, "export {}")
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), file),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
|
||||||
|
expect(server.plugin).toEqual(["acme@1.2.3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not change configured package version without force", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@2.0.0",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const json = await read(cfg)
|
||||||
|
expect(json.plugin).toEqual(["acme@1.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not change scoped package version without force", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "@scope/acme@2.0.0",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const json = await read(cfg)
|
||||||
|
expect(json.plugin).toEqual(["@scope/acme@1.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps file plugin entries and still adds npm plugin", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const json = await read(cfg)
|
||||||
|
expect(json.plugin).toEqual(["file:///tmp/acme.ts", "acme@1.2.3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("force replaces configured package version and keeps tuple options", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
cfg,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@2.0.0",
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const json = await read(cfg)
|
||||||
|
expect(json.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes to global scope when global flag is set", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const global = path.join(tmp.path, "global")
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
|
deps(global, target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
|
||||||
|
expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes local scope under directory when vcs is not git", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const directory = path.join(tmp.path, "dir")
|
||||||
|
const worktree = path.join(tmp.path, "worktree")
|
||||||
|
await fs.mkdir(directory, { recursive: true })
|
||||||
|
await fs.mkdir(worktree, { recursive: true })
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctxDir(directory, worktree))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes local scope under directory when worktree is root slash", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const directory = path.join(tmp.path, "dir")
|
||||||
|
await fs.mkdir(directory, { recursive: true })
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctxRoot(directory))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes tui local scope under directory when worktree is root slash", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["tui"])
|
||||||
|
const directory = path.join(tmp.path, "dir")
|
||||||
|
await fs.mkdir(directory, { recursive: true })
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctxRoot(directory))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes only tui config for tui-only plugins", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["tui"])
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("force replaces version in both server and tui configs", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server", "tui"])
|
||||||
|
const server = path.join(tmp.path, ".opencode", "opencode.json")
|
||||||
|
const tui = path.join(tmp.path, ".opencode", "tui.json")
|
||||||
|
await fs.mkdir(path.dirname(server), { recursive: true })
|
||||||
|
await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
|
||||||
|
await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@2.0.0",
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const serverJson = await read(server)
|
||||||
|
const tuiJson = await read(tui)
|
||||||
|
expect(serverJson.plugin).toEqual(["acme@2.0.0", "other@1.0.0"])
|
||||||
|
expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false and keeps config unchanged for invalid JSONC", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path, ["server"])
|
||||||
|
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
|
||||||
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
|
const bad = '{"plugin": ["acme@1.0.0",}'
|
||||||
|
await Bun.write(cfg, bad)
|
||||||
|
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@2.0.0",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(await fs.readFile(cfg, "utf8")).toBe(bad)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when manifest declares no supported targets", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = await plugin(tmp.path)
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when manifest cannot be read", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const target = path.join(tmp.path, "plugin")
|
||||||
|
await fs.mkdir(target, { recursive: true })
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@1.2.3",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), target),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when install fails", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const run = createPlugTask(
|
||||||
|
{
|
||||||
|
mod: "acme@9.9.9",
|
||||||
|
},
|
||||||
|
deps(path.join(tmp.path, "global"), new Error("boom")),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await run(ctx(tmp.path))
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
548
packages/opencode/test/plugin/loader-shared.test.ts
Normal file
548
packages/opencode/test/plugin/loader-shared.test.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
|
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||||
|
|
||||||
|
const { Plugin } = await import("../../src/plugin/index")
|
||||||
|
const { Instance } = await import("../../src/project/instance")
|
||||||
|
const { BunProc } = await import("../../src/bun")
|
||||||
|
const { Bus } = await import("../../src/bus")
|
||||||
|
const { Session } = await import("../../src/session")
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (disableDefault === undefined) {
|
||||||
|
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load(dir: string) {
|
||||||
|
return Instance.provide({
|
||||||
|
directory: dir,
|
||||||
|
fn: async () => {
|
||||||
|
await Plugin.list()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errs(dir: string) {
|
||||||
|
return Instance.provide({
|
||||||
|
directory: dir,
|
||||||
|
fn: async () => {
|
||||||
|
const errors: string[] = []
|
||||||
|
const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
||||||
|
const error = evt.properties.error
|
||||||
|
if (!error || typeof error !== "object") return
|
||||||
|
if (!("data" in error)) return
|
||||||
|
if (!error.data || typeof error.data !== "object") return
|
||||||
|
if (!("message" in error.data)) return
|
||||||
|
if (typeof error.data.message !== "string") return
|
||||||
|
errors.push(error.data.message)
|
||||||
|
})
|
||||||
|
await Plugin.list()
|
||||||
|
off()
|
||||||
|
return errors
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin.loader.shared", () => {
|
||||||
|
test("loads a file:// plugin function export", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
const mark = path.join(dir, "called.txt")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"export default async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
|
||||||
|
" return {}",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates same function exported as default and named", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
const mark = path.join(dir, "count.txt")
|
||||||
|
await Bun.write(mark, "")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"const run = async () => {",
|
||||||
|
` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
|
||||||
|
" return {}",
|
||||||
|
"}",
|
||||||
|
"export default run",
|
||||||
|
"export const named = run",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses only default v1 server plugin when present", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
const mark = path.join(dir, "count.txt")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"export const named = async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "named")`,
|
||||||
|
" return {}",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const acme = path.join(dir, "node_modules", "acme-plugin")
|
||||||
|
const scope = path.join(dir, "node_modules", "scope-plugin")
|
||||||
|
await fs.mkdir(acme, { recursive: true })
|
||||||
|
await fs.mkdir(scope, { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(acme, "package.json"),
|
||||||
|
JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(scope, "package.json"),
|
||||||
|
JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { acme, scope }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
|
||||||
|
if (pkg === "acme-plugin") return tmp.extra.acme
|
||||||
|
return tmp.extra.scope
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load(tmp.path)
|
||||||
|
|
||||||
|
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
|
||||||
|
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads npm server plugin from package ./server export", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const mark = path.join(dir, "server-called.txt")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
exports: {
|
||||||
|
".": "./index.js",
|
||||||
|
"./server": "./server.js",
|
||||||
|
"./tui": "./tui.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
||||||
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "server.js"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
mark,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects npm server export that resolves outside plugin directory", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const outside = path.join(dir, "outside")
|
||||||
|
const mark = path.join(dir, "outside-server.txt")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await fs.mkdir(outside, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
exports: {
|
||||||
|
".": "./index.js",
|
||||||
|
"./server": "./escape/server.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(outside, "server.js"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "outside")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
mark,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
|
.text()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(called).toBe(false)
|
||||||
|
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips legacy codex and copilot auth plugin specs", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load(tmp.path)
|
||||||
|
|
||||||
|
const pkgs = install.mock.calls.map((call) => call[0])
|
||||||
|
expect(pkgs).toContain("regular-plugin")
|
||||||
|
expect(pkgs).not.toContain("opencode-openai-codex-auth")
|
||||||
|
expect(pkgs).not.toContain("opencode-copilot-auth")
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishes session.error when install fails", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
|
||||||
|
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishes session.error when plugin init throws", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "throws.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.throws",',
|
||||||
|
" server: async () => {",
|
||||||
|
' throw new Error("explode")',
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
||||||
|
|
||||||
|
return { file }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
|
||||||
|
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishes session.error when plugin module has invalid export", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "invalid.ts"),
|
||||||
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
||||||
|
|
||||||
|
return { file }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
|
||||||
|
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishes session.error when plugin import fails", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
||||||
|
|
||||||
|
return { missing }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
|
||||||
|
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads object plugin via plugin.server", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "object-plugin.ts")
|
||||||
|
const mark = path.join(dir, "object-called.txt")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"const plugin = {",
|
||||||
|
' id: "demo.object",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"export default plugin",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes tuple plugin options into server plugin", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "options-plugin.ts")
|
||||||
|
const mark = path.join(dir, "options.json")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"const plugin = {",
|
||||||
|
' id: "demo.options",',
|
||||||
|
" server: async (_input, options) => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"export default plugin",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await load(tmp.path)
|
||||||
|
expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
|
||||||
|
source: "tuple",
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips external plugins in pure mode", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
const mark = path.join(dir, "called.txt")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.pure",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mark }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pure = process.env.OPENCODE_PURE
|
||||||
|
process.env.OPENCODE_PURE = "1"
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load(tmp.path)
|
||||||
|
const called = await fs
|
||||||
|
.readFile(tmp.extra.mark, "utf8")
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(called).toBe(false)
|
||||||
|
} finally {
|
||||||
|
if (pure === undefined) {
|
||||||
|
delete process.env.OPENCODE_PURE
|
||||||
|
} else {
|
||||||
|
process.env.OPENCODE_PURE = pure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
137
packages/opencode/test/plugin/meta.test.ts
Normal file
137
packages/opencode/test/plugin/meta.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { Process } from "../../src/util/process"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
|
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||||
|
const root = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
|
||||||
|
|
||||||
|
function run(input: { file: string; spec: string; target: string; id: string }) {
|
||||||
|
return Process.run([process.execPath, worker, JSON.stringify(input)], {
|
||||||
|
cwd: root,
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function map<Value>(file: string): Promise<Record<string, Value>> {
|
||||||
|
return Filesystem.readJson<Record<string, Value>>(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("plugin.meta", () => {
|
||||||
|
test("tracks file plugin loads and changes", async () => {
|
||||||
|
await using tmp = await tmpdir<{ file: string }>({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
await Bun.write(file, "export default async () => ({})\n")
|
||||||
|
return { file }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
|
||||||
|
const file = process.env.OPENCODE_PLUGIN_META_FILE!
|
||||||
|
const spec = pathToFileURL(tmp.extra.file).href
|
||||||
|
|
||||||
|
const one = await PluginMeta.touch(spec, spec, "demo.file")
|
||||||
|
expect(one.state).toBe("first")
|
||||||
|
expect(one.entry.source).toBe("file")
|
||||||
|
expect(one.entry.id).toBe("demo.file")
|
||||||
|
expect(one.entry.modified).toBeDefined()
|
||||||
|
|
||||||
|
const two = await PluginMeta.touch(spec, spec, "demo.file")
|
||||||
|
expect(two.state).toBe("same")
|
||||||
|
expect(two.entry.load_count).toBe(2)
|
||||||
|
|
||||||
|
await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
|
||||||
|
const stamp = new Date(Date.now() + 10_000)
|
||||||
|
await fs.utimes(tmp.extra.file, stamp, stamp)
|
||||||
|
|
||||||
|
const three = await PluginMeta.touch(spec, spec, "demo.file")
|
||||||
|
expect(three.state).toBe("updated")
|
||||||
|
expect(three.entry.load_count).toBe(3)
|
||||||
|
expect((three.entry.modified ?? 0) > (one.entry.modified ?? 0)).toBe(true)
|
||||||
|
|
||||||
|
const all = await PluginMeta.list()
|
||||||
|
expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
|
||||||
|
const saved = await map<{ spec: string; load_count: number }>(file)
|
||||||
|
expect(saved["demo.file"]?.spec).toBe(spec)
|
||||||
|
expect(saved["demo.file"]?.load_count).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tracks npm plugin versions", async () => {
|
||||||
|
await using tmp = await tmpdir<{ mod: string; pkg: string }>({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "node_modules", "acme-plugin")
|
||||||
|
const pkg = path.join(mod, "package.json")
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
|
||||||
|
return { mod, pkg }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
|
||||||
|
const file = process.env.OPENCODE_PLUGIN_META_FILE!
|
||||||
|
|
||||||
|
const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
|
||||||
|
expect(one.state).toBe("first")
|
||||||
|
expect(one.entry.source).toBe("npm")
|
||||||
|
expect(one.entry.requested).toBe("latest")
|
||||||
|
expect(one.entry.version).toBe("1.0.0")
|
||||||
|
|
||||||
|
await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
|
||||||
|
|
||||||
|
const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
|
||||||
|
expect(two.state).toBe("updated")
|
||||||
|
expect(two.entry.version).toBe("1.1.0")
|
||||||
|
expect(two.entry.load_count).toBe(2)
|
||||||
|
|
||||||
|
const all = await PluginMeta.list()
|
||||||
|
expect(Object.values(all).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
|
||||||
|
const saved = await map<{ id: string; version?: string }>(file)
|
||||||
|
expect(Object.values(saved).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes concurrent metadata updates across processes", async () => {
|
||||||
|
await using tmp = await tmpdir<{ file: string }>({
|
||||||
|
init: async (dir) => {
|
||||||
|
const file = path.join(dir, "plugin.ts")
|
||||||
|
await Bun.write(file, "export default async () => ({})\n")
|
||||||
|
return { file }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
|
||||||
|
const file = process.env.OPENCODE_PLUGIN_META_FILE!
|
||||||
|
const spec = pathToFileURL(tmp.extra.file).href
|
||||||
|
const n = 12
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
Array.from({ length: n }, () =>
|
||||||
|
run({
|
||||||
|
file,
|
||||||
|
spec,
|
||||||
|
target: spec,
|
||||||
|
id: "demo.file",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out.map((item) => item.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||||
|
expect(out.map((item) => item.stderr.toString()).filter(Boolean)).toEqual([])
|
||||||
|
|
||||||
|
const all = await PluginMeta.list()
|
||||||
|
const hit = Object.values(all).find((item) => item.spec === spec)
|
||||||
|
expect(hit?.load_count).toBe(n)
|
||||||
|
|
||||||
|
const saved = await map<{ spec: string; load_count: number }>(file)
|
||||||
|
expect(Object.values(saved).find((item) => item.spec === spec)?.load_count).toBe(n)
|
||||||
|
}, 20_000)
|
||||||
|
})
|
||||||
38
packages/opencode/test/util/error.test.ts
Normal file
38
packages/opencode/test/util/error.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
|
||||||
|
|
||||||
|
describe("util.error", () => {
|
||||||
|
test("formats native Error instances", () => {
|
||||||
|
const err = new Error("boom")
|
||||||
|
expect(errorMessage(err)).toBe("boom")
|
||||||
|
expect(errorFormat(err)).toContain("boom")
|
||||||
|
|
||||||
|
const data = errorData(err)
|
||||||
|
expect(data.type).toBe("Error")
|
||||||
|
expect(data.message).toBe("boom")
|
||||||
|
expect(String(data.formatted)).toContain("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts message from record-like values", () => {
|
||||||
|
const err = { message: "bad input", code: "E_BAD" }
|
||||||
|
expect(errorMessage(err)).toBe("bad input")
|
||||||
|
|
||||||
|
const data = errorData(err)
|
||||||
|
expect(data.message).toBe("bad input")
|
||||||
|
expect(data.code).toBe("E_BAD")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles opaque throwables with custom toString", () => {
|
||||||
|
const err = {
|
||||||
|
toString() {
|
||||||
|
return "ResolveMessage: Cannot resolve module"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
|
||||||
|
|
||||||
|
const data = errorData(err)
|
||||||
|
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
|
||||||
|
expect(String(data.formatted)).toContain("ResolveMessage")
|
||||||
|
})
|
||||||
|
})
|
||||||
383
packages/opencode/test/util/flock.test.ts
Normal file
383
packages/opencode/test/util/flock.test.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Flock } from "../../src/util/flock"
|
||||||
|
import { Hash } from "../../src/util/hash"
|
||||||
|
import { Process } from "../../src/util/process"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const root = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
key: string
|
||||||
|
dir: string
|
||||||
|
staleMs?: number
|
||||||
|
timeoutMs?: number
|
||||||
|
baseDelayMs?: number
|
||||||
|
maxDelayMs?: number
|
||||||
|
holdMs?: number
|
||||||
|
ready?: string
|
||||||
|
active?: string
|
||||||
|
done?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock(dir: string, key: string) {
|
||||||
|
return path.join(dir, Hash.fast(key) + ".lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string) {
|
||||||
|
return fs
|
||||||
|
.stat(file)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(file: string, timeout = 3_000) {
|
||||||
|
const stop = Date.now() + timeout
|
||||||
|
while (Date.now() < stop) {
|
||||||
|
if (await exists(file)) return
|
||||||
|
await sleep(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for file: ${file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(msg: Msg) {
|
||||||
|
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
|
||||||
|
cwd: root,
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(msg: Msg) {
|
||||||
|
return Process.spawn([process.execPath, worker, JSON.stringify(msg)], {
|
||||||
|
cwd: root,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("util.flock", () => {
|
||||||
|
test("enforces mutual exclusion under process contention", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const done = path.join(tmp.path, "done.log")
|
||||||
|
const active = path.join(tmp.path, "active")
|
||||||
|
const key = "flock:stress"
|
||||||
|
const n = 16
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
Array.from({ length: n }, () =>
|
||||||
|
run({
|
||||||
|
key,
|
||||||
|
dir,
|
||||||
|
done,
|
||||||
|
active,
|
||||||
|
holdMs: 30,
|
||||||
|
staleMs: 1_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||||
|
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||||
|
|
||||||
|
const lines = (await fs.readFile(done, "utf8"))
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
expect(lines.length).toBe(n)
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
|
test("times out while waiting when lock is still healthy", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:timeout"
|
||||||
|
const ready = path.join(tmp.path, "ready")
|
||||||
|
const proc = spawn({
|
||||||
|
key,
|
||||||
|
dir,
|
||||||
|
ready,
|
||||||
|
holdMs: 20_000,
|
||||||
|
staleMs: 10_000,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(ready, 5_000)
|
||||||
|
const seen: string[] = []
|
||||||
|
const err = await Flock.withLock(key, async () => {}, {
|
||||||
|
dir,
|
||||||
|
staleMs: 10_000,
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
onWait: (tick) => {
|
||||||
|
seen.push(tick.key)
|
||||||
|
},
|
||||||
|
}).catch((err) => err)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
if (!(err instanceof Error)) throw err
|
||||||
|
expect(err.message).toContain("Timed out waiting for lock")
|
||||||
|
expect(seen.length).toBeGreaterThan(0)
|
||||||
|
expect(seen.every((x) => x === key)).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await Process.stop(proc).catch(() => undefined)
|
||||||
|
await proc.exited.catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
test("recovers after a crashed lock owner", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:crash"
|
||||||
|
const ready = path.join(tmp.path, "ready")
|
||||||
|
const proc = spawn({
|
||||||
|
key,
|
||||||
|
dir,
|
||||||
|
ready,
|
||||||
|
holdMs: 20_000,
|
||||||
|
staleMs: 500,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(ready, 5_000)
|
||||||
|
await Process.stop(proc)
|
||||||
|
await proc.exited.catch(() => undefined)
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
hit = true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 500,
|
||||||
|
timeoutMs: 8_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
|
test("breaks stale lock dirs when heartbeat is missing", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:missing-heartbeat"
|
||||||
|
const lockDir = lock(dir, key)
|
||||||
|
|
||||||
|
await fs.mkdir(lockDir, { recursive: true })
|
||||||
|
const old = new Date(Date.now() - 2_000)
|
||||||
|
await fs.utimes(lockDir, old, old)
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
hit = true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 200,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("recovers when a stale breaker claim was left behind", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:stale-breaker"
|
||||||
|
const lockDir = lock(dir, key)
|
||||||
|
const breaker = lockDir + ".breaker"
|
||||||
|
|
||||||
|
await fs.mkdir(lockDir, { recursive: true })
|
||||||
|
await fs.mkdir(breaker)
|
||||||
|
|
||||||
|
const old = new Date(Date.now() - 2_000)
|
||||||
|
await fs.utimes(lockDir, old, old)
|
||||||
|
await fs.utimes(breaker, old, old)
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
hit = true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 200,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
expect(await exists(breaker)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails clearly if lock dir is removed while held", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:compromised"
|
||||||
|
const lockDir = lock(dir, key)
|
||||||
|
|
||||||
|
const err = await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
await fs.rm(lockDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 1_000,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
).catch((err) => err)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
if (!(err instanceof Error)) throw err
|
||||||
|
expect(err.message).toContain("compromised")
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
hit = true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 200,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes owner metadata while lock is held", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:meta"
|
||||||
|
const file = path.join(lock(dir, key), "meta.json")
|
||||||
|
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
const json = await Filesystem.readJson<{
|
||||||
|
token?: unknown
|
||||||
|
pid?: unknown
|
||||||
|
hostname?: unknown
|
||||||
|
createdAt?: unknown
|
||||||
|
}>(file)
|
||||||
|
|
||||||
|
expect(typeof json.token).toBe("string")
|
||||||
|
expect(typeof json.pid).toBe("number")
|
||||||
|
expect(typeof json.hostname).toBe("string")
|
||||||
|
expect(typeof json.createdAt).toBe("string")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 1_000,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports acquire with await using", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:acquire"
|
||||||
|
const lockDir = lock(dir, key)
|
||||||
|
|
||||||
|
{
|
||||||
|
await using _ = await Flock.acquire(key, {
|
||||||
|
dir,
|
||||||
|
staleMs: 1_000,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
})
|
||||||
|
expect(await exists(lockDir)).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await exists(lockDir)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses token mismatch release and recovers from stale", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:token"
|
||||||
|
const lockDir = lock(dir, key)
|
||||||
|
const meta = path.join(lockDir, "meta.json")
|
||||||
|
|
||||||
|
const err = await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
const json = await Filesystem.readJson<{ token?: string }>(meta)
|
||||||
|
json.token = "tampered"
|
||||||
|
await fs.writeFile(meta, JSON.stringify(json, null, 2))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 500,
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
},
|
||||||
|
).catch((err) => err)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
if (!(err instanceof Error)) throw err
|
||||||
|
expect(err.message).toContain("token mismatch")
|
||||||
|
expect(await exists(lockDir)).toBe(true)
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
await Flock.withLock(
|
||||||
|
key,
|
||||||
|
async () => {
|
||||||
|
hit = true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dir,
|
||||||
|
staleMs: 500,
|
||||||
|
timeoutMs: 6_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails clearly on unwritable lock roots", async () => {
|
||||||
|
if (process.platform === "win32") return
|
||||||
|
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dir = path.join(tmp.path, "locks")
|
||||||
|
const key = "flock:perm"
|
||||||
|
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
await fs.chmod(dir, 0o500)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const err = await Flock.withLock(key, async () => {}, {
|
||||||
|
dir,
|
||||||
|
staleMs: 100,
|
||||||
|
timeoutMs: 500,
|
||||||
|
}).catch((err) => err)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
if (!(err instanceof Error)) throw err
|
||||||
|
const text = err.message
|
||||||
|
expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await fs.chmod(dir, 0o700)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./tool": "./src/tool.ts"
|
"./tool": "./src/tool.ts",
|
||||||
|
"./tui": "./src/tui.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -19,7 +20,21 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.1.90",
|
||||||
|
"@opentui/solid": ">=0.1.90"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentui/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/solid": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opentui/core": "0.1.90",
|
||||||
|
"@opentui/solid": "0.1.90",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Auth,
|
Auth,
|
||||||
Config,
|
Config as SDKConfig,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
|
|
||||||
import type { BunShell } from "./shell.js"
|
import type { BunShell } from "./shell.js"
|
||||||
@@ -32,7 +32,18 @@ export type PluginInput = {
|
|||||||
$: BunShell
|
$: BunShell
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Plugin = (input: PluginInput) => Promise<Hooks>
|
export type PluginOptions = Record<string, unknown>
|
||||||
|
|
||||||
|
export type Config = Omit<SDKConfig, "plugin"> & {
|
||||||
|
plugin?: Array<string | [string, PluginOptions]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
|
||||||
|
|
||||||
|
export type PluginModule = {
|
||||||
|
id?: string
|
||||||
|
server?: Plugin
|
||||||
|
}
|
||||||
|
|
||||||
type Rule = {
|
type Rule = {
|
||||||
key: string
|
key: string
|
||||||
@@ -72,7 +83,7 @@ export type AuthHook = {
|
|||||||
when?: Rule
|
when?: Rule
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
|
authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "api"
|
type: "api"
|
||||||
@@ -116,7 +127,7 @@ export type AuthHook = {
|
|||||||
)[]
|
)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthOuathResult = { url: string; instructions: string } & (
|
export type AuthOAuthResult = { url: string; instructions: string } & (
|
||||||
| {
|
| {
|
||||||
method: "auto"
|
method: "auto"
|
||||||
callback(): Promise<
|
callback(): Promise<
|
||||||
@@ -161,6 +172,9 @@ export type AuthOuathResult = { url: string; instructions: string } & (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** @deprecated Use AuthOAuthResult instead. */
|
||||||
|
export type AuthOuathResult = AuthOAuthResult
|
||||||
|
|
||||||
export interface Hooks {
|
export interface Hooks {
|
||||||
event?: (input: { event: Event }) => Promise<void>
|
event?: (input: { event: Event }) => Promise<void>
|
||||||
config?: (input: Config) => Promise<void>
|
config?: (input: Config) => Promise<void>
|
||||||
|
|||||||
419
packages/plugin/src/tui.ts
Normal file
419
packages/plugin/src/tui.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import type {
|
||||||
|
OpencodeClient,
|
||||||
|
Event,
|
||||||
|
LspStatus,
|
||||||
|
McpStatus,
|
||||||
|
Todo,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
Provider,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
SessionStatus,
|
||||||
|
Workspace,
|
||||||
|
Config as SdkConfig,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
|
||||||
|
import type { JSX, SolidPlugin } from "@opentui/solid"
|
||||||
|
import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
|
||||||
|
|
||||||
|
export type { CliRenderer, SlotMode } from "@opentui/core"
|
||||||
|
|
||||||
|
export type TuiRouteCurrent =
|
||||||
|
| {
|
||||||
|
name: "home"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: "session"
|
||||||
|
params: {
|
||||||
|
sessionID: string
|
||||||
|
initialPrompt?: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiRouteDefinition = {
|
||||||
|
name: string
|
||||||
|
render: (input: { params?: Record<string, unknown> }) => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiCommand = {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
keybind?: string
|
||||||
|
suggested?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
slash?: {
|
||||||
|
name: string
|
||||||
|
aliases?: string[]
|
||||||
|
}
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiKeybind = {
|
||||||
|
name: string
|
||||||
|
ctrl: boolean
|
||||||
|
meta: boolean
|
||||||
|
shift: boolean
|
||||||
|
super?: boolean
|
||||||
|
leader: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiKeybindMap = Record<string, string>
|
||||||
|
|
||||||
|
export type TuiKeybindSet = {
|
||||||
|
readonly all: TuiKeybindMap
|
||||||
|
get: (name: string) => string
|
||||||
|
match: (name: string, evt: ParsedKey) => boolean
|
||||||
|
print: (name: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogProps = {
|
||||||
|
size?: "medium" | "large" | "xlarge"
|
||||||
|
onClose: () => void
|
||||||
|
children?: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogStack = {
|
||||||
|
replace: (render: () => JSX.Element, onClose?: () => void) => void
|
||||||
|
clear: () => void
|
||||||
|
setSize: (size: "medium" | "large" | "xlarge") => void
|
||||||
|
readonly size: "medium" | "large" | "xlarge"
|
||||||
|
readonly depth: number
|
||||||
|
readonly open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogAlertProps = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogConfirmProps = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogPromptProps = {
|
||||||
|
title: string
|
||||||
|
description?: () => JSX.Element
|
||||||
|
placeholder?: string
|
||||||
|
value?: string
|
||||||
|
onConfirm?: (value: string) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogSelectOption<Value = unknown> = {
|
||||||
|
title: string
|
||||||
|
value: Value
|
||||||
|
description?: string
|
||||||
|
footer?: JSX.Element | string
|
||||||
|
category?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDialogSelectProps<Value = unknown> = {
|
||||||
|
title: string
|
||||||
|
placeholder?: string
|
||||||
|
options: TuiDialogSelectOption<Value>[]
|
||||||
|
flat?: boolean
|
||||||
|
onMove?: (option: TuiDialogSelectOption<Value>) => void
|
||||||
|
onFilter?: (query: string) => void
|
||||||
|
onSelect?: (option: TuiDialogSelectOption<Value>) => void
|
||||||
|
skipFilter?: boolean
|
||||||
|
current?: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiToast = {
|
||||||
|
variant?: "info" | "success" | "warning" | "error"
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiThemeCurrent = {
|
||||||
|
readonly primary: RGBA
|
||||||
|
readonly secondary: RGBA
|
||||||
|
readonly accent: RGBA
|
||||||
|
readonly error: RGBA
|
||||||
|
readonly warning: RGBA
|
||||||
|
readonly success: RGBA
|
||||||
|
readonly info: RGBA
|
||||||
|
readonly text: RGBA
|
||||||
|
readonly textMuted: RGBA
|
||||||
|
readonly selectedListItemText: RGBA
|
||||||
|
readonly background: RGBA
|
||||||
|
readonly backgroundPanel: RGBA
|
||||||
|
readonly backgroundElement: RGBA
|
||||||
|
readonly backgroundMenu: RGBA
|
||||||
|
readonly border: RGBA
|
||||||
|
readonly borderActive: RGBA
|
||||||
|
readonly borderSubtle: RGBA
|
||||||
|
readonly diffAdded: RGBA
|
||||||
|
readonly diffRemoved: RGBA
|
||||||
|
readonly diffContext: RGBA
|
||||||
|
readonly diffHunkHeader: RGBA
|
||||||
|
readonly diffHighlightAdded: RGBA
|
||||||
|
readonly diffHighlightRemoved: RGBA
|
||||||
|
readonly diffAddedBg: RGBA
|
||||||
|
readonly diffRemovedBg: RGBA
|
||||||
|
readonly diffContextBg: RGBA
|
||||||
|
readonly diffLineNumber: RGBA
|
||||||
|
readonly diffAddedLineNumberBg: RGBA
|
||||||
|
readonly diffRemovedLineNumberBg: RGBA
|
||||||
|
readonly markdownText: RGBA
|
||||||
|
readonly markdownHeading: RGBA
|
||||||
|
readonly markdownLink: RGBA
|
||||||
|
readonly markdownLinkText: RGBA
|
||||||
|
readonly markdownCode: RGBA
|
||||||
|
readonly markdownBlockQuote: RGBA
|
||||||
|
readonly markdownEmph: RGBA
|
||||||
|
readonly markdownStrong: RGBA
|
||||||
|
readonly markdownHorizontalRule: RGBA
|
||||||
|
readonly markdownListItem: RGBA
|
||||||
|
readonly markdownListEnumeration: RGBA
|
||||||
|
readonly markdownImage: RGBA
|
||||||
|
readonly markdownImageText: RGBA
|
||||||
|
readonly markdownCodeBlock: RGBA
|
||||||
|
readonly syntaxComment: RGBA
|
||||||
|
readonly syntaxKeyword: RGBA
|
||||||
|
readonly syntaxFunction: RGBA
|
||||||
|
readonly syntaxVariable: RGBA
|
||||||
|
readonly syntaxString: RGBA
|
||||||
|
readonly syntaxNumber: RGBA
|
||||||
|
readonly syntaxType: RGBA
|
||||||
|
readonly syntaxOperator: RGBA
|
||||||
|
readonly syntaxPunctuation: RGBA
|
||||||
|
readonly thinkingOpacity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiTheme = {
|
||||||
|
readonly current: TuiThemeCurrent
|
||||||
|
readonly selected: string
|
||||||
|
has: (name: string) => boolean
|
||||||
|
set: (name: string) => boolean
|
||||||
|
install: (jsonPath: string) => Promise<void>
|
||||||
|
mode: () => "dark" | "light"
|
||||||
|
readonly ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiKV = {
|
||||||
|
get: <Value = unknown>(key: string, fallback?: Value) => Value
|
||||||
|
set: (key: string, value: unknown) => void
|
||||||
|
readonly ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiState = {
|
||||||
|
readonly ready: boolean
|
||||||
|
readonly config: SdkConfig
|
||||||
|
readonly provider: ReadonlyArray<Provider>
|
||||||
|
readonly path: {
|
||||||
|
state: string
|
||||||
|
config: string
|
||||||
|
worktree: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
readonly vcs: { branch?: string } | undefined
|
||||||
|
readonly workspace: {
|
||||||
|
list: () => ReadonlyArray<Workspace>
|
||||||
|
get: (workspaceID: string) => Workspace | undefined
|
||||||
|
}
|
||||||
|
session: {
|
||||||
|
count: () => number
|
||||||
|
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
||||||
|
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
|
||||||
|
messages: (sessionID: string) => ReadonlyArray<Message>
|
||||||
|
status: (sessionID: string) => SessionStatus | undefined
|
||||||
|
permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
|
||||||
|
question: (sessionID: string) => ReadonlyArray<QuestionRequest>
|
||||||
|
}
|
||||||
|
part: (messageID: string) => ReadonlyArray<Part>
|
||||||
|
lsp: () => ReadonlyArray<TuiSidebarLspItem>
|
||||||
|
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
|
||||||
|
NonNullable<PluginConfig["tui"]> & {
|
||||||
|
plugin_enabled?: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiApp = {
|
||||||
|
readonly version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frozen<Value> = Value extends (...args: never[]) => unknown
|
||||||
|
? Value
|
||||||
|
: Value extends ReadonlyArray<infer Item>
|
||||||
|
? ReadonlyArray<Frozen<Item>>
|
||||||
|
: Value extends object
|
||||||
|
? { readonly [Key in keyof Value]: Frozen<Value[Key]> }
|
||||||
|
: Value
|
||||||
|
|
||||||
|
export type TuiSidebarMcpItem = {
|
||||||
|
name: string
|
||||||
|
status: McpStatus["status"]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
|
||||||
|
|
||||||
|
export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
|
||||||
|
|
||||||
|
export type TuiSidebarFileItem = {
|
||||||
|
file: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiSlotMap = {
|
||||||
|
app: {}
|
||||||
|
home_logo: {}
|
||||||
|
home_bottom: {}
|
||||||
|
sidebar_title: {
|
||||||
|
session_id: string
|
||||||
|
title: string
|
||||||
|
share_url?: string
|
||||||
|
}
|
||||||
|
sidebar_content: {
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
sidebar_footer: {
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiSlotContext = {
|
||||||
|
theme: TuiTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlotCore = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||||
|
|
||||||
|
export type TuiSlotPlugin = Omit<SlotCore, "id"> & {
|
||||||
|
id?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiSlots = {
|
||||||
|
register: (plugin: TuiSlotPlugin) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiEventBus = {
|
||||||
|
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiDispose = () => void | Promise<void>
|
||||||
|
|
||||||
|
export type TuiLifecycle = {
|
||||||
|
readonly signal: AbortSignal
|
||||||
|
onDispose: (fn: TuiDispose) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginState = "first" | "updated" | "same"
|
||||||
|
|
||||||
|
export type TuiPluginEntry = {
|
||||||
|
id: string
|
||||||
|
source: "file" | "npm" | "internal"
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
requested?: string
|
||||||
|
version?: string
|
||||||
|
modified?: number
|
||||||
|
first_time: number
|
||||||
|
last_time: number
|
||||||
|
time_changed: number
|
||||||
|
load_count: number
|
||||||
|
fingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginMeta = TuiPluginEntry & {
|
||||||
|
state: TuiPluginState
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginStatus = {
|
||||||
|
id: string
|
||||||
|
source: TuiPluginEntry["source"]
|
||||||
|
spec: string
|
||||||
|
target: string
|
||||||
|
enabled: boolean
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginInstallOptions = {
|
||||||
|
global?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginInstallResult =
|
||||||
|
| {
|
||||||
|
ok: true
|
||||||
|
dir: string
|
||||||
|
tui: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
missing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiWorkspace = {
|
||||||
|
current: () => string | undefined
|
||||||
|
set: (workspaceID?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPluginApi = {
|
||||||
|
app: TuiApp
|
||||||
|
command: {
|
||||||
|
register: (cb: () => TuiCommand[]) => () => void
|
||||||
|
trigger: (value: string) => void
|
||||||
|
}
|
||||||
|
route: {
|
||||||
|
register: (routes: TuiRouteDefinition[]) => () => void
|
||||||
|
navigate: (name: string, params?: Record<string, unknown>) => void
|
||||||
|
readonly current: TuiRouteCurrent
|
||||||
|
}
|
||||||
|
ui: {
|
||||||
|
Dialog: (props: TuiDialogProps) => JSX.Element
|
||||||
|
DialogAlert: (props: TuiDialogAlertProps) => JSX.Element
|
||||||
|
DialogConfirm: (props: TuiDialogConfirmProps) => JSX.Element
|
||||||
|
DialogPrompt: (props: TuiDialogPromptProps) => JSX.Element
|
||||||
|
DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value>) => JSX.Element
|
||||||
|
toast: (input: TuiToast) => void
|
||||||
|
dialog: TuiDialogStack
|
||||||
|
}
|
||||||
|
keybind: {
|
||||||
|
match: (key: string, evt: ParsedKey) => boolean
|
||||||
|
print: (key: string) => string
|
||||||
|
create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
|
||||||
|
}
|
||||||
|
readonly tuiConfig: Frozen<TuiConfigView>
|
||||||
|
kv: TuiKV
|
||||||
|
state: TuiState
|
||||||
|
theme: TuiTheme
|
||||||
|
client: OpencodeClient
|
||||||
|
scopedClient: (workspaceID?: string) => OpencodeClient
|
||||||
|
workspace: TuiWorkspace
|
||||||
|
event: TuiEventBus
|
||||||
|
renderer: CliRenderer
|
||||||
|
slots: TuiSlots
|
||||||
|
plugins: {
|
||||||
|
list: () => ReadonlyArray<TuiPluginStatus>
|
||||||
|
activate: (id: string) => Promise<boolean>
|
||||||
|
deactivate: (id: string) => Promise<boolean>
|
||||||
|
add: (spec: string) => Promise<boolean>
|
||||||
|
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
|
||||||
|
}
|
||||||
|
lifecycle: TuiLifecycle
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
|
||||||
|
|
||||||
|
export type TuiPluginModule = PluginModule & {
|
||||||
|
tui?: TuiPlugin
|
||||||
|
}
|
||||||
@@ -1447,7 +1447,15 @@ export type Config = {
|
|||||||
watcher?: {
|
watcher?: {
|
||||||
ignore?: Array<string>
|
ignore?: Array<string>
|
||||||
}
|
}
|
||||||
plugin?: Array<string>
|
plugin?: Array<
|
||||||
|
| string
|
||||||
|
| [
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
[key: string]: unknown
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>
|
||||||
/**
|
/**
|
||||||
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
|
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user