workspace plugin api and reactive client accessor
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? "",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user