From e89f5084d328fd8332edbb4faec62fdc8b77d2a2 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Tue, 24 Mar 2026 21:29:42 +0100 Subject: [PATCH] squashed --- .opencode/plugins/smoke-theme.json | 223 ++++ .opencode/plugins/tui-smoke.tsx | 965 ++++++++++++++++++ .opencode/themes/.gitignore | 1 + .opencode/tui.json | 19 + .../src/components/status-popover-body.tsx | 4 +- packages/opencode/bunfig.toml | 2 +- packages/opencode/script/build.ts | 5 +- packages/opencode/src/bun/index.ts | 11 +- packages/opencode/src/bun/registry.ts | 6 + packages/opencode/src/cli/cmd/db.ts | 5 +- packages/opencode/src/cli/cmd/plug.ts | 331 ++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 363 ++++--- .../cli/cmd/tui/component/dialog-command.tsx | 36 +- .../cli/cmd/tui/component/dialog-status.tsx | 3 +- .../cli/cmd/tui/component/error-component.tsx | 91 ++ .../tui/component/plugin-route-missing.tsx | 14 + .../cli/cmd/tui/component/startup-loading.tsx | 63 ++ .../opencode/src/cli/cmd/tui/context/exit.tsx | 3 +- .../src/cli/cmd/tui/context/keybind.tsx | 21 +- .../cli/cmd/tui/context/plugin-keybinds.ts | 41 + .../src/cli/cmd/tui/context/route.tsx | 9 +- .../src/cli/cmd/tui/context/theme.tsx | 178 +++- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 277 +++++ .../opencode/src/cli/cmd/tui/plugin/index.ts | 3 + .../src/cli/cmd/tui/plugin/internal.ts | 7 + .../src/cli/cmd/tui/plugin/runtime.ts | 581 +++++++++++ .../opencode/src/cli/cmd/tui/plugin/slots.tsx | 62 ++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 37 +- .../src/cli/cmd/tui/routes/session/index.tsx | 1 - .../cli/cmd/tui/routes/session/sidebar.tsx | 488 +++++---- packages/opencode/src/cli/cmd/tui/thread.ts | 5 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 5 + packages/opencode/src/cli/error.ts | 15 +- packages/opencode/src/config/config.ts | 125 ++- packages/opencode/src/config/tui-schema.ts | 1 + packages/opencode/src/config/tui.ts | 146 ++- packages/opencode/src/flag/flag.ts | 25 + packages/opencode/src/index.ts | 17 +- packages/opencode/src/plugin/index.ts | 133 ++- packages/opencode/src/plugin/meta.ts | 181 ++++ packages/opencode/src/plugin/shared.ts | 33 + packages/opencode/src/provider/auth.ts | 6 +- packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/tool/batch.ts | 3 +- packages/opencode/src/util/error.ts | 77 ++ packages/opencode/src/util/flock.ts | 333 ++++++ .../src/util/{proxied.ts => network.ts} | 6 + packages/opencode/src/util/process.ts | 3 +- packages/opencode/src/util/record.ts | 3 + packages/opencode/src/worktree/index.ts | 12 +- .../test/cli/plug-concurrency.test.ts | 134 +++ packages/opencode/test/cli/plug-task.test.ts | 351 +++++++ .../test/cli/tui/keybind-plugin.test.ts | 90 ++ .../test/cli/tui/plugin-lifecycle.test.ts | 359 +++++++ .../tui/plugin-loader-error-logging.test.ts | 84 ++ .../tui/plugin-loader-missing-meta.test.ts | 104 ++ .../test/cli/tui/plugin-loader-pure.test.ts | 71 ++ .../test/cli/tui/plugin-loader.test.ts | 483 +++++++++ .../opencode/test/cli/tui/theme-store.test.ts | 50 + packages/opencode/test/config/config.test.ts | 78 +- packages/opencode/test/config/tui.test.ts | 124 ++- .../opencode/test/fixture/flock-worker.ts | 72 ++ packages/opencode/test/fixture/plug-worker.ts | 99 ++ .../test/fixture/plugin-meta-worker.ts | 24 + packages/opencode/test/fixture/tui-plugin.ts | 190 ++++ .../test/plugin/loader-shared.test.ts | 365 +++++++ packages/opencode/test/plugin/meta.test.ts | 134 +++ packages/opencode/test/util/error.test.ts | 38 + packages/opencode/test/util/flock.test.ts | 383 +++++++ packages/plugin/package.json | 17 +- packages/plugin/src/index.ts | 17 +- packages/plugin/src/tui.ts | 350 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 10 +- 73 files changed, 7968 insertions(+), 641 deletions(-) create mode 100644 .opencode/plugins/smoke-theme.json create mode 100644 .opencode/plugins/tui-smoke.tsx create mode 100644 .opencode/themes/.gitignore create mode 100644 .opencode/tui.json create mode 100644 packages/opencode/src/cli/cmd/plug.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/error-component.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/api.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/index.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/internal.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/slots.tsx create mode 100644 packages/opencode/src/plugin/meta.ts create mode 100644 packages/opencode/src/plugin/shared.ts create mode 100644 packages/opencode/src/util/error.ts create mode 100644 packages/opencode/src/util/flock.ts rename packages/opencode/src/util/{proxied.ts => network.ts} (50%) create mode 100644 packages/opencode/src/util/record.ts create mode 100644 packages/opencode/test/cli/plug-concurrency.test.ts create mode 100644 packages/opencode/test/cli/plug-task.test.ts create mode 100644 packages/opencode/test/cli/tui/keybind-plugin.test.ts create mode 100644 packages/opencode/test/cli/tui/plugin-lifecycle.test.ts create mode 100644 packages/opencode/test/cli/tui/plugin-loader-error-logging.test.ts create mode 100644 packages/opencode/test/cli/tui/plugin-loader-missing-meta.test.ts create mode 100644 packages/opencode/test/cli/tui/plugin-loader-pure.test.ts create mode 100644 packages/opencode/test/cli/tui/plugin-loader.test.ts create mode 100644 packages/opencode/test/cli/tui/theme-store.test.ts create mode 100644 packages/opencode/test/fixture/flock-worker.ts create mode 100644 packages/opencode/test/fixture/plug-worker.ts create mode 100644 packages/opencode/test/fixture/plugin-meta-worker.ts create mode 100644 packages/opencode/test/fixture/tui-plugin.ts create mode 100644 packages/opencode/test/plugin/loader-shared.test.ts create mode 100644 packages/opencode/test/plugin/meta.test.ts create mode 100644 packages/opencode/test/util/error.test.ts create mode 100644 packages/opencode/test/util/flock.test.ts create mode 100644 packages/plugin/src/tui.ts diff --git a/.opencode/plugins/smoke-theme.json b/.opencode/plugins/smoke-theme.json new file mode 100644 index 0000000000..5c84b54b0d --- /dev/null +++ b/.opencode/plugins/smoke-theme.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord6", + "light": "nord0" + }, + "textMuted": { + "dark": "#8B95A7", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord2", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#36413C", + "light": "#E6EBE7" + }, + "diffRemovedBg": { + "dark": "#43393D", + "light": "#ECE6E8" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#303A35", + "light": "#DDE4DF" + }, + "diffRemovedLineNumberBg": { + "dark": "#3C3336", + "light": "#E4DDE0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "#8B95A7", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx new file mode 100644 index 0000000000..67e6d530df --- /dev/null +++ b/.opencode/plugins/tui-smoke.tsx @@ -0,0 +1,965 @@ +/** @jsxImportSource @opentui/solid */ +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { RGBA, VignetteEffect } from "@opentui/core" +import type { TuiApi, 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 | 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 | 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 cash = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +const ink = (map: Record, 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) => { + 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: TuiApi) => { + 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 ( + { + props.run() + }} + backgroundColor={props.on ? props.skin.accent : props.skin.border} + paddingLeft={1} + paddingRight={1} + > + {props.txt} + + ) +} + +const parse = (params: Record | 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: TuiApi, 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: TuiApi, input: Cfg, skin: Skin) => { + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + + + {input.label} host overlay + + Using api.ui.dialog stack with built-in backdrop + esc closes · depth {api.ui.dialog.depth} + + api.ui.dialog.clear()} skin={skin} on /> + + + )) +} + +const warn = (api: TuiApi, route: Route, value: State) => { + const DialogAlert = api.ui.DialogAlert + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + api.route.navigate(route.screen, { ...value, source: "alert" })} + /> + )) +} + +const check = (api: TuiApi, route: Route, value: State) => { + const DialogConfirm = api.ui.DialogConfirm + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + 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: TuiApi, route: Route, value: State) => { + const DialogPrompt = api.ui.DialogPrompt + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + { + 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: TuiApi, route: Route, value: State) => { + const DialogSelect = api.ui.DialogSelect + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ( + { + 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: TuiApi + input: Cfg + route: Route + keys: Keys + meta: TuiPluginMeta + params?: Record +}) => { + 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 ( + + + + + {props.input.label} screen + plugin route + + {props.keys.print("home")} home + + + + {tabs.map((item, i) => { + const on = value.tab === i + return ( + props.api.route.navigate(props.route.screen, { ...value, tab: i })} + skin={skin} + on={on} + /> + ) + })} + + + + {value.tab === 0 ? ( + + Route: {props.route.screen} + plugin state: {props.meta.state} + + first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "} + {props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count} + + plugin source: {props.meta.source} + source: {value.source} + note: {value.note || "(none)"} + selected: {value.selected || "(none)"} + local stack depth: {value.local} + host stack open: {props.api.ui.dialog.open ? "yes" : "no"} + + ) : null} + + {value.tab === 1 ? ( + + Counter: {value.count} + + {props.keys.print("up")} / {props.keys.print("down")} change value + + + ) : null} + + {value.tab === 2 ? ( + + + {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 + + + {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + + + local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} + close + + {props.keys.print("home")} returns home + + ) : null} + + + + props.api.route.navigate("home")} skin={skin} /> + props.api.route.navigate(props.route.modal, value)} skin={skin} on /> + + host(props.api, props.input, skin)} skin={skin} /> + warn(props.api, props.route, value)} skin={skin} /> + check(props.api, props.route, value)} skin={skin} /> + entry(props.api, props.route, value)} skin={skin} /> + picker(props.api, props.route, value)} skin={skin} /> + + + + 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() + }} + > + { + 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" + > + + {props.input.label} local overlay + + Plugin-owned stack depth: {value.local} + + {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + + + + + + + + + ) +} + +const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record }) => { + 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 ( + + props.api.route.navigate("home")}> + + + {props.input.label} modal + + {props.keys.print("modal")} modal command + {props.keys.print("screen")} screen command + + {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes + + + props.api.route.navigate(props.route.screen, { ...value, source: "modal" })} + skin={skin} + on + /> + props.api.route.navigate("home")} skin={skin} /> + + + + + ) +} + +const slot = (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 ( + + {art.map((line, i) => ( + {line} + ))} + + ) + }, + home_tips(ctx, value) { + if (!value.show_tips) return null + const skin = look(ctx.theme.current) + + return ( + + + {input.label} replaces the built-in home tips slot + + + ) + }, + home_below_tips(ctx, value) { + const skin = look(ctx.theme.current) + const text = value.first_time_user + ? "first-time user state" + : value.tips_hidden + ? "tips are hidden" + : "extra content below tips" + + return ( + + + + {input.label} {text} + + + + ) + }, + sidebar_top(ctx, value) { + const skin = look(ctx.theme.current) + + return ( + + + {input.label} + + sidebar slot active + session {value.session_id.slice(0, 8)} + + ) + }, + sidebar_title(ctx, value) { + const skin = look(ctx.theme.current) + + return ( + + + + {value.title} + + plugin + + session {value.session_id.slice(0, 8)} + {value.share_url ? {value.share_url} : null} + + ) + }, + sidebar_context(ctx, value) { + const skin = look(ctx.theme.current) + const used = value.percentage === null ? "n/a" : `${value.percentage}%` + const bar = + value.percentage === null ? "" : "■".repeat(Math.max(1, Math.min(10, Math.round(value.percentage / 10)))) + + return ( + + + + Context + + slot + + {value.tokens.toLocaleString()} tokens + {bar ? `${used} · ${bar}` : used} + {cash.format(value.cost)} spent + + ) + }, + sidebar_files(ctx, value) { + if (!value.items.length) return null + const map = ctx.theme.current + const skin = look(map) + const add = ink(map, "diffAdded", "#7bd389") + const del = ink(map, "diffRemoved", "#ff8e8e") + const list = value.items.slice(0, 3) + + return ( + + + + Working Tree + + {value.items.length} + + {list.map((item) => ( + + + {item.file} + + + {item.additions ? +{item.additions} : null} + {item.deletions ? -{item.deletions} : null} + + + ))} + {value.items.length > list.length ? ( + +{value.items.length - list.length} more file(s) + ) : null} + + ) + }, + sidebar_bottom(ctx, value) { + const skin = look(ctx.theme.current) + + return ( + + + {input.label} footer slot + + + append demo after {value.show_getting_started ? "welcome card" : "default footer"} + + + {value.directory_name} · {value.version} + + + ) + }, + }, +}) + +const reg = (api: TuiApi, 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 | 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 }) => , + }, + { + name: route.modal, + render: ({ params }) => , + }, + ]) + + reg(api, value, keys) + api.slots.register(slot(value)) +} + +export default { + tui, +} diff --git a/.opencode/themes/.gitignore b/.opencode/themes/.gitignore new file mode 100644 index 0000000000..5b41319c6a --- /dev/null +++ b/.opencode/themes/.gitignore @@ -0,0 +1 @@ +smoke-theme.json diff --git a/.opencode/tui.json b/.opencode/tui.json new file mode 100644 index 0000000000..bfb1ec14af --- /dev/null +++ b/.opencode/tui.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "smoke-theme", + "plugin": [ + [ + "./plugins/tui-smoke.tsx", + { + "enabled": true, + "label": "workspace", + "keybinds": { + "modal": "ctrl+alt+m", + "screen": "ctrl+alt+o", + "home": "escape,ctrl+shift+h", + "dialog_close": "escape,q" + } + } + ] + ] +} diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index aaf9f58d6a..0f6a1c1355 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -239,7 +239,9 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) - const plugins = createMemo(() => sync.data.config.plugin ?? []) + const plugins = createMemo(() => + (sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])), + ) const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index c3b7270764..33b39f719f 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -1,7 +1,7 @@ preload = ["@opentui/solid/preload"] [test] -preload = ["./test/preload.ts"] +preload = ["@opentui/solid/preload", "./test/preload.ts"] # timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun) # using --timeout in package.json scripts instead # https://github.com/oven-sh/bun/issues/7789 diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 653c67d8de..074dcb01bd 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import fs from "fs" import path from "path" import { fileURLToPath } from "url" -import solidPlugin from "@opentui/solid/bun-plugin" +import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -63,6 +63,7 @@ console.log(`Loaded ${migrations.length} migrations`) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") const createEmbeddedWebUIBundle = async () => { @@ -200,7 +201,7 @@ for (const item of targets) { await Bun.build({ conditions: ["browser"], tsconfig: "./tsconfig.json", - plugins: [solidPlugin], + plugins: [plugin], compile: { autoloadBunfig: false, autoloadDotenv: false, diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index d6c4538259..dbdf5a2bc4 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem" import { NamedError } from "@opencode-ai/util/error" import { Lock } from "../util/lock" import { PackageRegistry } from "./registry" -import { proxied } from "@/util/proxied" +import { online, proxied } from "@/util/network" import { Process } from "../util/process" export namespace BunProc { @@ -68,12 +68,13 @@ export namespace BunProc { if (!modExists || !cachedVersion) { // continue to install - } else if (version !== "latest" && cachedVersion === version) { - return mod } else if (version === "latest") { - const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) - if (!isOutdated) return mod + if (!online()) return mod + const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) + if (!stale) return mod log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion }) + } else if (cachedVersion === version) { + return mod } // Build command arguments diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index e43e20e6c5..dead5e74d7 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -1,6 +1,7 @@ import semver from "semver" import { Log } from "../util/log" import { Process } from "../util/process" +import { online } from "@/util/network" export namespace PackageRegistry { const log = Log.create({ service: "bun" }) @@ -10,6 +11,11 @@ export namespace PackageRegistry { } export async function info(pkg: string, field: string, cwd?: string): Promise { + if (!online()) { + log.debug("offline, skipping bun info", { pkg, field }) + return null + } + const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], { cwd, env: { diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index 8ca4b9a42e..03e765dabc 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -6,6 +6,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { JsonMigration } from "../../storage/json-migration" import { EOL } from "os" +import { errorMessage } from "../../util/error" const QueryCommand = cmd({ command: "$0 [query]", @@ -39,7 +40,7 @@ const QueryCommand = cmd({ } } } catch (err) { - UI.error(err instanceof Error ? err.message : String(err)) + UI.error(errorMessage(err)) process.exit(1) } db.close() @@ -100,7 +101,7 @@ const MigrateCommand = cmd({ } } catch (err) { if (tty) process.stderr.write("\x1b[?25h") - UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`) + UI.error(`Migration failed: ${errorMessage(err)}`) process.exit(1) } finally { sqlite.close() diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts new file mode 100644 index 0000000000..dd4599ae7f --- /dev/null +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -0,0 +1,331 @@ +import { cmd } from "./cmd" +import type { Argv } from "yargs" +import { spinner, log, intro, outro } from "@clack/prompts" +import path from "path" +import type { BigIntStats, Stats } from "fs" +import { mkdir } from "fs/promises" +import { + type ParseError as JsoncParseError, + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser" +import { Instance } from "../../project/instance" +import { Global } from "../../global" +import { UI } from "../ui" +import { ConfigPaths } from "../../config/paths" +import { Filesystem } from "../../util/filesystem" +import { Flock } from "../../util/flock" +import { Process } from "../../util/process" +import { errorMessage } from "../../util/error" +import { parsePluginSpecifier, resolvePluginTarget } from "../../plugin/shared" + +type Mode = "noop" | "add" | "replace" + +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 patchPluginList(list: unknown[], mod: string, force = false): { mode: Mode; list: unknown[] } { + const pkg = parsePluginSpecifier(mod).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 === mod) return true + if (item.spec.startsWith("file://")) return false + return parsePluginSpecifier(item.spec).pkg === pkg + }) + + if (!dup.length) { + return { + mode: "add", + list: [...list, mod], + } + } + + if (!force) { + return { + mode: "noop", + list, + } + } + + const keep = dup[0] + if (!keep) { + return { + mode: "noop", + list, + } + } + + if (dup.length === 1 && keep.spec === mod) { + return { + mode: "noop", + list, + } + } + + const idx = new Set(dup.map((item) => item.i)) + return { + mode: "replace", + list: rows.flatMap((item) => { + if (!idx.has(item.i)) return [item.item] + if (item.i !== keep.i) return [] + if (typeof item.item === "string") return [mod] + if (Array.isArray(item.item) && typeof item.item[0] === "string") { + return [[mod, ...item.item.slice(1)]] + } + return [item.item] + }), + } +} + +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 + } + mkdir: (dir: string, opts: { recursive: true }) => Promise + resolve: (spec: string) => Promise + stat: (file: string) => Stats | BigIntStats | undefined + readJson: (file: string) => Promise + readText: (file: string) => Promise + write: (file: string, text: string) => Promise + exists: (file: string) => Promise + 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), + }, + mkdir: async (dir, opts) => { + await mkdir(dir, opts) + }, + resolve: (spec) => resolvePluginTarget(spec), + stat: (file) => Filesystem.stat(file), + readJson: (file) => Filesystem.readJson(file), + 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, +} + +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 root = ctx.vcs === "git" ? ctx.worktree : ctx.directory + const dir = global ? dep.global : path.join(root, ".opencode") + await dep.mkdir(dir, { recursive: true }) + + const install = dep.spinner() + install.start("Installing plugin package...") + const target = await dep.resolve(mod).catch((err) => err) + if (target instanceof Error) { + install.stop("Install failed", 1) + dep.log.error(`Could not install "${mod}"`) + if (target instanceof Process.RunFailedError) { + const lines = target.stderr + .toString() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + const errors = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, "")) + const detail = errors[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.") + } + } else { + dep.log.error(errorMessage(target)) + } + return false + } + install.stop("Plugin package ready") + + const inspect = dep.spinner() + inspect.start("Reading plugin manifest...") + const stat = dep.stat(target) + const base = stat?.isDirectory() ? target : path.dirname(target) + const file = path.join(base, "package.json") + const json = await dep.readJson>(file).catch((err) => err) + if (json instanceof Error) { + inspect.stop("Manifest read failed", 1) + dep.log.error(`Installed "${mod}" but failed to read ${file}`) + dep.log.error(errorMessage(json)) + return false + } + + const raw = json["oc-plugin"] + const kinds = Array.isArray(raw) ? raw.filter((x): x is "server" | "tui" => x === "server" || x === "tui") : [] + + if (!kinds.length) { + 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 either one).') + return false + } + inspect.stop(`Detected ${kinds.join(" + ")} target${kinds.length === 1 ? "" : "s"}`) + + const patch = async (name: "opencode" | "tui", kind: "server" | "tui") => { + const spin = dep.spinner() + spin.start(`Updating ${kind} config...`) + + 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 "{}" + throw err + }) + 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") + const line = lines.length + const col = lines[lines.length - 1].length + 1 + spin.stop(`Failed updating ${kind} config`, 1) + dep.log.error(`Invalid JSON in ${cfg} (${printParseErrorCode(err.error)} at line ${line}, column ${col})`) + dep.log.info("Fix the config file and run the command again.") + return false + } + + const list: unknown[] = + data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : [] + const out = patchPluginList(list, mod, force) + + if (out.mode === "noop") { + spin.stop(`Already configured in ${cfg}`) + return true + } + + const edits = modify(text, ["plugin"], out.list, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + }, + }) + await dep.write(cfg, applyEdits(text, edits)) + spin.stop(out.mode === "replace" ? `Replaced in ${cfg}` : `Added to ${cfg}`) + return true + } + + if (kinds.includes("server")) { + const ok = await patch("opencode", "server") + if (!ok) return false + } + + if (kinds.includes("tui")) { + const ok = await patch("tui", "tui") + if (!ok) return false + } + + dep.log.success(`Installed ${mod}`) + dep.log.info(global ? `Scope: global (${dir})` : `Scope: local (${dir})`) + return true + } +} + +export const PlugCommand = cmd({ + command: "plug ", + aliases: ["plugin"], + describe: "install plugin and update config", + builder: (yargs: Argv) => { + return yargs + .positional("module", { + type: "string", + describe: "npm module name", + }) + .option("global", { + alias: ["g"], + type: "boolean", + default: false, + describe: "install in global config", + }) + .option("force", { + alias: ["f"], + type: "boolean", + default: false, + describe: "replace existing plugin version", + }) + }, + handler: async (args) => { + const mod = String(args.module ?? "").trim() + if (!mod) { + UI.error("module is required") + process.exitCode = 1 + return + } + + UI.empty() + intro(`Install plugin ${mod}`) + + const run = createPlugTask({ + mod, + global: Boolean(args.global), + force: Boolean(args.force), + }) + let ok = true + + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + ok = await run({ + vcs: Instance.project.vcs, + worktree: Instance.worktree, + directory: Instance.directory, + }) + }, + }) + + outro("Done") + if (!ok) process.exitCode = 1 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4897cb7e82..e6786200d5 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,15 +1,29 @@ -import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" -import { MouseButton, TextAttributes } from "@opentui/core" +import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" -import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" +import { + Switch, + Match, + createEffect, + createMemo, + ErrorBoundary, + createSignal, + onMount, + batch, + Show, + on, +} from "solid-js" +import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" import semver from "semver" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" +import { ErrorComponent } from "@tui/component/error-component" +import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { SDKProvider, useSDK } from "@tui/context/sdk" +import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" @@ -21,7 +35,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" -import { KeybindProvider } from "@tui/context/keybind" +import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -40,8 +54,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" -import { TuiConfigProvider } from "./context/tui-config" +import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" +import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" +import { FormatError, FormatUnknownError } from "@/cli/error" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -104,7 +120,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { } import type { EventSource } from "./context/sdk" -import { Installation } from "@/installation" + +function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { + return { + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: { events: process.platform === "win32" }, + autoFocus: false, + openConsoleOnError: false, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + onCopySelection: (text) => { + Clipboard.copy(text).catch((error) => { + console.error(`Failed to copy console selection to clipboard: ${error}`) + }) + }, + }, + } +} + +function errorMessage(error: unknown) { + const formatted = FormatError(error) + if (formatted !== undefined) return formatted + if ( + typeof error === "object" && + error !== null && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "message" in error.data && + typeof error.data.message === "string" + ) { + return error.data.message + } + return FormatUnknownError(error) +} export function tui(input: { url: string @@ -132,77 +183,68 @@ export function tui(input: { resolve() } - render( - () => { - 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}`) - }) - }, - }, - }, - ) + const onBeforeExit = async () => { + await TuiPluginRuntime.dispose() + } + + const renderer = await createCliRenderer(rendererConfig(input.config)) + + await render(() => { + return ( + ( + + )} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) }) } function App(props: { onSnapshot?: () => Promise }) { + const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -211,12 +253,46 @@ function App(props: { onSnapshot?: () => Promise }) { const local = useLocal() const kv = useKV() const command = useCommandDialog() + const keybind = useKeybind() const sdk = useSDK() const toast = useToast() - const { theme, mode, setMode, locked, lock, unlock } = useTheme() + const themeState = useTheme() + const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const routes: RouteMap = new Map() + const [routeRev, setRouteRev] = createSignal(0) + const routeView = (name: string) => { + routeRev() + return routes.get(name)?.at(-1)?.render + } + const api = createTuiApi({ + command, + tuiConfig, + dialog, + keybind, + kv, + route, + routes, + bump: () => setRouteRev((x) => x + 1), + sync, + theme: themeState, + toast, + }) + const [ready, setReady] = createSignal(false) + TuiPluginRuntime.init({ + client: sdk.client, + event: sdk.event, + renderer, + ...api, + }) + .catch((error) => { + console.error("Failed to load TUI plugins", error) + }) + .finally(() => { + setReady(true) + }) useKeyboard((evt) => { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return @@ -259,10 +335,6 @@ function App(props: { onSnapshot?: () => Promise }) { } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) - createEffect(() => { - console.log(JSON.stringify(route.data)) - }) - // Update terminal window title based on current route and session createEffect(() => { if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return @@ -279,9 +351,13 @@ function App(props: { onSnapshot?: () => Promise }) { return } - // Truncate title to 40 chars max const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title renderer.setTerminalTitle(`OC | ${title}`) + return + } + + if (route.data.type === "plugin") { + renderer.setTerminalTitle(`OC | ${route.data.id}`) } }) @@ -723,17 +799,7 @@ function App(props: { onSnapshot?: () => Promise }) { sdk.event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return - const message = (() => { - if (!error) return "An error occurred" - - if (typeof error === "object") { - const data = error.data - if ("message" in data && typeof data.message === "string") { - return data.message - } - } - return String(error) - })() + const message = errorMessage(error) toast.show({ variant: "error", @@ -789,6 +855,14 @@ function App(props: { onSnapshot?: () => Promise }) { exit() }) + const plugin = createMemo(() => { + if (!ready()) return + if (route.data.type !== "plugin") return + const render = routeView(route.data.id) + if (!render) return route.navigate({ type: "home" })} /> + return render({ params: route.data.data }) + }) + return ( Promise }) { }} onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)} > - - - - - - - - - - ) -} - -function ErrorComponent(props: { - error: Error - reset: () => void - onExit: () => Promise - 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 ( - - - - Please report an issue. - - - - Copy issue URL (exception info pre-filled) - - - {copied() && Successfully copied} - - - A fatal error occurred! - - Reset TUI - - - Exit - - - - {props.error.stack} - - {props.error.message} + + + + + + + + + + + + + + {plugin()} + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index be031296e9..f42ba15ec0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -4,13 +4,15 @@ import { createContext, createMemo, createSignal, + getOwner, onCleanup, + runWithOwner, useContext, type Accessor, type ParentProps, } from "solid-js" import { useKeyboard } from "@opentui/solid" -import { type KeybindKey, useKeybind } from "@tui/context/keybind" +import { useKeybind } from "@tui/context/keybind" type Context = ReturnType const ctx = createContext() @@ -21,7 +23,7 @@ export type Slash = { } export type CommandOption = DialogSelectOption & { - keybind?: KeybindKey + keybind?: string suggested?: boolean slash?: Slash hidden?: boolean @@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption & { } function init() { + const root = getOwner() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) const dialog = useDialog() @@ -100,11 +103,32 @@ function init() { dialog.replace(() => ) }, register(cb: () => CommandOption[]) { - const results = createMemo(cb) - setRegistrations((arr) => [results, ...arr]) - onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== results)) + const owner = getOwner() ?? root + if (!owner) return () => {} + + let list: Accessor | undefined + + // TUI plugins now register commands via an async store that runs outside an active reactive scope. + // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly. + runWithOwner(owner, () => { + list = createMemo(cb) + const ref = list + if (!ref) return + setRegistrations((arr) => [ref, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== ref)) + }) }) + + if (!list) return () => {} + let done = false + return () => { + if (done) return + done = true + const ref = list + if (!ref) return + setRegistrations((arr) => arr.filter((x) => x !== ref)) + } }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef218..ebc65a45b7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -16,7 +16,8 @@ export function DialogStatus() { const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] - const result = list.map((value) => { + const result = list.map((item) => { + const value = typeof item === "string" ? item : item[0] if (value.startsWith("file://")) { const path = fileURLToPath(value) const parts = path.split("/") diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx new file mode 100644 index 0000000000..c568e54e42 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -0,0 +1,91 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { Clipboard } from "@tui/util/clipboard" +import { createSignal } from "solid-js" +import { Installation } from "@/installation" +import { win32FlushInputBuffer } from "../win32" + +export function ErrorComponent(props: { + error: Error + reset: () => void + onBeforeExit?: () => Promise + onExit: () => Promise + 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 ( + + + + Please report an issue. + + + + Copy issue URL (exception info pre-filled) + + + {copied() && Successfully copied} + + + A fatal error occurred! + + Reset TUI + + + Exit + + + + {props.error.stack} + + {props.error.message} + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx b/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx new file mode 100644 index 0000000000..77e2ea8dd3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/plugin-route-missing.tsx @@ -0,0 +1,14 @@ +import { useTheme } from "../context/theme" + +export function PluginRouteMissing(props: { id: string; onHome: () => void }) { + const { theme } = useTheme() + + return ( + + Unknown plugin route: {props.id} + + go home + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx new file mode 100644 index 0000000000..6665c0c2e8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx @@ -0,0 +1,63 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" +import { useTheme } from "../context/theme" +import { Spinner } from "./spinner" + +export function StartupLoading(props: { ready: () => boolean }) { + const theme = useTheme().theme + const [show, setShow] = createSignal(false) + const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) + let wait: NodeJS.Timeout | undefined + let hold: NodeJS.Timeout | undefined + let stamp = 0 + + createEffect(() => { + if (props.ready()) { + if (wait) { + clearTimeout(wait) + wait = undefined + } + if (!show()) return + if (hold) return + + const left = 3000 - (Date.now() - stamp) + if (left <= 0) { + setShow(false) + return + } + + hold = setTimeout(() => { + hold = undefined + setShow(false) + }, left).unref() + return + } + + if (hold) { + clearTimeout(hold) + hold = undefined + } + if (show()) return + if (wait) return + + wait = setTimeout(() => { + wait = undefined + stamp = Date.now() + setShow(true) + }, 500).unref() + }) + + onCleanup(() => { + if (wait) clearTimeout(wait) + if (hold) clearTimeout(hold) + }) + + return ( + + + + {text()} + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 236320cf06..205025f867 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise) & { export const { use: useExit, provider: ExitProvider } = createSimpleContext({ name: "Exit", - init: (input: { onExit?: () => Promise }) => { + init: (input: { onBeforeExit?: () => Promise; onExit?: () => Promise }) => { const renderer = useRenderer() let message: string | undefined let task: Promise | undefined @@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ (reason?: unknown) => { if (task) return task task = (async () => { + await input.onBeforeExit?.() // Reset window title before destroying renderer renderer.setTerminalTitle("") renderer.destroy() diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 566d66ade5..8d3fe487d1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } return Keybind.fromParsedKey(evt, store.leader) }, - match(key: KeybindKey, evt: ParsedKey) { - const keybind = keybinds()[key] - if (!keybind) return false + match(key: string, evt: ParsedKey) { + const list = keybinds()[key] ?? Keybind.parse(key) + if (!list.length) return false const parsed: Keybind.Info = result.parse(evt) - for (const key of keybind) { - if (Keybind.match(key, parsed)) { + for (const item of list) { + if (Keybind.match(item, parsed)) { return true } } + return false }, - print(key: KeybindKey) { - const first = keybinds()[key]?.at(0) + print(key: string) { + const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0) if (!first) return "" - const result = Keybind.toString(first) - return result.replace("", Keybind.toString(keybinds().leader![0]!)) + const text = Keybind.toString(first) + const lead = keybinds().leader?.[0] + if (!lead) return text + return text.replace("", Keybind.toString(lead)) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts new file mode 100644 index 0000000000..a84e10128c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts @@ -0,0 +1,41 @@ +import type { ParsedKey } from "@opentui/core" + +export type PluginKeybindMap = Record + +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, +): PluginKeybind { + const all = Object.freeze( + Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])), + ) + const get = (name: string) => all[name] ?? name + + return { + get all() { + return all + }, + get, + match: (name, evt) => base.match(get(name), evt), + print: (name) => base.print(get(name)), + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index e96cd2c3a4..939c2d5dc8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -14,7 +14,13 @@ export type SessionRoute = { initialPrompt?: PromptInfo } -export type Route = HomeRoute | SessionRoute +export type PluginRoute = { + type: "plugin" + id: string + data?: Record +} + +export type Route = HomeRoute | SessionRoute | PluginRoute export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ name: "Route", @@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ return store }, navigate(route: Route) { - console.log("navigate", route) setStore(route) }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index a3d268afd3..4daed8cb28 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -42,6 +42,7 @@ import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { useTuiConfig } from "./tui-config" +import { isRecord } from "@/util/record" type ThemeColors = { primary: RGBA @@ -128,7 +129,7 @@ type Variant = { light: HexColor | RefName } type ColorValue = HexColor | RefName | Variant | RGBA -type ThemeJson = { +export type ThemeJson = { $schema?: string defs?: Record theme: Omit, "selectedListItemText" | "backgroundMenu"> & { @@ -174,27 +175,91 @@ export const DEFAULT_THEMES: Record = { carbonfox, } -function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { +type State = { + themes: Record + mode: "dark" | "light" + lock: "dark" | "light" | undefined + active: string + ready: boolean +} + +const pluginThemes: Record = {} +let customThemes: Record = {} +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({ + themes: listThemes(), + mode: "dark", + lock: undefined, + active: "opencode", + ready: false, +}) + +export function allThemes() { + return store.themes +} + +function isTheme(theme: unknown): theme is ThemeJson { + if (!isRecord(theme)) return false + if (!isRecord(theme.theme)) return false + return true +} + +export function hasTheme(name: string) { + if (!name) return false + return allThemes()[name] !== undefined +} + +export function addTheme(name: string, theme: unknown) { + if (!name) return false + if (!isTheme(theme)) return false + if (hasTheme(name)) return false + pluginThemes[name] = theme + syncThemes() + return true +} + +export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} - function resolveColor(c: ColorValue): RGBA { + function resolveColor(c: ColorValue, chain: string[] = []): RGBA { if (c instanceof RGBA) return c if (typeof c === "string") { if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0) if (c.startsWith("#")) return RGBA.fromHex(c) - if (defs[c] != null) { - return resolveColor(defs[c]) - } else if (theme.theme[c as keyof ThemeColors] !== undefined) { - return resolveColor(theme.theme[c as keyof ThemeColors]!) - } else { + if (chain.includes(c)) { + throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`) + } + + const next = defs[c] ?? theme.theme[c as keyof ThemeColors] + if (next === undefined) { throw new Error(`Color reference "${c}" not found in defs or theme`) } + return resolveColor(next, [...chain, c]) } if (typeof c === "number") { return ansiToRgba(c) } - return resolveColor(c[mode]) + return resolveColor(c[mode], chain) } const resolved = Object.fromEntries( @@ -287,14 +352,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (value === "dark" || value === "light") return value return } - const lock = pick(kv.get("theme_mode_lock")) - const [store, setStore] = createStore({ - themes: DEFAULT_THEMES, - mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode, - lock, - active: (config.theme ?? kv.get("theme", "opencode")) as string, - ready: false, - }) + + setStore( + produce((draft) => { + const lock = pick(kv.get("theme_mode_lock")) + const mode = pick(kv.get("theme_mode", props.mode)) + draft.mode = lock ?? mode ?? props.mode + draft.lock = lock + const active = config.theme ?? kv.get("theme", "opencode") + draft.active = typeof active === "string" ? active : "opencode" + draft.ready = false + }), + ) createEffect(() => { const theme = config.theme @@ -302,52 +371,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) function init() { - resolveSystemTheme(store.mode) - getCustomThemes() - .then((custom) => { - setStore( - produce((draft) => { - Object.assign(draft.themes, custom) - }), - ) - }) - .catch(() => { - setStore("active", "opencode") - }) - .finally(() => { - if (store.active !== "system") { - setStore("ready", true) - } - }) + Promise.allSettled([ + resolveSystemTheme(store.mode), + getCustomThemes() + .then((custom) => { + customThemes = custom + syncThemes() + }) + .catch(() => { + setStore("active", "opencode") + }), + ]).finally(() => { + setStore("ready", true) + }) } onMount(init) function resolveSystemTheme(mode: "dark" | "light" = store.mode) { - renderer + return renderer .getPalette({ size: 16, }) - .then((colors) => { + .then((colors: TerminalColors) => { if (!colors.palette[0]) { + systemTheme = undefined + syncThemes() if (store.active === "system") { - setStore( - produce((draft) => { - draft.active = "opencode" - draft.ready = true - }), - ) + setStore("active", "opencode") } return } - setStore( - produce((draft) => { - draft.themes.system = generateSystem(colors, mode) - if (store.active === "system") { - draft.ready = true - } - }), - ) + systemTheme = generateSystem(colors, mode) + syncThemes() + }) + .catch(() => { + systemTheme = undefined + syncThemes() + if (store.active === "system") { + setStore("active", "opencode") + } }) } @@ -377,8 +440,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ apply(mode) } renderer.on(CliRenderEvents.THEME_MODE, handle) + + const refresh = () => { + renderer.clearPaletteCache() + init() + } + process.on("SIGUSR2", refresh) + onCleanup(() => { renderer.off(CliRenderEvents.THEME_MODE, handle) + process.off("SIGUSR2", refresh) }) const values = createMemo(() => { @@ -403,7 +474,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ return store.active }, all() { - return store.themes + return allThemes() + }, + has(name: string) { + return hasTheme(name) }, syntax, subtleSyntax, @@ -423,8 +497,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ pin(mode) }, set(theme: string) { + if (!hasTheme(theme)) return false setStore("active", theme) kv.set("theme", theme) + return true }, get ready() { return store.ready diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx new file mode 100644 index 0000000000..f27ee5d636 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -0,0 +1,277 @@ +import type { ParsedKey } from "@opentui/core" +import type { TuiApi, TuiDialogSelectOption, 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 { 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" + +type RouteEntry = { + key: symbol + render: TuiRouteDefinition["render"] +} + +export type RouteMap = Map + +type Input = { + command: ReturnType + tuiConfig: TuiConfig.Info + dialog: ReturnType + keybind: ReturnType + kv: ReturnType + route: ReturnType + routes: RouteMap + bump: () => void + sync: ReturnType + theme: ReturnType + toast: ReturnType +} + +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, name: string, params?: Record) { + 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): TuiApi["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(item: TuiDialogSelectOption): SelectOption { + return { + ...item, + onSelect: () => item.onSelect?.(), + } +} + +function pickOption(item: SelectOption): TuiDialogSelectOption { + return { + title: item.title, + value: item.value, + description: item.description, + footer: item.footer, + category: item.category, + disabled: item.disabled, + } +} + +function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { + if (!cb) return + return (item: SelectOption) => cb(pickOption(item)) +} + +function stateApi(sync: ReturnType): TuiApi["state"] { + return { + session: { + diff(sessionID) { + return sync.data.session_diff[sessionID] ?? [] + }, + todo(sessionID) { + return sync.data.todo[sessionID] ?? [] + }, + }, + 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, + })) + }, + } +} + +export function createTuiApi(input: Input): TuiApi { + return { + 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 ( + + {props.children} + + ) + }, + DialogAlert(props) { + return + }, + DialogConfirm(props) { + return + }, + DialogPrompt(props) { + return + }, + DialogSelect(props) { + return ( + + ) + }, + 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), + 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 + }, + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/index.ts b/packages/opencode/src/cli/cmd/tui/plugin/index.ts new file mode 100644 index 0000000000..c970a318f2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/index.ts @@ -0,0 +1,3 @@ +export { TuiPluginRuntime } from "./runtime" +export { createTuiApi } from "./api" +export type { RouteMap } from "./api" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts new file mode 100644 index 0000000000..33e091c8e0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -0,0 +1,7 @@ +export type InternalTuiPlugin = { + name: string + module: Record + root?: string +} + +export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [] diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts new file mode 100644 index 0000000000..4f2cd98f66 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -0,0 +1,581 @@ +import "@opentui/solid/runtime-plugin-support" +import { + type TuiDispose, + type TuiPlugin, + type TuiPluginApi, + type TuiPluginMeta, + type TuiSlotPlugin, + type TuiTheme, +} from "@opencode-ai/plugin/tui" +import type { CliRenderer } from "@opentui/core" +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 { isDeprecatedPlugin, resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared" +import { PluginMeta } from "@/plugin/meta" +import { addTheme, hasTheme } from "../context/theme" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Flag } from "@/flag/flag" +import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" +import { setupSlots, Slot as View } from "./slots" +import type { HostPluginApi, HostSlotPlugin, HostSlots } from "./slots" + +type Loaded = { + item?: Config.PluginSpec + spec: string + target: string + retry: boolean + mod: Record + install: TuiTheme["install"] +} +type Deps = { + wait?: Promise +} + +type Api = HostPluginApi & { + slots: HostSlots +} + +type Scope = { + lifecycle: TuiPluginApi["lifecycle"] + wrap: (fn: (() => void) | undefined) => () => void + dispose: () => Promise +} + +const log = Log.create({ service: "tui.plugin" }) +const DISPOSE_TIMEOUT_MS = 5000 + +function fail(message: string, data: Record) { + 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 { + 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 isTuiPlugin(value: unknown): value is TuiPlugin { + return typeof value === "function" +} + +function getTuiPlugin(value: unknown) { + if (!isRecord(value) || !("tui" in value)) return + if (!isTuiPlugin(value.tui)) return + return value.tui +} + +function isTheme(value: unknown) { + if (!isRecord(value)) return false + if (!isRecord(value.theme)) return false + return true +} + +function localDir(file: string) { + const dir = path.dirname(file) + if (path.basename(dir) === ".opencode") return path.join(dir, "themes") + return path.join(dir, ".opencode", "themes") +} + +function scopeDir(pluginMeta: TuiConfig.PluginMeta) { + if (pluginMeta.scope === "local") return localDir(pluginMeta.source) + return path.join(Global.Path.config, "themes") +} + +function pluginRoot(spec: string, target: string) { + if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec)) + if (target.startsWith("file://")) return path.dirname(fileURLToPath(target)) + return target +} + +function rootDir(root?: string) { + if (!root) return process.cwd() + 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 resolveThemePath(root: string, file: string) { + if (file.startsWith("file://")) return fileURLToPath(file) + if (path.isAbsolute(file)) return file + return path.resolve(root, file) +} + +function themeName(file: string) { + return path.basename(file, path.extname(file)) +} + +function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) { + const key = Config.getPluginName(item) + return config.plugin_meta?.[key] +} + +function makeInstallFn(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] { + return async (file) => { + const src = resolveThemePath(root, file) + const theme = themeName(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 dest = path.join(scopeDir(meta), `${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) + } +} + +function waitDeps(state: Deps) { + state.wait ??= TuiConfig.waitForDependencies().catch((error) => { + log.warn("failed waiting for tui plugin dependencies", { error }) + }) + return state.wait +} + +async function prepPlugin(config: TuiConfig.Info, item: Config.PluginSpec, retry = false): Promise { + const spec = Config.pluginSpecifier(item) + if (isDeprecatedPlugin(spec)) return + log.info("loading tui plugin", { path: spec, retry }) + const target = await resolvePluginTarget(spec).catch((error) => { + fail("failed to resolve tui plugin", { path: spec, retry, error }) + return + }) + if (!target) return + + const root = pluginRoot(spec, target) + const pluginMeta = getPluginMeta(config, item) + if (!pluginMeta) { + log.warn("missing tui plugin metadata", { + path: spec, + retry, + name: Config.getPluginName(item), + }) + return + } + + const install = makeInstallFn(pluginMeta, root, spec) + const mod = await import(target).catch((error) => { + fail("failed to load tui plugin", { path: spec, target, retry, error }) + return + }) + if (!mod) return + + return { + item, + spec, + target, + retry, + mod, + install, + } +} + +function createMeta( + spec: string, + target: string, + meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined, + name?: string, +): TuiPluginMeta { + if (meta) { + return { + state: meta.state, + ...meta.entry, + } + } + + const source = spec.startsWith("internal:") ? "internal" : spec.startsWith("file://") ? "file" : "npm" + const now = Date.now() + return { + state: source === "internal" ? "same" : "first", + name: name ?? spec, + source, + spec, + target, + first_time: now, + last_time: now, + time_changed: now, + load_count: 1, + fingerprint: target, + } +} + +function prepInternalPlugin(item: InternalTuiPlugin): Loaded { + const spec = `internal:${item.name}` + const target = item.root ?? spec + const root = rootDir(item.root) + + return { + spec, + target, + retry: false, + mod: item.module, + install: makeInstallFn( + { + scope: "global", + source: target, + }, + root, + spec, + ), + } +} + +function scope(load: Loaded, name: 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 wrap = (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, + name, + 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, + name, + timeout: DISPOSE_TIMEOUT_MS, + }) + break + } + + if (out.type === "error") { + fail("failed to clean up tui plugin", { + path: load.spec, + name, + error: out.error, + }) + } + } + } + + return { + lifecycle, + wrap, + dispose, + } +} + +function sid(meta: TuiPluginMeta, name: string) { + if (name === "default") return meta.name + return `${meta.name}:${name}` +} + +function plug(plugin: TuiSlotPlugin, id: string): HostSlotPlugin { + return { + ...plugin, + id, + } +} + +function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPluginApi { + const command: TuiPluginApi["command"] = { + register(cb) { + return state.wrap(api.command.register(cb)) + }, + trigger(value) { + api.command.trigger(value) + }, + } + + const route: TuiPluginApi["route"] = { + register(list) { + return state.wrap(api.route.register(list)) + }, + navigate(name, params) { + api.route.navigate(name, params) + }, + get current() { + return api.route.current + }, + } + + const theme: TuiPluginApi["theme"] = Object.create(api.theme, { + install: { + value: load.install, + configurable: true, + enumerable: true, + }, + }) + + const event: TuiPluginApi["event"] = { + on(type, handler) { + return state.wrap(api.event.on(type, handler)) + }, + } + + let count = 0 + + const slots: TuiPluginApi["slots"] = { + register(plugin) { + const id = count ? `${base}:${count}` : base + count += 1 + state.wrap(api.slots.register(plug(plugin, id))) + return id + }, + } + + return { + ...api, + command, + route, + theme, + event, + slots, + lifecycle: state.lifecycle, + } +} + +async function applyPlugin(api: Api, load: Loaded, meta: TuiPluginMeta, all: Scope[]) { + const opts = load.item ? Config.pluginOptions(load.item) : undefined + + for (const [name, value] of uniqueModuleEntries(load.mod)) { + if (!value || typeof value !== "object") { + log.warn("ignoring non-object tui plugin export", { + path: load.spec, + name, + type: value === null ? "null" : typeof value, + }) + continue + } + + const tuiPlugin = getTuiPlugin(value) + if (!tuiPlugin) continue + + const state = scope(load, name) + const plugin = pluginApi(api, load, state, sid(meta, name)) + const ready = await Promise.resolve() + .then(async () => { + await tuiPlugin(plugin, opts, meta) + return true + }) + .catch((error) => { + fail("failed to initialize tui plugin export", { + path: load.spec, + name, + error, + }) + return false + }) + + if (!ready) { + await state.dispose() + continue + } + + all.push(state) + } +} + +export namespace TuiPluginRuntime { + let dir = "" + let loaded: Promise | undefined + let list: Scope[] = [] + 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, + slots: setupSlots(api), + }) + return loaded + } + + export async function dispose() { + const task = loaded + loaded = undefined + dir = "" + if (task) await task + const queue = [...list].reverse() + list = [] + for (const state of queue) { + await state.dispose() + } + } + + async function load(api: Api) { + const cwd = process.cwd() + const next: Scope[] = [] + + 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 }) + } + const deps: Deps = {} + + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { name: item.name }) + const entry = prepInternalPlugin(item) + await applyPlugin(api, entry, createMeta(entry.spec, entry.target, undefined, item.name), next) + } + + const loaded = await Promise.all(plugins.map((item) => prepPlugin(config, item))) + const ready: Loaded[] = [] + + for (let i = 0; i < plugins.length; i++) { + let entry = loaded[i] + if (!entry) { + const item = plugins[i] + if (!item) continue + const spec = Config.pluginSpecifier(item) + if (!spec.startsWith("file://")) continue + await waitDeps(deps) + entry = await prepPlugin(config, item, true) + } + if (!entry) continue + ready.push(entry) + } + + const meta = await PluginMeta.touchMany(ready.map((item) => ({ spec: item.spec, target: item.target }))).catch( + (error) => { + log.warn("failed to track tui plugins", { error }) + return undefined + }, + ) + + 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, + }) + } + + // 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 applyPlugin(api, entry, createMeta(entry.spec, entry.target, hit), next) + } + + list = next + }, + }).catch((error) => { + fail("failed to load tui plugins", { directory: cwd, error }) + }) + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx new file mode 100644 index 0000000000..7b9b9f5db1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/slots.tsx @@ -0,0 +1,62 @@ +import type { CliRenderer } from "@opentui/core" +import { type SlotMode, type TuiHostPluginApi, 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 = { + name: K + mode?: SlotMode + children?: JSX.Element +} & TuiSlotMap[K] + +type Slot = (props: SlotProps) => JSX.Element | null +export type HostSlotPlugin = SolidPlugin + +export type HostPluginApi = TuiHostPluginApi +export type HostSlots = { + register: (plugin: HostSlotPlugin) => () => void +} + +function empty(_props: SlotProps) { + 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( + 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(reg) + view = (props) => slot(props) + return { + register(plugin) { + if (!isHostSlotPlugin(plugin)) return () => {} + return reg.register(plugin) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index e76e165b26..67a2acc92e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,6 +15,7 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +import { TuiPluginRuntime } from "../plugin" // TODO: what is the best way to do this? let once = false @@ -57,8 +58,8 @@ export function Home() { ]) const Hint = ( - 0}> - + + 0}> @@ -71,8 +72,8 @@ export function Home() { - - + + ) let prompt: PromptRef @@ -111,7 +112,9 @@ export function Home() { - + + + @@ -124,11 +127,25 @@ export function Home() { workspaceID={route.workspaceID} /> - - - - - + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 0d9ddc746c..080065fd78 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" -import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe08..0e2745c890 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -2,15 +2,17 @@ import { useSync } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" -import { Locale } from "@/util/locale" -import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import { Global } from "@/global" import { Installation } from "@/installation" -import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { TuiPluginRuntime } from "../../plugin" + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -27,36 +29,45 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { lsp: true, }) - // Sort MCP servers alphabetically for consistent display order - const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) + const mcp = createMemo(() => + 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, + })), + ) - // Count connected and error MCP servers for collapsed header display - const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length) + const lsp = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))) + + const connectedMcpCount = createMemo(() => mcp().filter((item) => item.status === "connected").length) const errorMcpCount = createMemo( () => - mcpEntries().filter( - ([_, item]) => + mcp().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 cost = createMemo(() => messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)) + const mcpStatusColor: Record = { + connected: theme.success, + failed: theme.error, + disabled: theme.textMuted, + needs_auth: theme.warning, + needs_client_registration: theme.error, + } const context = createMemo(() => { - const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage - if (!last) return - const total = + const last = messages().findLast((x): x is AssistantMessage => x.role === "assistant" && x.tokens.output > 0) + if (!last) return { tokens: 0, percentage: null } + const tokens = 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, + tokens, + percentage: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, } }) @@ -67,6 +78,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const showGettingStarted = createMemo(() => !hasProviders() && !gettingStartedDismissed()) + const dir = createMemo(() => { + const value = directory() + const parts = value.split("/") + return { + value, + parent: parts.slice(0, -1).join("/"), + name: parts.at(-1) ?? "", + } + }) return ( @@ -90,163 +111,192 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { }} > - - - {session().title} - - - {session().share!.url} + + + + + {session().title} + + + {session().share!.url} + + + + + + + Context + + {context().tokens.toLocaleString()} tokens + {context().percentage ?? 0}% used + {money.format(cost())} spent + + + + 0}> + + mcp().length > 2 && setExpanded("mcp", !expanded.mcp)} + > + 2}> + {expanded.mcp ? "▼" : "▶"} + + + MCP + + + {" "} + ({connectedMcpCount()} active + {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) + + + + + + + {(item) => ( + + + • + + + {item.name}{" "} + + + Connected + + {item.error} + + Disabled + Needs auth + Needs client ID + + + + + )} + + + - - - - Context - - {context()?.tokens ?? 0} tokens - {context()?.percentage ?? 0}% used - {cost()} spent - - 0}> + + mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} + onMouseDown={() => lsp().length > 2 && setExpanded("lsp", !expanded.lsp)} > - 2}> - {expanded.mcp ? "▼" : "▶"} + 2}> + {expanded.lsp ? "▼" : "▶"} - MCP - - - {" "} - ({connectedMcpCount()} active - {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) - - + LSP - - - {([key, item]) => ( + + + + {sync.data.config.lsp === false + ? "LSPs have been disabled in settings" + : "LSPs will activate as files are read"} + + + + {(item) => ( - )[item.status], + fg: { + connected: theme.success, + error: theme.error, + }[item.status], }} > • - - {key}{" "} - - - Connected - {(val) => {val().error}} - Disabled - Needs auth - - Needs client ID - - - + + {item.id} {item.root} )} - - - sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} - > - 2}> - {expanded.lsp ? "▼" : "▶"} - - - LSP - - - - - - {sync.data.config.lsp === false - ? "LSPs have been disabled in settings" - : "LSPs will activate as files are read"} - - - - {(item) => ( - - - • - - - {item.id} {item.root} - - - )} - + + + 0 && todo().some((item) => item.status !== "completed")}> + + todo().length > 2 && setExpanded("todo", !expanded.todo)} + > + 2}> + {expanded.todo ? "▼" : "▶"} + + + Todo + + + + {(item) => } + + - - 0 && todo().some((t) => t.status !== "completed")}> - - todo().length > 2 && setExpanded("todo", !expanded.todo)} - > - 2}> - {expanded.todo ? "▼" : "▶"} - - - Todo - - - - {(todo) => } - - - - 0}> - - diff().length > 2 && setExpanded("diff", !expanded.diff)} - > - 2}> - {expanded.diff ? "▼" : "▶"} - - - Modified Files - - - - - {(item) => { - return ( + + + 0}> + + diff().length > 2 && setExpanded("diff", !expanded.diff)} + > + 2}> + {expanded.diff ? "▼" : "▶"} + + + Modified Files + + + + + {(item) => ( {item.file} @@ -260,60 +310,96 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - ) - }} - - - - + )} + + + + + - - - - ⬖ - - - - - Getting started - - kv.set("dismissed_getting_started", true)}> - ✕ - - - OpenCode includes free models so you can start immediately. - - Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc + + + + + ⬖ - - Connect provider - /connect + + + + Getting started + + kv.set("dismissed_getting_started", true)}> + ✕ + + + OpenCode includes free models so you can start immediately. + + Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc + + + Connect provider + /connect + - - - - {directory().split("/").slice(0, -1).join("/")}/ - {directory().split("/").at(-1)} - - - Open - - Code - {" "} - {Installation.VERSION} - + + + + + {dir().parent}/ + {dir().name} + + + + + Open + + Code + {" "} + {Installation.VERSION} + + + diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index d984dc6f3f..3bb56937a6 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,6 +6,7 @@ import path from "path" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" import { Log } from "@/util/log" +import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { Filesystem } from "@/util/filesystem" @@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({ const reload = () => { client.call("reload", undefined).catch((err) => { Log.Default.warn("worker reload failed", { - error: err instanceof Error ? err.message : String(err), + error: errorMessage(err), }) }) } @@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({ process.off("SIGUSR2", reload) await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { Log.Default.warn("worker shutdown failed", { - error: error instanceof Error ? error.message : String(error), + error: errorMessage(error), }) }) worker.terminate() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 43f1a1ff58..e43d6afe56 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -35,6 +35,7 @@ export function Dialog( height={dimensions().height} alignItems="center" position="absolute" + zIndex={3000} paddingTop={dimensions().height / 4} left={0} top={0} @@ -72,6 +73,9 @@ function init() { if (evt.defaultPrevented) return if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + if (renderer.getSelection()) { + renderer.clearSelection() + } const current = store.stack.at(-1)! current.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -151,6 +155,7 @@ export function DialogProvider(props: ParentProps) { {props.children} { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index d7120aa5e9..52bad892eb 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,4 +1,5 @@ import { ConfigMarkdown } from "@/config/markdown" +import { errorFormat } from "@/util/error" import { Config } from "../config/config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" @@ -41,17 +42,5 @@ export function FormatError(input: unknown) { } export function FormatUnknownError(input: unknown): string { - if (input instanceof Error) { - return input.stack ?? `${input.name}: ${input.message}` - } - - if (typeof input === "object" && input !== null) { - try { - return JSON.stringify(input, null, 2) - } catch { - return "Unexpected error (unserializable)" - } - } - - return String(input) + return errorFormat(input) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 67f298b427..94c8193091 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -30,20 +30,26 @@ import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Glob } from "../util/glob" import { PackageRegistry } from "@/bun/registry" -import { proxied } from "@/util/proxied" +import { online, proxied } from "@/util/network" import { iife } from "@/util/iife" import { Account } from "@/account" +import { isRecord } from "@/util/record" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" -import { Lock } from "@/util/lock" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Duration, Effect, Layer, ServiceMap } from "effect" +import { Flock } from "@/util/flock" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) + const PluginOptions = z.record(z.string(), z.unknown()) + export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])]) + + export type PluginOptions = z.infer + export type PluginSpec = z.infer const log = Log.create({ service: "config" }) @@ -78,34 +84,56 @@ export namespace Config { return merged } - export async function installDependencies(dir: string) { + export type InstallInput = { + signal?: AbortSignal + waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise + } + + export async function installDependencies(dir: string, input?: InstallInput) { + if (!(await needsInstall(dir))) return + + await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, { + signal: input?.signal, + onWait: (tick) => + input?.waitTick?.({ + dir, + attempt: tick.attempt, + delay: tick.delay, + waited: tick.waited, + }), + }) + + input?.signal?.throwIfAborted() + if (!(await needsInstall(dir))) return + const pkg = path.join(dir, "package.json") - const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION + const target = Installation.isLocal() ? "*" : Installation.VERSION const json = await Filesystem.readJson<{ dependencies?: Record }>(pkg).catch(() => ({ dependencies: {}, })) json.dependencies = { ...json.dependencies, - "@opencode-ai/plugin": targetVersion, + "@opencode-ai/plugin": target, } await Filesystem.writeJson(pkg, json) const gitignore = path.join(dir, ".gitignore") - const hasGitIgnore = await Filesystem.exists(gitignore) - if (!hasGitIgnore) + const ignore = await Filesystem.exists(gitignore) + if (!ignore) { await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) + } - // Install any additional dependencies defined in the package.json - // This allows local plugins and custom tools to use external packages - using _ = await Lock.write("bun-install") await BunProc.run( [ "install", // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) ...(proxied() || process.env.CI ? ["--no-cache"] : []), ], - { cwd: dir }, + { + cwd: dir, + abort: input?.signal, + }, ).catch((err) => { if (err instanceof Process.RunFailedError) { const detail = { @@ -149,8 +177,8 @@ export namespace Config { return false } - const nodeModules = path.join(dir, "node_modules") - if (!existsSync(nodeModules)) return true + const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin") + if (!existsSync(mod)) return true const pkg = path.join(dir, "package.json") const pkgExists = await Filesystem.exists(pkg) @@ -163,8 +191,9 @@ export namespace Config { const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION if (targetVersion === "latest") { - const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) - if (!isOutdated) return false + if (!online()) return false + const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) + if (!stale) return false log.info("Cached version is outdated, proceeding with install", { pkg: "@opencode-ai/plugin", cachedVersion: depVersion, @@ -303,7 +332,7 @@ export namespace Config { } async function loadPlugin(dir: string) { - const plugins: string[] = [] + const plugins: PluginSpec[] = [] for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { cwd: dir, @@ -316,6 +345,32 @@ export namespace Config { return plugins } + export function pluginSpecifier(plugin: PluginSpec): string { + return Array.isArray(plugin) ? plugin[0] : plugin + } + + export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined + } + + export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec { + const spec = pluginSpecifier(plugin) + try { + const resolved = import.meta.resolve!(spec, configFilepath) + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } catch { + try { + const require = createRequire(configFilepath) + const resolved = pathToFileURL(require.resolve(spec)).href + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } catch { + return plugin + } + } + } + /** * Extracts a canonical plugin name from a plugin specifier. * - For file:// URLs: extracts filename without extension @@ -326,15 +381,16 @@ export namespace Config { * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ - export function getPluginName(plugin: string): string { - if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + export function getPluginName(plugin: PluginSpec): string { + const spec = pluginSpecifier(plugin) + if (spec.startsWith("file://")) { + return path.parse(new URL(spec).pathname).name } - const lastAt = plugin.lastIndexOf("@") + const lastAt = spec.lastIndexOf("@") if (lastAt > 0) { - return plugin.substring(0, lastAt) + return spec.substring(0, lastAt) } - return plugin + return spec } /** @@ -348,14 +404,14 @@ export namespace Config { * Since plugins are added in low-to-high priority order, * we reverse, deduplicate (keeping first occurrence), then restore order. */ - export function deduplicatePlugins(plugins: string[]): string[] { + export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] { // seenNames: canonical plugin names for duplicate detection // e.g., "oh-my-opencode", "@scope/pkg" const seenNames = new Set() // uniqueSpecifiers: full plugin specifiers to return - // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" - const uniqueSpecifiers: string[] = [] + // e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }] + const uniqueSpecifiers: PluginSpec[] = [] for (const specifier of plugins.toReversed()) { const name = getPluginName(specifier) @@ -858,13 +914,13 @@ export namespace Config { ignore: z.array(z.string()).optional(), }) .optional(), - plugin: z.string().array().optional(), snapshot: z .boolean() .optional() .describe( "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", ), + plugin: PluginSpec.array().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() @@ -1070,10 +1126,6 @@ export namespace Config { return candidates[0] } - function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) - } - function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { const edits = modify(input, path, patch, { @@ -1184,18 +1236,7 @@ export namespace Config { const data = parsed.data if (data.plugin && isFile) { for (let i = 0; i < data.plugin.length; i++) { - const plugin = data.plugin[i] - try { - data.plugin[i] = import.meta.resolve!(plugin, options.path) - } catch (e) { - try { - const require = createRequire(options.path) - const resolvedPath = require.resolve(plugin) - data.plugin[i] = pathToFileURL(resolvedPath).href - } catch { - // Ignore, plugin might be a generic string identifier like "mcp-server" - } - } + data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path) } } return data diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index f9068e3f01..1637b27c79 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -29,6 +29,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), + plugin: Config.PluginSpec.array().optional(), }) .extend(TuiOptions.shape) .strict() diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index f0964f63b3..502bfd8b5e 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" +import { isRecord } from "@/util/record" import { Global } from "@/global" export namespace TuiConfig { @@ -15,16 +16,91 @@ export namespace TuiConfig { export const Info = TuiInfo - export type Info = z.output + 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 & { + plugin_meta?: Record + } + + function pluginScope(file: string): PluginMeta["scope"] { + if (Instance.containsPath(file)) return "local" + return "global" + } + + function dedupePlugins(list: PluginEntry[]) { + const seen = new Set() + const result: PluginEntry[] = [] + for (const item of list.toReversed()) { + const name = Config.getPluginName(item.item) + if (seen.has(name)) continue + seen.add(name) + result.push(item) + } + return result.toReversed() + } function mergeInfo(target: Info, source: Info): Info { - return mergeDeep(target, source) + const merged = mergeDeep(target, source) + if (target.plugin && source.plugin) { + merged.plugin = [...target.plugin, ...source.plugin] + } + return merged } function customPath() { return Flag.OPENCODE_TUI_CONFIG } + function normalize(raw: Record) { + 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 { + return Config.installDependencies(dir) + } + + async function mergeFile(acc: Acc, file: string) { + const data = await loadFile(file) + acc.result = mergeInfo(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file) + for (const item of data.plugin) { + acc.entries.push({ + item, + meta: { + scope, + source: file, + }, + }) + } + } + const state = Instance.state(async () => { let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] @@ -38,38 +114,55 @@ export namespace TuiConfig { ? [] : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) - let result: Info = {} + const acc: Acc = { + result: {}, + entries: [], + } for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await mergeFile(acc, file) } if (custom) { - result = mergeInfo(result, await loadFile(custom)) + await mergeFile(acc, custom) log.debug("loaded custom tui config", { path: custom }) } for (const file of projectFiles) { - result = mergeInfo(result, await loadFile(file)) + await mergeFile(acc, file) } for (const dir of unique(directories)) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await mergeFile(acc, file) } } if (existsSync(managed)) { for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await mergeFile(acc, file) } } - result.keybinds = Config.Keybinds.parse(result.keybinds ?? {}) + const merged = dedupePlugins(acc.entries) + acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) + acc.result.plugin = merged.map((item) => item.item) + acc.result.plugin_meta = merged.length + ? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta])) + : undefined + + const deps: Promise[] = [] + if (acc.result.plugin?.length) { + for (const dir of unique(directories)) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + deps.push(installDeps(dir)) + } + } return { - config: result, + config: acc.result, + deps, } }) @@ -77,6 +170,11 @@ export namespace TuiConfig { return state().then((x) => x.config) } + export async function waitForDependencies() { + const deps = await state().then((x) => x.deps) + await Promise.all(deps) + } + async function loadFile(filepath: string): Promise { const text = await ConfigPaths.readFile(filepath) if (!text) return {} @@ -87,25 +185,12 @@ export namespace TuiConfig { } async function load(text: string, configFilepath: string): Promise { - const data = await ConfigPaths.parseText(text, configFilepath, "empty") - if (!data || typeof data !== "object" || Array.isArray(data)) return {} + const raw = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!isRecord(raw)) return {} // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. - const normalized = (() => { - const copy = { ...(data as Record) } - 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 - delete copy.tui - return { - ...tui, - ...copy, - } - })() + const normalized = normalize(raw) const parsed = Info.safeParse(normalized) if (!parsed.success) { @@ -113,6 +198,13 @@ export namespace TuiConfig { return {} } - return parsed.data + const data = parsed.data + if (data.plugin) { + for (let i = 0; i < data.plugin.length; i++) { + data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b35f84c8e2..27190f2eb2 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -14,13 +14,16 @@ export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] + export declare const OPENCODE_PURE: boolean export declare const OPENCODE_TUI_CONFIG: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined + export declare const OPENCODE_PLUGIN_META_FILE: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") + export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD") export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"] export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS") export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") @@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", { configurable: false, }) +// Dynamic getter for OPENCODE_PURE +// This must be evaluated at access time, not module load time, +// because the CLI can set this flag at runtime +Object.defineProperty(Flag, "OPENCODE_PURE", { + get() { + return truthy("OPENCODE_PURE") + }, + enumerable: true, + configurable: false, +}) + +// Dynamic getter for OPENCODE_PLUGIN_META_FILE +// This must be evaluated at access time, not module load time, +// because tests and external tooling may set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", { + get() { + return process.env["OPENCODE_PLUGIN_META_FILE"] + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_CLIENT // This must be evaluated at access time, not module load time, // because some commands override the client at runtime diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e27471068f..febcec215d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -33,16 +33,18 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" +import { errorMessage } from "./util/error" +import { PlugCommand } from "./cli/cmd/plug" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { - e: e instanceof Error ? e.message : e, + e: errorMessage(e), }) }) process.on("uncaughtException", (e) => { Log.Default.error("exception", { - e: e instanceof Error ? e.message : e, + e: errorMessage(e), }) }) @@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv)) type: "string", choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) .middleware(async (opts) => { + if (opts.pure) { + process.env.OPENCODE_PURE = "1" + } + await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isLocal(), @@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(PlugCommand) .command(DbCommand) .fail((msg, err) => { if ( @@ -194,7 +205,7 @@ try { if (formatted) UI.error(formatted) if (formatted === undefined) { UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) - process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL) + process.stderr.write(errorMessage(e) + EOL) } process.exitCode = 1 } finally { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9804169a46..5788d44ded 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,7 +3,6 @@ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" -import { BunProc } from "../bun" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" @@ -14,6 +13,8 @@ import { PoeAuthPlugin } from "opencode-poe-auth" import { Effect, Layer, ServiceMap, Stream } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { errorMessage } from "@/util/error" +import { isDeprecatedPlugin, parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -22,6 +23,12 @@ export namespace Plugin { hooks: Hooks[] } + type Loaded = { + item: Config.PluginSpec + spec: string + mod: Record + } + // Hook names that follow the (input, output) => Promise trigger pattern type TriggerName = { [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never @@ -46,8 +53,70 @@ export namespace Plugin { // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] - // Old npm package names for plugins that are now built-in — skip if users still have them in config - const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] + function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" + } + + function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server + } + + 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 { + const spec = Config.pluginSpecifier(item) + if (isDeprecatedPlugin(spec)) return + log.info("loading plugin", { path: spec }) + const target = await resolvePlugin(spec) + if (!target) return + + const mod = await import(target).catch((err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: spec, 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[]) { + // Prevent duplicate initialization when plugins export the same function + // as both a named export and default export (e.g., `export const X` and `export default X`). + // uniqueModuleEntries keeps only the first export for each shared value reference. + for (const [, entry] of uniqueModuleEntries(load.mod)) { + const server = getServerPlugin(entry) + if (!server) throw new TypeError("Plugin export is not a function") + hooks.push(await server(input, Config.pluginOptions(load.item))) + } + } export const layer = Layer.effect( Service, @@ -91,51 +160,27 @@ export namespace Plugin { if (init) hooks.push(init) } - let plugins = cfg.plugin ?? [] + const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? []) + if (Flag.OPENCODE_PURE && cfg.plugin?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin.length }) + } if (plugins.length) await Config.waitForDependencies() - for (let plugin of plugins) { - if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue - log.info("loading plugin", { path: plugin }) - if (!plugin.startsWith("file://")) { - const idx = plugin.lastIndexOf("@") - const pkg = idx > 0 ? plugin.substring(0, idx) : plugin - const version = idx > 0 ? plugin.substring(idx + 1) : "latest" - plugin = await BunProc.install(pkg, version).catch((err) => { - const cause = err instanceof Error ? err.cause : err - const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { pkg, version, error: detail }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to install plugin ${pkg}@${version}: ${detail}`, - }).toObject(), - }) - return "" - }) - if (!plugin) continue - } + const loaded = await Promise.all(plugins.map((item) => prepPlugin(item))) + for (const load of loaded) { + if (!load) continue - // Prevent duplicate initialization when plugins export the same function - // as both a named export and default export (e.g., `export const X` and `export default X`). - // Object.entries(mod) would return both entries pointing to the same function reference. - await import(plugin) - .then(async (mod) => { - const seen = new Set() - for (const [_name, fn] of Object.entries(mod)) { - if (seen.has(fn)) continue - seen.add(fn) - hooks.push(await fn(input)) - } - }) - .catch((err) => { - const message = err instanceof Error ? err.message : String(err) - log.error("failed to load plugin", { path: plugin, error: message }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${plugin}: ${message}`, - }).toObject(), - }) + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + await applyPlugin(load, input, hooks).catch((err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${load.spec}: ${message}`, + }).toObject(), }) + }) } // Notify plugins of current config diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts new file mode 100644 index 0000000000..a4fc95bcf7 --- /dev/null +++ b/packages/opencode/src/plugin/meta.ts @@ -0,0 +1,181 @@ +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 } from "./shared" + +export namespace PluginMeta { + type Source = "file" | "npm" + + export type Entry = { + name: 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 + } + + type Store = Record + type Core = Omit + type Row = Touch & { + id: string + 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 sourceKind(spec: string): Source { + if (spec.startsWith("file://")) return "file" + return "npm" + } + + function entryKey(spec: string) { + if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}` + return `npm:${parsePluginSpecifier(spec).pkg}` + } + + function entryName(spec: string) { + if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name + return parsePluginSpecifier(spec).pkg + } + + 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(spec: string, target: string): Promise { + const source = sourceKind(spec) + if (source === "file") { + const file = fileTarget(spec, target) + return { + name: entryName(spec), + source, + spec, + target, + modified: file ? modifiedAt(file) : undefined, + } + } + + return { + name: entryName(spec), + 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 { + return Filesystem.readJson(file).catch(() => ({}) as Store) + } + + async function row(item: Touch): Promise { + return { + ...item, + id: entryKey(item.spec), + core: await entryCore(item.spec, item.target), + } + } + + 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> { + 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): Promise<{ state: State; entry: Entry }> { + return touchMany([{ spec, target }]).then((item) => { + const hit = item[0] + if (hit) return hit + throw new Error("Failed to touch plugin metadata.") + }) + } + + export async function list(): Promise { + const file = storePath() + return Flock.withLock(lock(file), async () => read(file)) + } +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts new file mode 100644 index 0000000000..5dda94c739 --- /dev/null +++ b/packages/opencode/src/plugin/shared.ts @@ -0,0 +1,33 @@ +import { BunProc } from "@/bun" + +// 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 async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { + if (spec.startsWith("file://")) return spec + return BunProc.install(parsed.pkg, parsed.version) +} + +export function uniqueModuleEntries(mod: Record) { + const seen = new Set() + const entries: [string, unknown][] = [] + + for (const [name, entry] of Object.entries(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + entries.push([name, entry]) + } + + return entries +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index fc4cc5e6b9..0b39a06a63 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,4 +1,4 @@ -import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" @@ -106,7 +106,7 @@ export namespace ProviderAuth { interface State { hooks: Record - pending: Map + pending: Map } export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} @@ -127,7 +127,7 @@ export namespace ProviderAuth { : Result.failVoid, ), ), - pending: new Map(), + pending: new Map(), } }), ), diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 86e4315652..37cbebc9ce 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" +import { errorMessage } from "@/util/error" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -990,7 +991,7 @@ export namespace MessageV2 { { cause: e }, ).toObject() case e instanceof Error: - return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject() + return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() default: try { const parsed = ProviderError.parseStreamError(e) diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 00c22bfe6b..c79a530f71 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" import { ProviderID, ModelID } from "../provider/schema" +import { errorMessage } from "../util/error" import DESCRIPTION from "./batch.txt" const DISALLOWED = new Set(["batch"]) @@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => { state: { status: "error", input: call.parameters, - error: error instanceof Error ? error.message : String(error), + error: errorMessage(error), time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts new file mode 100644 index 0000000000..ea1c79178c --- /dev/null +++ b/packages/opencode/src/util/error.ts @@ -0,0 +1,77 @@ +import { isRecord } from "./record" + +export function errorFormat(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}` + } + + if (typeof error === "object" && error !== null) { + try { + return JSON.stringify(error, null, 2) + } catch { + return "Unexpected error (unserializable)" + } + } + + return String(error) +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error) { + if (error.message) return error.message + if (error.name) return error.name + } + + if (isRecord(error) && typeof error.message === "string" && error.message) { + return error.message + } + + const text = String(error) + if (text && text !== "[object Object]") return text + + const formatted = errorFormat(error) + if (formatted && formatted !== "{}") return formatted + return "unknown error" +} + +export function errorData(error: unknown) { + if (error instanceof Error) { + return { + type: error.name, + message: errorMessage(error), + stack: error.stack, + cause: error.cause === undefined ? undefined : errorFormat(error.cause), + formatted: errorFormatted(error), + } + } + + if (!isRecord(error)) { + return { + type: typeof error, + message: errorMessage(error), + formatted: errorFormatted(error), + } + } + + const data = Object.getOwnPropertyNames(error).reduce>((acc, key) => { + const value = error[key] + if (value === undefined) return acc + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + acc[key] = value + return acc + } + acc[key] = value instanceof Error ? value.message : String(value) + return acc + }, {}) + + if (typeof data.message !== "string") data.message = errorMessage(error) + if (typeof data.type !== "string") data.type = error.constructor?.name + data.formatted = errorFormatted(error) + return data +} + +function errorFormatted(error: unknown) { + const formatted = errorFormat(error) + if (formatted !== "{}") return formatted + return String(error) +} diff --git a/packages/opencode/src/util/flock.ts b/packages/opencode/src/util/flock.ts new file mode 100644 index 0000000000..74c7905ebb --- /dev/null +++ b/packages/opencode/src/util/flock.ts @@ -0,0 +1,333 @@ +import path from "path" +import os from "os" +import { randomBytes, randomUUID } from "crypto" +import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises" +import { Global } from "@/global" +import { Hash } from "@/util/hash" + +export namespace Flock { + const root = path.join(Global.Path.state, "locks") + // Defaults for callers that do not provide timing options. + const defaultOpts = { + staleMs: 60_000, + timeoutMs: 5 * 60_000, + baseDelayMs: 100, + maxDelayMs: 2_000, + } + + export interface WaitEvent { + key: string + attempt: number + delay: number + waited: number + } + + export type Wait = (input: WaitEvent) => void | Promise + + 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 + } + + export interface Lease { + release: () => Promise + [Symbol.asyncDispose]: () => Promise + } + + 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((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 { + 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 { + 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(key: string, fn: () => Promise, input: Options = {}) { + await using _ = await acquire(key, input) + input.signal?.throwIfAborted() + return await fn() + } +} diff --git a/packages/opencode/src/util/proxied.ts b/packages/opencode/src/util/network.ts similarity index 50% rename from packages/opencode/src/util/proxied.ts rename to packages/opencode/src/util/network.ts index 440a9ccced..69e5d17588 100644 --- a/packages/opencode/src/util/proxied.ts +++ b/packages/opencode/src/util/network.ts @@ -1,3 +1,9 @@ +export function online() { + const nav = globalThis.navigator + if (!nav || typeof nav.onLine !== "boolean") return true + return nav.onLine +} + export function proxied() { return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) } diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 22dce37cb0..1230ed3236 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -1,6 +1,7 @@ import { type ChildProcess } from "child_process" import launch from "cross-spawn" import { buffer } from "node:stream/consumers" +import { errorMessage } from "./error" export namespace Process { export type Stdio = "inherit" | "pipe" | "ignore" @@ -136,7 +137,7 @@ export namespace Process { return { code: 1, stdout: Buffer.alloc(0), - stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + stderr: Buffer.from(errorMessage(err)), } }) if (out.code === 0 || opts.nothrow) return out diff --git a/packages/opencode/src/util/record.ts b/packages/opencode/src/util/record.ts new file mode 100644 index 0000000000..495927463b --- /dev/null +++ b/packages/opencode/src/util/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 3aeee983f4..d4403927c6 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import { Log } from "../util/log" import { Slug } from "@opencode-ai/util/slug" +import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect" @@ -258,7 +259,7 @@ export namespace Worktree { }) .then(() => true) .catch((error) => { - const message = error instanceof Error ? error.message : String(error) + const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, @@ -342,9 +343,12 @@ export namespace Worktree { function cleanDirectory(target: string) { return Effect.promise(() => - import("fs/promises").then((fsp) => - fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), - ), + import("fs/promises") + .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) + .catch((error) => { + const message = errorMessage(error) + throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) + }), ) } diff --git a/packages/opencode/test/cli/plug-concurrency.test.ts b/packages/opencode/test/cli/plug-concurrency.test.ts new file mode 100644 index 0000000000..7f30d25f5f --- /dev/null +++ b/packages/opencode/test/cli/plug-concurrency.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" + +import { Process } from "../../src/util/process" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts") + +type Msg = { + dir: string + target: string + mod: string + holdMs?: number +} + +function run(msg: Msg) { + return Process.run([process.execPath, worker, JSON.stringify(msg)], { + cwd: root, + nothrow: true, + }) +} + +async function plugin(dir: string, kinds: Array<"server" | "tui">) { + const p = path.join(dir, "plugin") + await fs.mkdir(p, { recursive: true }) + await Bun.write( + path.join(p, "package.json"), + JSON.stringify( + { + name: "acme", + version: "1.0.0", + "oc-plugin": kinds, + }, + null, + 2, + ), + ) + return p +} + +async function read(file: string) { + return Filesystem.readJson<{ plugin?: unknown[] }>(file) +} + +function mods(prefix: string, n: number) { + return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`) +} + +function expectPlugins(list: unknown[] | undefined, expectMods: string[]) { + expect(Array.isArray(list)).toBe(true) + const hit = (list ?? []).filter((item): item is string => typeof item === "string") + expect(hit.length).toBe(expectMods.length) + expect(new Set(hit)).toEqual(new Set(expectMods)) +} + +describe("cli.plug.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) +}) diff --git a/packages/opencode/test/cli/plug-task.test.ts b/packages/opencode/test/cli/plug-task.test.ts new file mode 100644 index 0000000000..fa74136b0a --- /dev/null +++ b/packages/opencode/test/cli/plug-task.test.ts @@ -0,0 +1,351 @@ +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() {}, + }, + mkdir: async (dir, opts) => { + await fs.mkdir(dir, opts) + }, + resolve: async () => { + if (target instanceof Error) throw target + return target + }, + stat: Filesystem.stat, + readJson: (file) => Filesystem.readJson(file), + 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, + } +} + +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("cli.plug.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("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 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) + }) +}) diff --git a/packages/opencode/test/cli/tui/keybind-plugin.test.ts b/packages/opencode/test/cli/tui/keybind-plugin.test.ts new file mode 100644 index 0000000000..7cd4c87a73 --- /dev/null +++ b/packages/opencode/test/cli/tui/keybind-plugin.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import type { ParsedKey } from "@opentui/core" +import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds" + +describe("createPluginKeybind", () => { + const defaults = { + open: "ctrl+o", + close: "escape", + } + + test("uses defaults when overrides are missing", () => { + const api = { + match: () => false, + print: (key: string) => key, + } + const bind = createPluginKeybind(api, defaults) + + expect(bind.all).toEqual(defaults) + expect(bind.get("open")).toBe("ctrl+o") + expect(bind.get("close")).toBe("escape") + }) + + test("applies valid overrides", () => { + const api = { + match: () => false, + print: (key: string) => key, + } + const bind = createPluginKeybind(api, defaults, { + open: "ctrl+alt+o", + close: "q", + }) + + expect(bind.all).toEqual({ + open: "ctrl+alt+o", + close: "q", + }) + }) + + test("ignores invalid overrides", () => { + const api = { + match: () => false, + print: (key: string) => key, + } + const bind = createPluginKeybind(api, defaults, { + open: " ", + close: 1, + extra: "ctrl+x", + }) + + expect(bind.all).toEqual(defaults) + expect(bind.get("extra")).toBe("extra") + }) + + test("resolves names for match", () => { + const list: string[] = [] + const api = { + match: (key: string) => { + list.push(key) + return true + }, + print: (key: string) => key, + } + const bind = createPluginKeybind(api, defaults, { + open: "ctrl+shift+o", + }) + + bind.match("open", { name: "x" } as ParsedKey) + bind.match("ctrl+k", { name: "x" } as ParsedKey) + + expect(list).toEqual(["ctrl+shift+o", "ctrl+k"]) + }) + + test("resolves names for print", () => { + const list: string[] = [] + const api = { + match: () => false, + print: (key: string) => { + list.push(key) + return `print:${key}` + }, + } + const bind = createPluginKeybind(api, defaults, { + close: "q", + }) + + expect(bind.print("close")).toBe("print:q") + expect(bind.print("ctrl+p")).toBe("print:ctrl+p") + expect(list).toEqual(["q", "ctrl+p"]) + }) +}) diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts new file mode 100644 index 0000000000..d80ed94cc9 --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -0,0 +1,359 @@ +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") + +type Count = { + event_add: number + event_drop: number + route_add: number + route_drop: number + command_add: number + command_drop: number +} + +test("disposes tracked event, route, and command hooks", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginPath = path.join(dir, "lifecycle-plugin.ts") + const pluginSpec = pathToFileURL(pluginPath).href + const marker = path.join(dir, "dispose-marker.txt") + + await Bun.write( + pluginPath, + `export default { + tui: async (api, options) => { + api.event.on("event.test", () => {}) + api.route.register([{ name: "lifecycle.route", render: () => null }]) + const off = api.command.register(() => []) + off() + 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 { + marker, + pluginSpec, + } + }, + }) + + const count: Count = { + event_add: 0, + event_drop: 0, + route_add: 0, + route_drop: 0, + command_add: 0, + command_drop: 0, + } + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [[tmp.extra.pluginSpec, { marker: tmp.extra.marker }]], + plugin_meta: { + [name]: { + 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({ count })) + + expect(count.event_add).toBe(1) + expect(count.event_drop).toBe(0) + expect(count.route_add).toBe(1) + expect(count.route_drop).toBe(0) + expect(count.command_add).toBe(1) + expect(count.command_drop).toBe(1) + + await TuiPluginRuntime.dispose() + + expect(count.event_drop).toBe(1) + expect(count.route_drop).toBe(1) + expect(count.command_drop).toBe(1) + + await TuiPluginRuntime.dispose() + + expect(count.event_drop).toBe(1) + expect(count.route_drop).toBe(1) + expect(count.command_drop).toBe(1) + + const marker = await fs.readFile(tmp.extra.marker, "utf8") + expect(marker).toContain("custom") + expect(marker).toContain("aborted:true") + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("rolls back failed plugin exports and continues loading", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const badPath = path.join(dir, "bad-plugin.ts") + const badSpec = pathToFileURL(badPath).href + const goodPath = path.join(dir, "good-plugin.ts") + const goodSpec = pathToFileURL(goodPath).href + const badMarker = path.join(dir, "bad-cleanup.txt") + const goodMarker = path.join(dir, "good-called.txt") + + await Bun.write( + badPath, + `export default { + 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( + goodPath, + `export default { + tui: async (_api, options) => { + await Bun.write(options.good_marker, "called") + }, +} +`, + ) + + return { + badSpec, + goodSpec, + badMarker, + goodMarker, + } + }, + }) + + const count: Count = { + event_add: 0, + event_drop: 0, + route_add: 0, + route_drop: 0, + command_add: 0, + command_drop: 0, + } + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const badName = path.parse(new URL(tmp.extra.badSpec).pathname).name + const goodName = path.parse(new URL(tmp.extra.goodSpec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [ + [tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }], + [tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }], + ], + plugin_meta: { + [badName]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + [goodName]: { + 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({ count })) + + await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned") + await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called") + expect(count.route_add).toBe(1) + expect(count.route_drop).toBe(1) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("registers slots via api and ignores manual slot plugin id", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginPath = path.join(dir, "slot-plugin.ts") + const pluginSpec = pathToFileURL(pluginPath).href + const marker = path.join(dir, "slot-setup.txt") + + await Bun.write( + pluginPath, + `import fs from "fs" + +const mark = (label) => { + fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n") +} + +export default { + 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_tips() { + return null + }, + }, + }) + mark("id:" + one) + mark("id:" + two) + }, +} +`, + ) + + return { + pluginSpec, + marker, + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.pluginSpec], + plugin_meta: { + [name]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + 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:${name}`) + expect(marker).toContain(`id:${name}:1`) + + const hit = err.mock.calls.find( + (item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin export"), + ) + expect(hit).toBeUndefined() + } finally { + await TuiPluginRuntime.dispose() + err.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test( + "times out hanging plugin cleanup on dispose", + async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginPath = path.join(dir, "timeout-plugin.ts") + const pluginSpec = pathToFileURL(pluginPath).href + + await Bun.write( + pluginPath, + `export default { + tui: async (api) => { + api.lifecycle.onDispose(() => new Promise(() => {})) + }, +} +`, + ) + + return { + pluginSpec, + } + }, + }) + + const count: Count = { + event_add: 0, + event_drop: 0, + route_add: 0, + route_drop: 0, + command_add: 0, + command_drop: 0, + } + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.pluginSpec], + plugin_meta: { + [name]: { + 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({ count })) + + const done = await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve("timeout") + }, 7000) + TuiPluginRuntime.dispose().then(() => { + clearTimeout(timer) + resolve("done") + }) + }) + expect(done).toBe("done") + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } + }, + { timeout: 15000 }, +) diff --git a/packages/opencode/test/cli/tui/plugin-loader-error-logging.test.ts b/packages/opencode/test/cli/tui/plugin-loader-error-logging.test.ts new file mode 100644 index 0000000000..4b298e262b --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-loader-error-logging.test.ts @@ -0,0 +1,84 @@ +import { expect, spyOn, test } from "bun:test" +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") + +function rec(value: unknown) { + if (!value || typeof value !== "object") return + return Object.fromEntries(Object.entries(value)) +} + +test("logs useful details when a tui plugin import fails", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const bad = path.join(dir, "bad-plugin.ts") + const spec = pathToFileURL(bad).href + await Bun.write( + bad, + `import "./missing-module.ts" + +export default { + tui: async () => {}, +} +`, + ) + return { spec } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const name = path.parse(new URL(tmp.extra.spec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_meta: { + [name]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const err = spyOn(console, "error").mockImplementation(() => {}) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + + const call = err.mock.calls.find( + (item) => typeof item[0] === "string" && item[0].includes("failed to load tui plugin"), + ) + expect(call).toBeDefined() + if (!call) return + + expect(String(call[0])).toContain("failed to load tui plugin:") + const data = rec(call[1]) + expect(data).toBeDefined() + if (!data) return + expect(data.path).toBe(tmp.extra.spec) + expect(data.target).toBe(tmp.extra.spec) + expect(data.retry).toBe(false) + expect(data.error).toBeObject() + + const info = rec(data.error) + expect(info).toBeDefined() + if (!info) return + expect(typeof info.message).toBe("string") + const message = typeof info.message === "string" ? info.message : "" + expect(message.length).toBeGreaterThan(0) + expect(typeof info.formatted).toBe("string") + const formatted = typeof info.formatted === "string" ? info.formatted : "" + expect(formatted.length).toBeGreaterThan(0) + expect(formatted).not.toBe("{}") + } finally { + await TuiPluginRuntime.dispose() + err.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) diff --git a/packages/opencode/test/cli/tui/plugin-loader-missing-meta.test.ts b/packages/opencode/test/cli/tui/plugin-loader-missing-meta.test.ts new file mode 100644 index 0000000000..eec9eafad4 --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-loader-missing-meta.test.ts @@ -0,0 +1,104 @@ +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("continues loading tui plugins when a plugin is missing config metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const badPluginPath = path.join(dir, "missing-meta-plugin.ts") + const nextPluginPath = path.join(dir, "next-plugin.ts") + const plainPluginPath = path.join(dir, "plain-plugin.ts") + const badSpec = pathToFileURL(badPluginPath).href + const nextSpec = pathToFileURL(nextPluginPath).href + const plainSpec = pathToFileURL(plainPluginPath).href + const badMarker = path.join(dir, "missing-meta-called.txt") + const nextMarker = path.join(dir, "next-called.txt") + const plainMarker = path.join(dir, "plain-called.txt") + + await Bun.write( + badPluginPath, + `export default { + tui: async (_api, options) => { + if (!options?.marker) return + await Bun.write(options.marker, "called") + }, +} +`, + ) + + await Bun.write( + nextPluginPath, + `export default { + tui: async (_api, options) => { + if (!options?.marker) return + await Bun.write(options.marker, "called") + }, +} +`, + ) + + await Bun.write( + plainPluginPath, + `export default { + tui: async (_api, options) => { + await Bun.write(${JSON.stringify(plainMarker)}, options === undefined ? "undefined" : options === null ? "null" : "value") + }, +} +`, + ) + + return { + badSpec, + nextSpec, + plainSpec, + badMarker, + nextMarker, + plainMarker, + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const next = path.parse(new URL(tmp.extra.nextSpec).pathname).name + const plain = path.parse(new URL(tmp.extra.plainSpec).pathname).name + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [ + [tmp.extra.badSpec, { marker: tmp.extra.badMarker }], + [tmp.extra.nextSpec, { marker: tmp.extra.nextMarker }], + tmp.extra.plainSpec, + ], + plugin_meta: { + [next]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + [plain]: { + 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.badMarker, "utf8")).rejects.toThrow() + await expect(fs.readFile(tmp.extra.nextMarker, "utf8")).resolves.toBe("called") + await expect(fs.readFile(tmp.extra.plainMarker, "utf8")).resolves.toBe("undefined") + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts new file mode 100644 index 0000000000..39ac99ba09 --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -0,0 +1,71 @@ +import { expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../../fixture/fixture" +import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { TuiConfig } from "../../../src/config/tui" + +const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") + +test("skips external tui plugins in pure mode", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "called.txt") + const name = path.parse(file).name + const meta = path.join(dir, "plugin-meta.json") + + await Bun.write( + file, + `export default { + tui: async (_api, options) => { + if (!options?.marker) return + await Bun.write(options.marker, "called") + }, +} +`, + ) + + return { spec, marker, name, 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.name]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + if (pure === undefined) { + delete process.env.OPENCODE_PURE + } else { + process.env.OPENCODE_PURE = pure + } + if (meta === undefined) { + delete process.env.OPENCODE_PLUGIN_META_FILE + } else { + process.env.OPENCODE_PLUGIN_META_FILE = meta + } + } +}) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts new file mode 100644 index 0000000000..1acb9e5c1d --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -0,0 +1,483 @@ +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 + +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 { + return Filesystem.readJson(file) +} + +async function load(): Promise { + 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 default async (_input, options) => { + if (!options?.fn_marker) return + await Bun.write(options.fn_marker, "called") +} + +export const object_plugin = { + 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 { + 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 { + 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 { + 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(() => {}) + } +} + +describe("tui.plugin.loader", () => { + let data: Data + + beforeAll(async () => { + data = await load() + }) + + test("passes keybind, kv, state, and dialog APIs to object plugins", () => { + expect(data.local.key_modal).toBe("ctrl+alt+m") + expect(data.local.key_close).toBe("q") + expect(data.local.key_unknown).toBe("ctrl+k") + expect(data.local.key_print).toBe("print:ctrl+alt+m") + expect(data.local.kv_before).toBe("missing") + expect(data.local.kv_after).toBe("stored") + expect(data.local.kv_ready).toBe(true) + expect(data.local.diff_count).toBe(1) + expect(data.local.diff_file).toBe("src/app.ts") + expect(data.local.todo_count).toBe(1) + expect(data.local.todo_first).toBe("ship it") + expect(data.local.lsp_count).toBe(1) + expect(data.local.mcp_count).toBe(1) + expect(data.local.mcp_first).toBe("github") + expect(data.local.depth_before).toBe(0) + expect(data.local.open_before).toBe(false) + expect(data.local.size_before).toBe("medium") + expect(data.local.size_after).toBe("large") + expect(data.local.depth_after).toBe(1) + expect(data.local.open_after).toBe(true) + expect(data.local.open_clear).toBe(false) + expect(data.local.cfg_theme).toBe("smoke") + expect(data.local.cfg_diff).toBe("stacked") + expect(data.local.cfg_speed).toBe(1.5) + expect(data.local.cfg_accel).toBe(true) + expect(data.local.cfg_submit).toBe("ctrl+enter") + }) + + test("installs themes in the correct scope and remains resilient", () => { + expect(data.local.before).toBe(false) + expect(data.local.set_missing).toBe(false) + expect(data.local.after).toBe(true) + expect(data.local.set_installed).toBe(true) + expect(data.local.selected).toBe(data.local_theme) + expect(data.local.same).toBe(true) + + expect(data.global.has).toBe(true) + expect(data.global.set_installed).toBe(true) + expect(data.global.selected).toBe(data.global_theme) + + expect(data.invalid.before).toBe(false) + expect(data.invalid.set_missing).toBe(false) + expect(data.invalid.after).toBe(false) + expect(data.invalid.set_installed).toBe(false) + + expect(data.preloaded.before).toBe(true) + expect(data.preloaded.after).toBe(true) + expect(data.preloaded.text).toContain("#303030") + expect(data.preloaded.text).not.toContain("#f0f0f0") + + expect(data.fn_called).toBe(false) + expect(data.local_installed).toContain("#101010") + expect(data.local_installed).not.toContain("#fefefe") + expect(data.global_installed).toContain("#202020") + expect(data.preloaded_installed).toContain("#303030") + expect(data.preloaded_installed).not.toContain("#f0f0f0") + expect(data.leaked_local_to_global).toBe(false) + expect(data.leaked_global_to_local).toBe(false) + }) +}) diff --git a/packages/opencode/test/cli/tui/theme-store.test.ts b/packages/opencode/test/cli/tui/theme-store.test.ts new file mode 100644 index 0000000000..23dcfb71cf --- /dev/null +++ b/packages/opencode/test/cli/tui/theme-store.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" + +const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } = + await import("../../../src/cli/cmd/tui/context/theme") + +test("addTheme writes into module theme store", () => { + const name = `plugin-theme-${Date.now()}` + expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true) + + expect(allThemes()[name]).toBeDefined() +}) + +test("addTheme keeps first theme for duplicate names", () => { + const name = `plugin-theme-keep-${Date.now()}` + const one = structuredClone(DEFAULT_THEMES.opencode) + const two = structuredClone(DEFAULT_THEMES.opencode) + one.theme.primary = "#101010" + two.theme.primary = "#fefefe" + + expect(addTheme(name, one)).toBe(true) + expect(addTheme(name, two)).toBe(false) + + expect(allThemes()[name]).toBeDefined() + expect(allThemes()[name]!.theme.primary).toBe("#101010") +}) + +test("addTheme ignores entries without a theme object", () => { + const name = `plugin-theme-invalid-${Date.now()}` + expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false) + expect(allThemes()[name]).toBeUndefined() +}) + +test("hasTheme checks theme presence", () => { + const name = `plugin-theme-has-${Date.now()}` + expect(hasTheme(name)).toBe(false) + expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true) + expect(hasTheme(name)).toBe(true) +}) + +test("resolveTheme rejects circular color refs", () => { + const item = structuredClone(DEFAULT_THEMES.opencode) + item.defs = { + ...(item.defs ?? {}), + one: "two", + two: "one", + } + item.theme.primary = "one" + + expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference") +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dc2397b38b..096f0f0e66 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -10,6 +10,7 @@ import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" +import * as Network from "../../src/util/network" import { BunProc } from "../../src/bun" // Get managed config directory from environment (set in preload.ts) @@ -746,6 +747,20 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR process.env.OPENCODE_CONFIG_DIR = tmp.extra + const online = spyOn(Network, "online").mockReturnValue(false) + const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => { + const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin") + await fs.mkdir(mod, { recursive: true }) + await Filesystem.write( + path.join(mod, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), + ) + return { + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + } + }) try { await Instance.provide({ @@ -759,25 +774,43 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) } finally { + online.mockRestore() + run.mockRestore() if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR else process.env.OPENCODE_CONFIG_DIR = prev } }) -test("serializes concurrent config dependency installs", async () => { +test("dedupes concurrent config dependency installs for the same dir", async () => { await using tmp = await tmpdir() - const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")] - await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true }))) + const dir = path.join(tmp.path, "a") + await fs.mkdir(dir, { recursive: true }) - const seen: string[] = [] - let active = 0 - let max = 0 + const ticks: number[] = [] + let calls = 0 + let start = () => {} + let done = () => {} + let blocked = () => {} + const ready = new Promise((resolve) => { + start = resolve + }) + const gate = new Promise((resolve) => { + done = resolve + }) + const waiting = new Promise((resolve) => { + blocked = resolve + }) + const online = spyOn(Network, "online").mockReturnValue(false) const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => { - active++ - max = Math.max(max, active) - seen.push(opts?.cwd ?? "") - await new Promise((resolve) => setTimeout(resolve, 25)) - active-- + calls += 1 + start() + await gate + const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin") + await fs.mkdir(mod, { recursive: true }) + await Filesystem.write( + path.join(mod, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), + ) return { code: 0, stdout: Buffer.alloc(0), @@ -786,15 +819,26 @@ test("serializes concurrent config dependency installs", async () => { }) try { - await Promise.all(dirs.map((dir) => Config.installDependencies(dir))) + const first = Config.installDependencies(dir) + await ready + const second = Config.installDependencies(dir, { + waitTick: (tick) => { + ticks.push(tick.attempt) + blocked() + blocked = () => {} + }, + }) + await waiting + done() + await Promise.all([first, second]) } finally { + online.mockRestore() run.mockRestore() } - expect(max).toBe(1) - expect(seen.toSorted()).toEqual(dirs.toSorted()) - expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true) - expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true) + expect(calls).toBe(1) + expect(ticks.length).toBeGreaterThan(0) + expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true) }) test("resolves scoped npm plugins in config", async () => { @@ -1807,7 +1851,7 @@ describe("deduplicatePlugins", () => { const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin") expect(myPlugins.length).toBe(1) - expect(myPlugins[0].startsWith("file://")).toBe(true) + expect(Config.pluginSpecifier(myPlugins[0]).startsWith("file://")).toBe(true) }, }) }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index f9de5b041b..14327d9ba2 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li test("loads managed tui config and gives it highest precedence", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2)) + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2), + ) await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2)) + await Bun.write( + path.join(managedConfigDir, "tui.json"), + JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2), + ) }, }) @@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => { fn: async () => { const config = await TuiConfig.get() expect(config.theme).toBe("managed-theme") + expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) + expect(config.plugin_meta).toEqual({ + "shared-plugin": { + scope: "global", + source: path.join(managedConfigDir, "tui.json"), + }, + }) }, }) }) @@ -508,3 +521,110 @@ 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": { + 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": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + "second-plugin": { + 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": { + scope: "global", + source: path.join(Global.Path.config, "tui.json"), + }, + "local-plugin": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }) + }, + }) +}) diff --git a/packages/opencode/test/fixture/flock-worker.ts b/packages/opencode/test/fixture/flock-worker.ts new file mode 100644 index 0000000000..ac05fe810c --- /dev/null +++ b/packages/opencode/test/fixture/flock-worker.ts @@ -0,0 +1,72 @@ +import fs from "fs/promises" +import { Flock } from "../../src/util/flock" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function input() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Missing flock worker input") + } + + return JSON.parse(raw) as Msg +} + +async function job(input: Msg) { + if (input.ready) { + await fs.writeFile(input.ready, String(process.pid)) + } + + if (input.active) { + await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) + } + + try { + if (input.holdMs && input.holdMs > 0) { + await sleep(input.holdMs) + } + + if (input.done) { + await fs.appendFile(input.done, "1\n") + } + } finally { + if (input.active) { + await fs.rm(input.active, { force: true }) + } + } +} + +async function main() { + const msg = input() + + await Flock.withLock(msg.key, () => job(msg), { + dir: msg.dir, + staleMs: msg.staleMs, + timeoutMs: msg.timeoutMs, + baseDelayMs: msg.baseDelayMs, + maxDelayMs: msg.maxDelayMs, + }) +} + +await main().catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/opencode/test/fixture/plug-worker.ts b/packages/opencode/test/fixture/plug-worker.ts new file mode 100644 index 0000000000..b2090708ee --- /dev/null +++ b/packages/opencode/test/fixture/plug-worker.ts @@ -0,0 +1,99 @@ +import path from "path" +import { mkdir } from "fs/promises" + +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((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 + 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() {}, + }, + mkdir: async (dir, opts) => { + await mkdir(dir, opts) + }, + resolve: async () => msg.target, + stat: (file) => Filesystem.stat(file), + readJson: (file) => Filesystem.readJson(file), + readText: (file) => Filesystem.readText(file), + write: async (file, text) => { + if (msg.holdMs && msg.holdMs > 0) { + await sleep(msg.holdMs) + } + await Filesystem.write(file, text) + }, + exists: (file) => Filesystem.exists(file), + files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)], + global: msg.globalDir ?? path.join(msg.dir, ".global"), + } +} + +function ctx(msg: Msg): PlugCtx { + return { + vcs: msg.vcs ?? "git", + worktree: msg.worktree ?? msg.dir, + directory: msg.directory ?? msg.dir, + } +} + +async function main() { + const msg = input() + const run = createPlugTask( + { + mod: msg.mod, + global: msg.global, + force: msg.force, + }, + deps(msg), + ) + + const ok = await run(ctx(msg)) + if (!ok) { + throw new Error("Plug task failed") + } +} + +await main().catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/opencode/test/fixture/plugin-meta-worker.ts b/packages/opencode/test/fixture/plugin-meta-worker.ts new file mode 100644 index 0000000000..08fe96bd94 --- /dev/null +++ b/packages/opencode/test/fixture/plugin-meta-worker.ts @@ -0,0 +1,24 @@ +type Msg = { + file: string + spec: string + target: 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") +} + +process.env.OPENCODE_PLUGIN_META_FILE = msg.file + +const { PluginMeta } = await import("../../src/plugin/meta") + +await PluginMeta.touch(msg.spec, msg.target) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts new file mode 100644 index 0000000000..d5b570256d --- /dev/null +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -0,0 +1,190 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import 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 +} + +type Opts = { + client?: HostPluginApi["client"] + renderer?: HostPluginApi["renderer"] + count?: Count + keybind?: Partial + tuiConfig?: HostPluginApi["tuiConfig"] + state?: { + session?: Partial + 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 = {} + const count = opts.count + let depth = 0 + let size: "medium" | "large" = "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(name: string, fallback: Value): Value + function kvGet(name: string, fallback?: unknown) { + const value = kv[name] + if (value === undefined) return fallback + return value + } + + return { + client: + opts.client ?? + createOpencodeClient({ + baseUrl: "http://localhost:4096", + }), + event: { + on: () => { + if (count) count.event_add += 1 + return () => { + if (!count) return + count.event_drop += 1 + } + }, + }, + renderer, + 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: { + session: { + diff: opts.state?.session?.diff ?? (() => []), + todo: opts.state?.session?.todo ?? (() => []), + }, + lsp: opts.state?.lsp ?? (() => []), + mcp: opts.state?.mcp ?? (() => []), + }, + theme: { + get current() { + return opts.theme?.current ?? {} + }, + get selected() { + return selected + }, + has(name) { + return has(name) + }, + set(name) { + return set(name) + }, + async install(file) { + if (opts.theme?.install) return opts.theme.install(file) + throw new Error("base theme.install should not run") + }, + mode() { + if (opts.theme?.mode) return opts.theme.mode() + return "dark" + }, + get ready() { + return opts.theme?.ready ?? true + }, + }, + } +} diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts new file mode 100644 index 0000000000..a03b54ba48 --- /dev/null +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -0,0 +1,365 @@ +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( + 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("resolves npm plugin specs with explicit and default versions", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + await Bun.write(file, ["export default async () => {", " return {}", "}", ""].join("\n")) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2), + ) + + return { file } + }, + }) + + const install = spyOn(BunProc, "install").mockImplementation(async () => pathToFileURL(tmp.extra.file).href) + + 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("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 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 async () => {", " return {}", "}", 'export const meta = { name: "invalid" }', ""].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 = {", + " 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 = {", + " 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 async () => {", + ` await Bun.write(${JSON.stringify(mark)}, \"called\")`, + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + }) + + const pure = process.env.OPENCODE_PURE + process.env.OPENCODE_PURE = "1" + + try { + await load(tmp.path) + const called = await fs + .readFile(tmp.extra.mark, "utf8") + .then(() => true) + .catch(() => false) + expect(called).toBe(false) + } finally { + if (pure === undefined) { + delete process.env.OPENCODE_PURE + } else { + process.env.OPENCODE_PURE = pure + } + } + }) +}) diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts new file mode 100644 index 0000000000..51da11787c --- /dev/null +++ b/packages/opencode/test/plugin/meta.test.ts @@ -0,0 +1,134 @@ +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 }) { + return Process.run([process.execPath, worker, JSON.stringify(input)], { + cwd: root, + nothrow: true, + }) +} + +async function map(file: string): Promise> { + return Filesystem.readJson>(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) + expect(one.state).toBe("first") + expect(one.entry.source).toBe("file") + expect(one.entry.modified).toBeDefined() + + const two = await PluginMeta.touch(spec, spec) + 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) + 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(Object.values(saved).some((item) => item.spec === spec && item.load_count === 3)).toBe(true) + }) + + 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) + 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) + 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.name === "acme-plugin" && item.version === "1.1.0")).toBe(true) + const saved = await map<{ name: string; version?: string }>(file) + expect(Object.values(saved).some((item) => item.name === "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, + }), + ), + ) + + expect(out.map((item) => item.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((item) => item.stderr.toString()).filter(Boolean)).toEqual([]) + + const all = await PluginMeta.list() + const hit = Object.values(all).find((item) => item.spec === spec) + expect(hit?.load_count).toBe(n) + + const saved = await map<{ spec: string; load_count: number }>(file) + expect(Object.values(saved).find((item) => item.spec === spec)?.load_count).toBe(n) + }, 20_000) +}) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts new file mode 100644 index 0000000000..e536f3c4ea --- /dev/null +++ b/packages/opencode/test/util/error.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { errorData, errorFormat, errorMessage } from "../../src/util/error" + +describe("util.error", () => { + test("formats native Error instances", () => { + const err = new Error("boom") + expect(errorMessage(err)).toBe("boom") + expect(errorFormat(err)).toContain("boom") + + const data = errorData(err) + expect(data.type).toBe("Error") + expect(data.message).toBe("boom") + expect(String(data.formatted)).toContain("boom") + }) + + test("extracts message from record-like values", () => { + const err = { message: "bad input", code: "E_BAD" } + expect(errorMessage(err)).toBe("bad input") + + const data = errorData(err) + expect(data.message).toBe("bad input") + expect(data.code).toBe("E_BAD") + }) + + test("handles opaque throwables with custom toString", () => { + const err = { + toString() { + return "ResolveMessage: Cannot resolve module" + }, + } + + expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module") + + const data = errorData(err) + expect(data.message).toBe("ResolveMessage: Cannot resolve module") + expect(String(data.formatted)).toContain("ResolveMessage") + }) +}) diff --git a/packages/opencode/test/util/flock.test.ts b/packages/opencode/test/util/flock.test.ts new file mode 100644 index 0000000000..fedbfb0697 --- /dev/null +++ b/packages/opencode/test/util/flock.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Flock } from "../../src/util/flock" +import { Hash } from "../../src/util/hash" +import { Process } from "../../src/util/process" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function wait(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + + throw new Error(`Timed out waiting for file: ${file}`) +} + +function run(msg: Msg) { + return Process.run([process.execPath, worker, JSON.stringify(msg)], { + cwd: root, + nothrow: true, + }) +} + +function spawn(msg: Msg) { + return Process.spawn([process.execPath, worker, JSON.stringify(msg)], { + cwd: root, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) +} + +describe("util.flock", () => { + test("enforces mutual exclusion under process contention", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const done = path.join(tmp.path, "done.log") + const active = path.join(tmp.path, "active") + const key = "flock:stress" + const n = 16 + + const out = await Promise.all( + Array.from({ length: n }, () => + run({ + key, + dir, + done, + active, + holdMs: 30, + staleMs: 1_000, + timeoutMs: 15_000, + }), + ), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + }, 20_000) + + test("times out while waiting when lock is still healthy", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:timeout" + const ready = path.join(tmp.path, "ready") + const proc = spawn({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 10_000, + timeoutMs: 30_000, + }) + + try { + await wait(ready, 5_000) + const seen: string[] = [] + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 10_000, + timeoutMs: 1_000, + onWait: (tick) => { + seen.push(tick.key) + }, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("Timed out waiting for lock") + expect(seen.length).toBeGreaterThan(0) + expect(seen.every((x) => x === key)).toBe(true) + } finally { + await Process.stop(proc).catch(() => undefined) + await proc.exited.catch(() => undefined) + } + }, 15_000) + + test("recovers after a crashed lock owner", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:crash" + const ready = path.join(tmp.path, "ready") + const proc = spawn({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 500, + timeoutMs: 30_000, + }) + + await wait(ready, 5_000) + await Process.stop(proc) + await proc.exited.catch(() => undefined) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 8_000, + }, + ) + + expect(hit).toBe(true) + }, 20_000) + + test("breaks stale lock dirs when heartbeat is missing", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:missing-heartbeat" + const lockDir = lock(dir, key) + + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + }) + + test("recovers when a stale breaker claim was left behind", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + expect(await exists(breaker)).toBe(false) + }) + + test("fails clearly if lock dir is removed while held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:compromised" + const lockDir = lock(dir, key) + + const err = await Flock.withLock( + key, + async () => { + await fs.rm(lockDir, { + recursive: true, + force: true, + }) + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("compromised") + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + expect(hit).toBe(true) + }) + + test("writes owner metadata while lock is held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:meta" + const file = path.join(lock(dir, key), "meta.json") + + await Flock.withLock( + key, + async () => { + const json = await Filesystem.readJson<{ + token?: unknown + pid?: unknown + hostname?: unknown + createdAt?: unknown + }>(file) + + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ) + }) + + test("supports acquire with await using", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:acquire" + const lockDir = lock(dir, key) + + { + await using _ = await Flock.acquire(key, { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }) + expect(await exists(lockDir)).toBe(true) + } + + expect(await exists(lockDir)).toBe(false) + }) + + test("refuses token mismatch release and recovers from stale", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const err = await Flock.withLock( + key, + async () => { + const json = await Filesystem.readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }, + { + dir, + staleMs: 500, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("token mismatch") + expect(await exists(lockDir)).toBe(true) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 6_000, + }, + ) + expect(hit).toBe(true) + }) + + test("fails clearly on unwritable lock roots", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:perm" + + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + + try { + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 100, + timeoutMs: 500, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + const text = err.message + expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true) + } finally { + await fs.chmod(dir, 0o700) + } + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5004a3ee27..c0565a7a2c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -10,7 +10,8 @@ }, "exports": { ".": "./src/index.ts", - "./tool": "./src/tool.ts" + "./tool": "./src/tool.ts", + "./tui": "./src/tui.ts" }, "files": [ "dist" @@ -19,7 +20,21 @@ "@opencode-ai/sdk": "workspace:*", "zod": "catalog:" }, + "peerDependencies": { + "@opentui/core": ">=0.1.90", + "@opentui/solid": ">=0.1.90" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + }, "devDependencies": { + "@opentui/core": "0.1.90", + "@opentui/solid": "0.1.90", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 8bdb51a2ae..d6d7a758dc 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,7 +9,7 @@ import type { Message, Part, Auth, - Config, + Config as SDKConfig, } from "@opencode-ai/sdk" import type { BunShell } from "./shell.js" @@ -32,7 +32,13 @@ export type PluginInput = { $: BunShell } -export type Plugin = (input: PluginInput) => Promise +export type PluginOptions = Record + +export type Config = Omit & { + plugin?: Array +} + +export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise type Rule = { key: string @@ -72,7 +78,7 @@ export type AuthHook = { when?: Rule } > - authorize(inputs?: Record): Promise + authorize(inputs?: Record): Promise } | { type: "api" @@ -116,7 +122,7 @@ export type AuthHook = { )[] } -export type AuthOuathResult = { url: string; instructions: string } & ( +export type AuthOAuthResult = { url: string; instructions: string } & ( | { method: "auto" callback(): Promise< @@ -161,6 +167,9 @@ export type AuthOuathResult = { url: string; instructions: string } & ( } ) +/** @deprecated Use AuthOAuthResult instead. */ +export type AuthOuathResult = AuthOAuthResult + export interface Hooks { event?: (input: { event: Event }) => Promise config?: (input: Config) => Promise diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts new file mode 100644 index 0000000000..e5e1e78f6d --- /dev/null +++ b/packages/plugin/src/tui.ts @@ -0,0 +1,350 @@ +import type { OpencodeClient, Event, LspStatus, McpStatus, Todo } from "@opencode-ai/sdk/v2" +import type { CliRenderer, ParsedKey } from "@opentui/core" +import type { JSX, SolidPlugin } from "@opentui/solid" +import type { Config, Plugin, 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 + } + +export type TuiRouteDefinition = { + name: string + render: (input: { params?: Record }) => 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 + +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" + onClose: () => void + children?: JSX.Element +} + +export type TuiDialogStack = { + replace: (render: () => JSX.Element, onClose?: () => void) => void + clear: () => void + setSize: (size: "medium" | "large") => void + readonly size: "medium" | "large" + 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 = { + title: string + value: Value + description?: string + footer?: JSX.Element | string + category?: string + disabled?: boolean + onSelect?: () => void +} + +export type TuiDialogSelectProps = { + title: string + placeholder?: string + options: TuiDialogSelectOption[] + flat?: boolean + onMove?: (option: TuiDialogSelectOption) => void + onFilter?: (query: string) => void + onSelect?: (option: TuiDialogSelectOption) => void + skipFilter?: boolean + current?: Value +} + +export type TuiToast = { + variant?: "info" | "success" | "warning" | "error" + title?: string + message: string + duration?: number +} + +export type TuiTheme = { + readonly current: Record + readonly selected: string + has: (name: string) => boolean + set: (name: string) => boolean + install: (jsonPath: string) => Promise + mode: () => "dark" | "light" + readonly ready: boolean +} + +export type TuiKV = { + get: (key: string, fallback?: Value) => Value + set: (key: string, value: unknown) => void + readonly ready: boolean +} + +export type TuiState = { + session: { + diff: (sessionID: string) => ReadonlyArray + todo: (sessionID: string) => ReadonlyArray + } + lsp: () => ReadonlyArray + mcp: () => ReadonlyArray +} + +type TuiConfigView = Pick & NonNullable + +type Frozen = Value extends (...args: never[]) => unknown + ? Value + : Value extends ReadonlyArray + ? ReadonlyArray> + : Value extends object + ? { readonly [Key in keyof Value]: Frozen } + : Value + +export type TuiApi = { + command: { + register: (cb: () => TuiCommand[]) => () => void + trigger: (value: string) => void + } + route: { + register: (routes: TuiRouteDefinition[]) => () => void + navigate: (name: string, params?: Record) => 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: (props: TuiDialogSelectProps) => JSX.Element + toast: (input: TuiToast) => void + dialog: TuiDialogStack + } + keybind: { + match: (key: string, evt: ParsedKey) => boolean + print: (key: string) => string + create: (defaults: TuiKeybindMap, overrides?: Record) => TuiKeybindSet + } + readonly tuiConfig: Frozen + kv: TuiKV + state: TuiState + theme: TuiTheme +} + +export type TuiSidebarMcpItem = { + name: string + status: McpStatus["status"] + error?: string +} + +export type TuiSidebarLspItem = Pick + +export type TuiSidebarTodoItem = Pick + +export type TuiSidebarFileItem = { + file: string + additions: number + deletions: number +} + +export type TuiSlotMap = { + app: {} + home_logo: {} + home_tips: { + show_tips: boolean + tips_hidden: boolean + first_time_user: boolean + } + home_below_tips: { + show_tips: boolean + tips_hidden: boolean + first_time_user: boolean + } + sidebar_top: { + session_id: string + } + sidebar_title: { + session_id: string + title: string + share_url?: string + } + sidebar_context: { + session_id: string + tokens: number + percentage: number | null + cost: number + } + sidebar_mcp: { + session_id: string + items: TuiSidebarMcpItem[] + connected: number + errors: number + } + sidebar_lsp: { + session_id: string + items: TuiSidebarLspItem[] + disabled: boolean + } + sidebar_todo: { + session_id: string + items: TuiSidebarTodoItem[] + } + sidebar_files: { + session_id: string + items: TuiSidebarFileItem[] + } + sidebar_getting_started: { + session_id: string + show_getting_started: boolean + has_providers: boolean + dismissed: boolean + } + sidebar_directory: { + session_id: string + directory: string + directory_parent: string + directory_name: string + } + sidebar_version: { + session_id: string + version: string + } + sidebar_bottom: { + session_id: string + directory: string + directory_parent: string + directory_name: string + version: string + show_getting_started: boolean + has_providers: boolean + dismissed: boolean + } +} + +export type TuiSlotContext = { + theme: TuiTheme +} + +type SlotCore = SolidPlugin + +export type TuiSlotPlugin = Omit & { + id?: never +} + +export type TuiSlots = { + register: (plugin: TuiSlotPlugin) => string +} + +export type TuiEventBus = { + on: (type: Type, handler: (event: Extract) => void) => () => void +} + +export type TuiDispose = () => void | Promise + +export type TuiLifecycle = { + readonly signal: AbortSignal + onDispose: (fn: TuiDispose) => () => void +} + +export type TuiPluginState = "first" | "updated" | "same" + +export type TuiPluginEntry = { + name: 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 TuiHostPluginApi = TuiApi & { + client: OpencodeClient + event: TuiEventBus + renderer: Renderer +} + +export type TuiPluginApi = TuiHostPluginApi & { + slots: TuiSlots + lifecycle: TuiLifecycle +} + +export type TuiPlugin = ( + api: TuiPluginApi, + options: PluginOptions | undefined, + meta: TuiPluginMeta, +) => Promise + +export type TuiPluginModule = { + server?: Plugin + tui?: TuiPlugin +} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4a2ae95918..ce5a47f84b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1447,7 +1447,15 @@ export type Config = { watcher?: { ignore?: Array } - plugin?: Array + 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. */