This commit is contained in:
Sebastian Herrlinger
2026-03-25 14:35:04 +01:00
parent cd320d3a74
commit c0a4755d15
4 changed files with 297 additions and 9 deletions

View File

@@ -0,0 +1,134 @@
import { expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("exposes expanded plugin state facade", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "state-plugin.ts")
const pluginSpec = pathToFileURL(pluginPath).href
const marker = path.join(dir, "state-marker.json")
await Bun.write(
pluginPath,
`export default {
tui: async (api, options) => {
const row = {
path_directory: api.state.path.directory,
path_config: api.state.path.config,
vcs_branch: api.state.vcs?.branch ?? null,
workspace_count: api.state.workspace.list().length,
workspace_hit: api.state.workspace.get(options.workspace_id)?.id ?? null,
diff_count: api.state.session.diff(options.session_id).length,
todo_count: api.state.session.todo(options.session_id).length,
status: api.state.session.status(options.session_id)?.type ?? null,
permission_count: api.state.session.permission(options.session_id).length,
question_count: api.state.session.question(options.session_id).length,
part_count: api.state.part(options.message_id).length,
}
await Bun.write(options.marker, JSON.stringify(row))
},
}
`,
)
return {
marker,
pluginSpec,
}
},
})
const restore = mockTuiRuntime(tmp.path, [
[
tmp.extra.pluginSpec,
{
marker: tmp.extra.marker,
session_id: "ses_1",
message_id: "msg_1",
workspace_id: "ws_1",
},
],
])
try {
await TuiPluginRuntime.init(
createTuiPluginApi({
state: {
path: {
state: "/tmp/project/.opencode/state",
config: "/tmp/project/.opencode/config",
worktree: "/tmp/project",
directory: "/tmp/project",
},
vcs: {
branch: "dev",
},
workspace: {
list() {
return [
{
id: "ws_1",
type: "worktree",
branch: "dev",
name: "Workspace 1",
directory: "/tmp/ws_1",
extra: null,
projectID: "project_1",
},
]
},
get(workspaceID) {
if (workspaceID !== "ws_1") return
return {
id: "ws_1",
type: "worktree",
branch: "dev",
name: "Workspace 1",
directory: "/tmp/ws_1",
extra: null,
projectID: "project_1",
}
},
},
session: {
diff(sessionID) {
if (sessionID !== "ses_1") return []
return [{ file: "src/app.ts", additions: 2, deletions: 1 }]
},
todo(sessionID) {
if (sessionID !== "ses_1") return []
return [{ content: "ship", status: "pending" }]
},
status(sessionID) {
if (sessionID !== "ses_1") return
return { type: "idle" }
},
},
},
}),
)
const row = JSON.parse(await fs.readFile(tmp.extra.marker, "utf8")) as Record<string, unknown>
expect(row.path_directory).toBe("/tmp/project")
expect(row.path_config).toBe("/tmp/project/.opencode/config")
expect(row.vcs_branch).toBe("dev")
expect(row.workspace_count).toBe(1)
expect(row.workspace_hit).toBe("ws_1")
expect(row.diff_count).toBe(1)
expect(row.todo_count).toBe(1)
expect(row.status).toBe("idle")
expect(row.permission_count).toBe(0)
expect(row.question_count).toBe(0)
expect(row.part_count).toBe(0)
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})

View File

@@ -0,0 +1,80 @@
import { expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { tmpdir } from "../../fixture/fixture"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("api.client tracks runtime client rebinds", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "rebind-plugin.ts")
const pluginSpec = pathToFileURL(pluginPath).href
const marker = path.join(dir, "rebind-marker.json")
await Bun.write(
pluginPath,
`export default {
tui: async (api, options) => {
const one = api.client.global
const one_scoped = api.scopedClient(options.workspace_id)
api.workspace.set(options.workspace_id)
const two = api.client.global
const two_scoped = api.scopedClient(options.workspace_id)
await Bun.write(
options.marker,
JSON.stringify({
rebound: one !== two,
scoped_ok: !!one_scoped && !!two_scoped,
}),
)
},
}
`,
)
return {
marker,
pluginSpec,
}
},
})
const restore = mockTuiRuntime(tmp.path, [[tmp.extra.pluginSpec, { marker: tmp.extra.marker, workspace_id: "ws_1" }]])
const local = createOpencodeClient({ baseUrl: "http://localhost:4096" })
const scoped = createOpencodeClient({
baseUrl: "http://localhost:4096",
experimental_workspaceID: "ws_1",
})
let cur = local
try {
await TuiPluginRuntime.init(
createTuiPluginApi({
client: () => cur,
scopedClient: (_workspaceID?: string) => scoped,
workspace: {
current: () => undefined,
set: (workspaceID) => {
cur = workspaceID ? scoped : local
},
},
}),
)
const hit = JSON.parse(await fs.readFile(tmp.extra.marker, "utf8")) as {
rebound: boolean
scoped_ok: boolean
}
expect(hit.rebound).toBe(true)
expect(hit.scoped_ok).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})

View File

@@ -81,7 +81,9 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
}
type Opts = {
client?: HostPluginApi["client"]
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
scopedClient?: HostPluginApi["scopedClient"]
workspace?: Partial<HostPluginApi["workspace"]>
renderer?: HostPluginApi["renderer"]
count?: Count
keybind?: Partial<HostPluginApi["keybind"]>
@@ -91,7 +93,11 @@ type Opts = {
ready?: HostPluginApi["state"]["ready"]
config?: HostPluginApi["state"]["config"]
provider?: HostPluginApi["state"]["provider"]
path?: HostPluginApi["state"]["path"]
vcs?: HostPluginApi["state"]["vcs"]
workspace?: Partial<HostPluginApi["state"]["workspace"]>
session?: Partial<HostPluginApi["state"]["session"]>
part?: HostPluginApi["state"]["part"]
lsp?: HostPluginApi["state"]["lsp"]
mcp?: HostPluginApi["state"]["mcp"]
}
@@ -109,6 +115,22 @@ type Opts = {
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
const kv: Record<string, unknown> = {}
const count = opts.count
const own = createOpencodeClient({
baseUrl: "http://localhost:4096",
})
const fallback = () => own
const read =
typeof opts.client === "function"
? opts.client
: opts.client
? () => opts.client as HostPluginApi["client"]
: fallback
const client = () => read()
const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
const workspace: HostPluginApi["workspace"] = {
current: opts.workspace?.current ?? (() => undefined),
set: opts.workspace?.set ?? (() => {}),
}
let depth = 0
let size: "medium" | "large" = "medium"
const has = opts.theme?.has ?? (() => false)
@@ -144,15 +166,12 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
get version() {
return opts.app?.version ?? "0.0.0-test"
},
get directory() {
return opts.app?.directory ?? "~"
},
},
client:
opts.client ??
createOpencodeClient({
baseUrl: "http://localhost:4096",
}),
get client() {
return client()
},
scopedClient,
workspace,
event: {
on: () => {
if (count) count.event_add += 1
@@ -243,12 +262,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
get provider() {
return opts.state?.provider ?? []
},
get path() {
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
},
get vcs() {
return opts.state?.vcs
},
workspace: {
list: opts.state?.workspace?.list ?? (() => []),
get: opts.state?.workspace?.get ?? (() => undefined),
},
session: {
count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []),
todo: opts.state?.session?.todo ?? (() => []),
messages: opts.state?.session?.messages ?? (() => []),
status: opts.state?.session?.status ?? (() => undefined),
permission: opts.state?.session?.permission ?? (() => []),
question: opts.state?.session?.question ?? (() => []),
},
part: opts.state?.part ?? (() => []),
lsp: opts.state?.lsp ?? (() => []),
mcp: opts.state?.mcp ?? (() => []),
},

View File

@@ -0,0 +1,41 @@
import { spyOn } from "bun:test"
import path from "path"
import { TuiConfig } from "../../src/config/tui"
type PluginSpec = string | [string, Record<string, unknown>]
function name(spec: string) {
if (spec.startsWith("file://")) {
return path.parse(new URL(spec).pathname).name
}
return path.parse(spec).name
}
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
const meta = Object.fromEntries(
plugin.map((item) => {
const spec = Array.isArray(item) ? item[0] : item
return [
name(spec),
{
scope: "local" as const,
source: path.join(dir, "tui.json"),
},
]
}),
)
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin,
plugin_meta: meta,
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
return () => {
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
}