diff --git a/bun.lock b/bun.lock index 7ff8a3072fd4..ea216dfa8301 100644 --- a/bun.lock +++ b/bun.lock @@ -5512,10 +5512,6 @@ "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], - "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], @@ -6444,10 +6440,6 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index da073f6e9202..77fcba114df5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -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" @@ -27,10 +28,11 @@ export function useEvent() { }) } - function on(type: T, handler: (event: Extract) => void) { + function on(type: T, handler: PluginEventHandler) { + const fn = handler as (event: PluginEvent) => void return subscribe((event) => { if (event.type !== type) return - handler(event as Extract) + fn(event as PluginEvent) }) } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 60942915a37c..e0ab1fedbb21 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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" @@ -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.$, } diff --git a/packages/opencode/test/plugin/event.test.ts b/packages/opencode/test/plugin/event.test.ts new file mode 100644 index 000000000000..3cc20915b07c --- /dev/null +++ b/packages/opencode/test/plugin/event.test.ts @@ -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 } }) + }, + }) + }) +}) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 33ddef5ddfac..547992588f2a 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -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, }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 49d995c6f7b9..4d7530edac98 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -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..`. + */ + event: { + emit(type: string, properties: unknown): void + } $: BunShell } diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index e6f832f7e1ea..e5babf6f6e04 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -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..`) + * arrive as {@link PluginEvent}. + */ +export type PluginEventHandler = ( + event: Type extends Event["type"] ? Extract : PluginEvent, +) => void + export type TuiEventBus = { - on: (type: Type, handler: (event: Extract) => void) => () => void + on: (type: Type, handler: PluginEventHandler) => () => void } export type TuiDispose = () => void | Promise