From cd320d3a7478bc0388cd2691e621cb12a7f930e5 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 25 Mar 2026 14:21:57 +0100 Subject: [PATCH] workspace plugin api and reactive client accessor --- packages/opencode/src/cli/cmd/tui/app.tsx | 46 +++++++++++++++++-- .../tui/component/dialog-workspace-list.tsx | 33 ++++++------- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 3 ++ .../tui/feature-plugins/sidebar/footer.tsx | 7 ++- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 40 ++++++++++++---- .../src/cli/cmd/tui/plugin/runtime.ts | 43 +++++++++-------- packages/plugin/src/tui.ts | 28 ++++++++++- 7 files changed, 143 insertions(+), 57 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e6786200d5..ce0ad6279b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,6 +14,7 @@ import { batch, Show, on, + onCleanup, } from "solid-js" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" @@ -53,10 +54,12 @@ import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" +import type { TuiHostPluginApi } from "@opencode-ai/plugin/tui" import { FormatError, FormatUnknownError } from "@/cli/error" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -259,6 +262,30 @@ function App(props: { onSnapshot?: () => Promise }) { const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() + const map = new Map() + const root = "__local__" + const scoped = (workspaceID?: string) => { + const key = workspaceID ?? root + const hit = map.get(key) + if (hit) return hit + + const next = createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.data.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) + map.set(key, next) + return next + } + const workspace: TuiHostPluginApi["workspace"] = { + current() { + return sdk.workspaceID + }, + set(workspaceID) { + sdk.setWorkspace(workspaceID) + }, + } const exit = useExit() const promptRef = usePromptRef() const routes: RouteMap = new Map() @@ -267,6 +294,10 @@ function App(props: { onSnapshot?: () => Promise }) { routeRev() return routes.get(name)?.at(-1)?.render } + onCleanup(() => { + map.clear() + }) + const api = createTuiApi({ command, tuiConfig, @@ -280,13 +311,18 @@ function App(props: { onSnapshot?: () => Promise }) { theme: themeState, toast, }) - const [ready, setReady] = createSignal(false) - TuiPluginRuntime.init({ - client: sdk.client, + const host: TuiHostPluginApi = { + ...api, + get client() { + return sdk.client + }, + scopedClient: scoped, + workspace, event: sdk.event, renderer, - ...api, - }) + } + const [ready, setReady] = createSignal(false) + TuiPluginRuntime.init(host) .catch((error) => { console.error("Failed to load TUI plugins", error) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx index 09bb492f63..84127b5763 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -3,14 +3,22 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import type { Session } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" import { useKeybind } from "../context/keybind" import { DialogSessionList } from "./workspace/dialog-session-list" -import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { setTimeout as sleep } from "node:timers/promises" +function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { + return createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.data.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) +} + async function openWorkspace(input: { dialog: ReturnType route: ReturnType @@ -29,12 +37,7 @@ async function openWorkspace(input: { ) } - const client = createOpencodeClient({ - baseUrl: input.sdk.url, - fetch: input.sdk.fetch, - directory: input.sync.data.path.directory || input.sdk.directory, - experimental_workspaceID: input.workspaceID, - }) + const client = scoped(input.sdk, input.sync, input.workspaceID) const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) const session = listed?.data?.[0] if (session?.id) { @@ -187,12 +190,7 @@ export function DialogWorkspaceList() { await open(workspaceID) return } - const client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) + const client = scoped(sdk, sync, workspaceID) const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) if (listed?.data?.length) { dialog.replace(() => ) @@ -223,12 +221,7 @@ export function DialogWorkspaceList() { setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) void Promise.all( workspaces.map(async (workspace) => { - const client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, - experimental_workspaceID: workspace.id, - }) + const client = scoped(sdk, sync, workspace.id) const result = await client.session.list({ roots: true }).catch(() => undefined) return [workspace.id, result ? (result.data?.length ?? 0) : null] as const }), diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 2403a4e938..a0f1b32249 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -109,6 +109,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get client() { return sdk }, + get workspaceID() { + return workspaceID + }, directory: props.directory, event: emitter, fetch: props.fetch ?? fetch, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index bcb79d31c1..23d3e28abb 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -1,5 +1,6 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" +import { Global } from "@/global" function View(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current @@ -11,8 +12,10 @@ function View(props: { api: TuiPluginApi }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const dir = props.api.app.directory - const list = dir.split("/") + const dir = props.api.state.path.directory || process.cwd() + const out = dir.replace(Global.Path.home, "~") + const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out + const list = text.split("/") return { parent: list.slice(0, -1).join("/"), name: list.at(-1) ?? "", diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 82076ba219..e777604bbc 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -14,7 +14,6 @@ import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select" import type { useToast } from "../ui/toast" -import { Global } from "@/global" import { Installation } from "@/installation" type RouteEntry = { @@ -130,6 +129,23 @@ function stateApi(sync: ReturnType): TuiApi["state"] { get provider() { return sync.data.provider }, + get path() { + return sync.data.path + }, + get vcs() { + if (!sync.data.vcs) return + return { + branch: sync.data.vcs.branch, + } + }, + workspace: { + list() { + return sync.data.workspaceList + }, + get(workspaceID) { + return sync.workspace.get(workspaceID) + }, + }, session: { count() { return sync.data.session.length @@ -143,6 +159,18 @@ function stateApi(sync: ReturnType): TuiApi["state"] { messages(sessionID) { return sync.data.message[sessionID] ?? [] }, + status(sessionID) { + return sync.data.session_status[sessionID] + }, + permission(sessionID) { + return sync.data.permission[sessionID] ?? [] + }, + question(sessionID) { + return sync.data.question[sessionID] ?? [] + }, + }, + part(messageID) { + return sync.data.part[messageID] ?? [] }, lsp() { return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })) @@ -159,23 +187,17 @@ function stateApi(sync: ReturnType): TuiApi["state"] { } } -function appApi(sync: ReturnType): TuiApi["app"] { +function appApi(): TuiApi["app"] { return { get version() { return Installation.VERSION }, - get directory() { - const dir = sync.data.path.directory || process.cwd() - const out = dir.replace(Global.Path.home, "~") - if (sync.data.vcs?.branch) return out + ":" + sync.data.vcs.branch - return out - }, } } export function createTuiApi(input: Input): TuiApi { return { - app: appApi(input.sync), + app: appApi(), command: { register(cb) { return input.command.register(() => cb()) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 7592600a89..164a621571 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,9 +39,7 @@ type Deps = { wait?: Promise } -type Api = HostPluginApi & { - slots: HostSlots -} +type Api = HostPluginApi type Scope = { lifecycle: TuiPluginApi["lifecycle"] @@ -375,7 +373,7 @@ function plug(plugin: TuiSlotPlugin, id: string): HostSlotPlugin { } } -function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPluginApi { +function pluginApi(api: Api, host: HostSlots, load: Loaded, state: Scope, base: string): TuiPluginApi { const command: TuiPluginApi["command"] = { register(cb) { return state.wrap(api.command.register(cb)) @@ -397,12 +395,8 @@ function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPlugi }, } - const theme: TuiPluginApi["theme"] = Object.create(api.theme, { - install: { - value: load.install, - configurable: true, - enumerable: true, - }, + const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { + install: load.install, }) const event: TuiPluginApi["event"] = { @@ -417,23 +411,34 @@ function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPlugi register(plugin) { const id = count ? `${base}:${count}` : base count += 1 - state.wrap(api.slots.register(plug(plugin, id))) + state.wrap(host.register(plug(plugin, id))) return id }, } return { - ...api, + app: api.app, command, route, + ui: api.ui, + keybind: api.keybind, + tuiConfig: api.tuiConfig, + kv: api.kv, + state: api.state, theme, + get client() { + return api.client + }, + scopedClient: api.scopedClient, + workspace: api.workspace, event, + renderer: api.renderer, slots, lifecycle: state.lifecycle, } } -async function applyPlugin(api: Api, load: Loaded, meta: TuiPluginMeta, all: Scope[]) { +async function applyPlugin(api: Api, host: HostSlots, load: Loaded, meta: TuiPluginMeta, all: Scope[]) { const opts = load.item ? Config.pluginOptions(load.item) : undefined for (const [name, value] of uniqueModuleEntries(load.mod)) { @@ -450,7 +455,7 @@ async function applyPlugin(api: Api, load: Loaded, meta: TuiPluginMeta, all: Sco if (!tuiPlugin) continue const state = scope(load, name) - const plugin = pluginApi(api, load, state, sid(meta, name)) + const plugin = pluginApi(api, host, load, state, sid(meta, name)) const ready = await Promise.resolve() .then(async () => { await tuiPlugin(plugin, opts, meta) @@ -490,10 +495,7 @@ export namespace TuiPluginRuntime { } dir = cwd - loaded = load({ - ...api, - slots: setupSlots(api), - }) + loaded = load(api) return loaded } @@ -512,6 +514,7 @@ export namespace TuiPluginRuntime { async function load(api: Api) { const cwd = process.cwd() const next: Scope[] = [] + const slots = setupSlots(api) await Instance.provide({ directory: cwd, @@ -526,7 +529,7 @@ export namespace TuiPluginRuntime { 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) + await applyPlugin(api, slots, entry, createMeta(entry.spec, entry.target, undefined, item.name), next) } const loaded = await Promise.all(plugins.map((item) => prepPlugin(config, item))) @@ -572,7 +575,7 @@ export namespace TuiPluginRuntime { // 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) + await applyPlugin(api, slots, entry, createMeta(entry.spec, entry.target, hit), next) } list = next diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 07334098f3..123b1cf978 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -5,7 +5,12 @@ import type { McpStatus, Todo, Message, + Part, Provider, + PermissionRequest, + QuestionRequest, + SessionStatus, + Workspace, Config as SdkConfig, } from "@opencode-ai/sdk/v2" import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core" @@ -211,12 +216,27 @@ export type TuiState = { readonly ready: boolean readonly config: SdkConfig readonly provider: ReadonlyArray + readonly path: { + state: string + config: string + worktree: string + directory: string + } + readonly vcs: { branch?: string } | undefined + readonly workspace: { + list: () => ReadonlyArray + get: (workspaceID: string) => Workspace | undefined + } session: { count: () => number diff: (sessionID: string) => ReadonlyArray todo: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray + status: (sessionID: string) => SessionStatus | undefined + permission: (sessionID: string) => ReadonlyArray + question: (sessionID: string) => ReadonlyArray } + part: (messageID: string) => ReadonlyArray lsp: () => ReadonlyArray mcp: () => ReadonlyArray } @@ -225,7 +245,6 @@ type TuiConfigView = Pick = Value extends (...args: never[]) => unknown @@ -346,8 +365,15 @@ export type TuiPluginMeta = TuiPluginEntry & { state: TuiPluginState } +export type TuiWorkspace = { + current: () => string | undefined + set: (workspaceID?: string) => void +} + export type TuiHostPluginApi = TuiApi & { client: OpencodeClient + scopedClient: (workspaceID?: string) => OpencodeClient + workspace: TuiWorkspace event: TuiEventBus renderer: Renderer }