Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Event } from "@opencode-ai/sdk/v2"
import type { PluginEvent, PluginEventHandler } from "@opencode-ai/plugin/tui"
import { useProject } from "./project"
import { useSDK } from "./sdk"

Expand Down Expand Up @@ -27,10 +28,11 @@ export function useEvent() {
})
}

function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
function on<T extends Event["type"] | string>(type: T, handler: PluginEventHandler<T>) {
const fn = handler as (event: PluginEvent) => void
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
fn(event as PluginEvent)
})
}

Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import type {
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import z from "zod"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { BusEvent } from "../bus/bus-event"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Flag } from "../flag/flag"
Expand Down Expand Up @@ -148,6 +150,11 @@ export namespace Plugin {
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
event: {
emit(type, properties) {
void Bus.publish(BusEvent.define(type, z.unknown()), properties)
},
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/plugin/event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"

const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"

const { Plugin } = await import("../../src/plugin/index")
const { Bus } = await import("../../src/bus")
const { Instance } = await import("../../src/project/instance")

afterEach(async () => {
await Instance.disposeAll()
})

afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})

async function project(source: string) {
return tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, source)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
)
},
})
}

describe("plugin.event", () => {
test("emits custom bus events reachable via subscribeAll", async () => {
await using tmp = await project(
[
"export default async (input) => ({",
" config() {",
' input.event.emit("plugin.demo.tick", { n: 1 })',
" },",
"})",
"",
].join("\n"),
)

await Instance.provide({
directory: tmp.path,
fn: async () => {
const received: Array<{ type: string; properties: unknown }> = []
const off = Bus.subscribeAll((evt) => {
received.push(evt)
})
await Plugin.init()
await Bun.sleep(10)
off()
expect(received).toContainEqual({ type: "plugin.demo.tick", properties: { n: 1 } })
},
})
})
})
3 changes: 3 additions & 0 deletions packages/opencode/test/plugin/github-copilot-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => {
register() {},
},
serverUrl: new URL("https://example.com"),
event: {
emit() {},
},
$: {} as never,
})

Expand Down
7 changes: 7 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export type PluginInput = {
register(type: string, adaptor: WorkspaceAdaptor): void
}
serverUrl: URL
/**
* Emit custom events on the bus. TUI plugins subscribe via
* `api.event.on(...)`. Namespace types as `plugin.<pluginID>.<name>`.
*/
event: {
emit(type: string, properties: unknown): void
}
$: BunShell
}

Expand Down
16 changes: 15 additions & 1 deletion packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,22 @@ export type TuiSlots = {
}
}

export type PluginEvent = {
type: string
properties: unknown
}

/**
* Event handler for `api.event.on`. Known server event types narrow to their
* SDK shape; custom types emitted by server plugins (e.g. `plugin.<pluginID>.<name>`)
* arrive as {@link PluginEvent}.
*/
export type PluginEventHandler<Type extends string> = (
event: Type extends Event["type"] ? Extract<Event, { type: Type }> : PluginEvent,
) => void

export type TuiEventBus = {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
on: <Type extends Event["type"] | string>(type: Type, handler: PluginEventHandler<Type>) => () => void
}

export type TuiDispose = () => void | Promise<void>
Expand Down
Loading