mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-23 22:34:53 +00:00
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user