workspace plugin api and reactive client accessor

This commit is contained in:
Sebastian Herrlinger
2026-03-25 14:21:57 +01:00
parent 9d4b720c4f
commit cd320d3a74
7 changed files with 143 additions and 57 deletions

View File

@@ -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<string[]> }) {
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const map = new Map<string, OpencodeClient>()
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<string[]> }) {
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<string[]> }) {
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)
})

View File

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

View File

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

View File

@@ -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) ?? "",

View File

@@ -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<typeof useSync>): 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<typeof useSync>): 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<typeof useSync>): TuiApi["state"] {
}
}
function appApi(sync: ReturnType<typeof useSync>): 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())

View File

@@ -39,9 +39,7 @@ type Deps = {
wait?: Promise<void>
}
type Api = HostPluginApi & {
slots: HostSlots
}
type Api = HostPluginApi
type Scope = {
lifecycle: TuiPluginApi<CliRenderer>["lifecycle"]
@@ -375,7 +373,7 @@ function plug(plugin: TuiSlotPlugin, id: string): HostSlotPlugin {
}
}
function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPluginApi<CliRenderer> {
function pluginApi(api: Api, host: HostSlots, load: Loaded, state: Scope, base: string): TuiPluginApi<CliRenderer> {
const command: TuiPluginApi<CliRenderer>["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<CliRenderer>["theme"] = Object.create(api.theme, {
install: {
value: load.install,
configurable: true,
enumerable: true,
},
const theme: TuiPluginApi<CliRenderer>["theme"] = Object.assign(Object.create(api.theme), {
install: load.install,
})
const event: TuiPluginApi<CliRenderer>["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

View File

@@ -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<Provider>
readonly path: {
state: string
config: string
worktree: string
directory: string
}
readonly vcs: { branch?: string } | undefined
readonly workspace: {
list: () => ReadonlyArray<Workspace>
get: (workspaceID: string) => Workspace | undefined
}
session: {
count: () => number
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
messages: (sessionID: string) => ReadonlyArray<Message>
status: (sessionID: string) => SessionStatus | undefined
permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
question: (sessionID: string) => ReadonlyArray<QuestionRequest>
}
part: (messageID: string) => ReadonlyArray<Part>
lsp: () => ReadonlyArray<TuiSidebarLspItem>
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
}
@@ -225,7 +245,6 @@ type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plug
export type TuiApp = {
readonly version: string
readonly directory: string
}
type Frozen<Value> = 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<Renderer = CliRenderer> = TuiApi & {
client: OpencodeClient
scopedClient: (workspaceID?: string) => OpencodeClient
workspace: TuiWorkspace
event: TuiEventBus
renderer: Renderer
}