diff --git a/README.md b/README.md index f83390b2..3b21ff7a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ For OpenCode v2, add the package to the native `plugins` list: } ``` +OpenCode also loads the package's `./tui` companion automatically so +auto-capture / profile / error toasts render in the TUI (server plugins +cannot call `ui.toast` directly). You do not need a second entry in +`plugins`. If you run a CLI-only TUI against a remote OpenCode server, +register `opencode-mem/tui` in that CLI's plugin list (for example +`cli.json`) so the companion can subscribe to toast RPC events. + For OpenCode v1, add the default entrypoint to your configuration at `~/.config/opencode/opencode.json`: @@ -113,7 +120,7 @@ To run the plugin from a local source checkout instead of the npm release, `bun } ``` -Point at the package root, not at `dist/` or a single file. OpenCode resolves a directory plugin by falling back to `/index` (OpenCode does not read `package.json` `exports`/`main` for a path spec on current releases), so this repository ships a thin root `index.js` that re-exports the built v2 entrypoint from `dist/plugin.js`. A path to a file is rejected (`configured plugin path must be a directory`), and a directory without a root `index.js` is skipped silently. +Point at the package root, not at `dist/` or a single file. OpenCode resolves a directory plugin by falling back to `/index` (OpenCode does not read `package.json` `exports`/`main` for a path spec on current releases), so this repository ships a thin root `index.js` that re-exports the built v2 entrypoint from `dist/plugin.js`, and a root `tui.js` that re-exports `dist/tui.js` for the TUI companion. A path to a file is rejected (`configured plugin path must be a directory`), and a directory without a root `index.js` is skipped silently. ### Optional database encryption at rest diff --git a/package.json b/package.json index 49f3be7a..0630e976 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "import": "./dist/v2/plugin.js", "types": "./dist/v2/plugin.d.ts" }, + "./tui": { + "import": "./dist/tui.js", + "types": "./dist/tui.d.ts" + }, "./tags": { "import": "./dist/services/tags.js", "types": "./dist/services/tags.d.ts" diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 00000000..ec560c86 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,18 @@ +import { define } from "@opencode/plugin/tui/plugin"; +import { MemToast, type MemToastPayload } from "./v2/toast-rpc.js"; + +/** + * TUI companion for opencode-mem. Subscribes to server-emitted toast RPC + * events and renders them with the host toast UI. + * + * Import from `@opencode/plugin/tui/plugin` (not `@opencode/plugin/tui`) so + * the companion does not pull optional Solid peer deps into the package load. + */ +export default define({ + id: "opencode-mem-tui", + setup(ctx) { + return ctx.client.rpc(MemToast).events.on("toast", (event) => { + ctx.ui.toast.show(event.data as MemToastPayload); + }); + }, +}); diff --git a/src/v2/legacy-client.ts b/src/v2/legacy-client.ts index d73958df..c4bb43e8 100644 --- a/src/v2/legacy-client.ts +++ b/src/v2/legacy-client.ts @@ -1,6 +1,7 @@ import type { Context } from "@opencode/plugin/promise/plugin"; import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; +import type { MemToastPayload } from "./toast-rpc.js"; type LegacyPart = { type?: string; text?: string; metadata?: unknown }; @@ -158,6 +159,42 @@ function schemaPrompt(body: any): string { return sections.filter(Boolean).join("\n\n"); } +export type ToastEmitter = { + events: { + emit: (name: "toast", data: MemToastPayload) => Promise; + }; +}; + +function toastPayloadFrom(input: any): MemToastPayload | null { + const body = bodyFrom(input); + const message = typeof body.message === "string" ? body.message : ""; + if (!message) return null; + + const payload: { + message: string; + title?: string; + variant?: MemToastPayload["variant"]; + duration?: number; + sessionID?: string; + } = { message }; + if (typeof body.title === "string" && body.title) payload.title = body.title; + if ( + body.variant === "info" || + body.variant === "success" || + body.variant === "warning" || + body.variant === "error" + ) { + payload.variant = body.variant; + } + if (typeof body.duration === "number" && Number.isFinite(body.duration)) { + payload.duration = body.duration; + } + if (typeof body.sessionID === "string" && body.sessionID) { + payload.sessionID = body.sessionID; + } + return payload; +} + function toastFallback(input: any): { data: false } { const body = bodyFrom(input); const message = [body.title, body.message].filter(Boolean).join(": "); @@ -169,11 +206,28 @@ function toastFallback(input: any): { data: false } { return { data: false }; } +async function showToastViaRpc( + toastRpc: ToastEmitter | undefined, + input: any +): Promise<{ data: boolean }> { + if (!toastRpc) return toastFallback(input); + + const payload = toastPayloadFrom(input); + if (!payload) return toastFallback(input); + + try { + await toastRpc.events.emit("toast", payload); + return { data: true }; + } catch { + return toastFallback(input); + } +} + /** * Adapts the released OpenCode v2 plugin context to the V1 client shape used * by the shared opencode-mem implementation. */ -export function createLegacyClient(ctx: Context) { +export function createLegacyClient(ctx: Context, toastRpc?: ToastEmitter) { const generatedSessions = new Set(); const client = { @@ -203,7 +257,7 @@ export function createLegacyClient(ctx: Context) { }, }, tui: { - showToast: async (input: any) => toastFallback(input), + showToast: async (input: any) => showToastViaRpc(toastRpc, input), appendPrompt: async () => ({ data: false }), submitPrompt: async () => ({ data: false }), }, diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts index 0f66c785..19153a54 100644 --- a/src/v2/plugin.ts +++ b/src/v2/plugin.ts @@ -4,11 +4,14 @@ import { CONFIG } from "../config.js"; import { loadOpencodeProvider } from "../services/ai/opencode-provider-loader.js"; import { registerV2Adapter } from "./adapter.js"; import { createLegacyClient } from "./legacy-client.js"; +import { MemToast } from "./toast-rpc.js"; const OpenCodeMemPluginV2: Plugin = { id: "opencode-mem", async setup(ctx) { - const legacyClient = createLegacyClient(ctx); + // Server Context has no ui.toast; bridge toast intents to the ./tui companion. + const toastRpc = await ctx.rpc.register(MemToast, {}); + const legacyClient = createLegacyClient(ctx, toastRpc); const legacy = (await OpenCodeMemPlugin({ client: legacyClient, directory: ctx.location.directory, diff --git a/src/v2/toast-rpc.ts b/src/v2/toast-rpc.ts new file mode 100644 index 00000000..bfe068c9 --- /dev/null +++ b/src/v2/toast-rpc.ts @@ -0,0 +1,35 @@ +import { Rpc } from "@opencode/plugin/rpc"; + +/** + * Shared server↔TUI contract for toast intents. + * Server plugins have no ui.toast; they emit via rpc.register events, + * and the ./tui companion renders with ctx.ui.toast.show. + */ +export const MemToast = Rpc.define({ + id: "opencode-mem", + methods: {}, + events: { + toast: { + schema: { + type: "object", + properties: { + title: { type: "string" }, + message: { type: "string" }, + variant: { type: "string" }, + duration: { type: "number" }, + sessionID: { type: "string" }, + }, + required: ["message"], + additionalProperties: false, + }, + }, + }, +}); + +export type MemToastPayload = { + readonly message: string; + readonly title?: string; + readonly variant?: "info" | "success" | "warning" | "error"; + readonly duration?: number; + readonly sessionID?: string; +}; diff --git a/tests/plugin-v2-loader-contract.test.ts b/tests/plugin-v2-loader-contract.test.ts index 558695e1..e07ec617 100644 --- a/tests/plugin-v2-loader-contract.test.ts +++ b/tests/plugin-v2-loader-contract.test.ts @@ -18,12 +18,26 @@ describe("OpenCode v2 plugin-loader contract", () => { expect(pkg.files).toContain("dist"); }); + it("publishes the TUI companion entrypoint", () => { + const pkg = readPackageJson(); + expect(pkg.exports?.["./tui"]).toEqual({ + import: "./dist/tui.js", + types: "./dist/tui.d.ts", + }); + }); + it("exports a native v2 plugin definition", async () => { const mod = await import(new URL("../dist/v2/plugin.js", import.meta.url).href); expect(mod.default.id).toBe("opencode-mem"); expect(typeof mod.default.setup).toBe("function"); }); + it("exports a TUI companion plugin definition", async () => { + const mod = await import(new URL("../dist/tui.js", import.meta.url).href); + expect(mod.default.id).toBe("opencode-mem-tui"); + expect(typeof mod.default.setup).toBe("function"); + }); + it("exposes V1 and V2 from the installable package entrypoint", async () => { const mod = await import(new URL("../dist/plugin.js", import.meta.url).href); expect(mod.default.id).toBe("opencode-mem"); diff --git a/tests/tui-companion.test.ts b/tests/tui-companion.test.ts new file mode 100644 index 00000000..6ee400bc --- /dev/null +++ b/tests/tui-companion.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import tuiPlugin from "../src/tui.js"; +import { MemToast } from "../src/v2/toast-rpc.js"; + +describe("opencode-mem TUI companion", () => { + it("subscribes to MemToast events and shows host toasts", () => { + expect(tuiPlugin.id).toBe("opencode-mem-tui"); + + const shown: unknown[] = []; + let subscribedDefinition: unknown; + let subscribedName: string | undefined; + let handler: ((event: { data: unknown }) => void) | undefined; + + const cleanup = tuiPlugin.setup({ + client: { + rpc: (definition: unknown) => { + subscribedDefinition = definition; + return { + events: { + on: (name: string, next: (event: { data: unknown }) => void) => { + subscribedName = name; + handler = next; + return () => { + handler = undefined; + }; + }, + }, + }; + }, + }, + ui: { + toast: { + show: (options: unknown) => { + shown.push(options); + }, + }, + }, + } as any); + + expect(subscribedDefinition).toBe(MemToast); + expect(subscribedName).toBe("toast"); + expect(typeof cleanup).toBe("function"); + + handler?.({ + data: { + title: "Memory Captured", + message: "Project memory saved from conversation", + variant: "success", + duration: 3000, + }, + }); + + expect(shown).toEqual([ + { + title: "Memory Captured", + message: "Project memory saved from conversation", + variant: "success", + duration: 3000, + }, + ]); + + cleanup?.(); + expect(handler).toBeUndefined(); + }); +}); diff --git a/tests/v2-legacy-client.test.ts b/tests/v2-legacy-client.test.ts index 22e061f6..13b6b306 100644 --- a/tests/v2-legacy-client.test.ts +++ b/tests/v2-legacy-client.test.ts @@ -444,4 +444,69 @@ describe("OpenCode v2 legacy client bridge", () => { metadata: { count: 1 }, }); }); + + it("emits toast intents over the RPC bridge when registered", async () => { + const emitted: Array<{ name: string; data: unknown }> = []; + const toastRpc = { + events: { + emit: async (name: "toast", data: unknown) => { + emitted.push({ name, data }); + }, + }, + }; + const client = createLegacyClient(createContext(), toastRpc); + + const result = await client.tui.showToast({ + body: { + title: "Memory Captured", + message: "Project memory saved from conversation", + variant: "success", + duration: 3000, + }, + }); + + expect(result).toEqual({ data: true }); + expect(emitted).toEqual([ + { + name: "toast", + data: { + title: "Memory Captured", + message: "Project memory saved from conversation", + variant: "success", + duration: 3000, + }, + }, + ]); + }); + + it("falls back to console when no toast RPC registration is provided", async () => { + const client = createLegacyClient(createContext()); + const result = await client.tui.showToast({ + body: { + title: "Memory Captured", + message: "Project memory saved from conversation", + variant: "success", + }, + }); + expect(result).toEqual({ data: false }); + }); + + it("falls back to console when toast RPC emit fails", async () => { + const toastRpc = { + events: { + emit: async () => { + throw new Error("rpc unavailable"); + }, + }, + }; + const client = createLegacyClient(createContext(), toastRpc); + const result = await client.tui.showToast({ + body: { + title: "Auto Capture Failed", + message: "timeout", + variant: "error", + }, + }); + expect(result).toEqual({ data: false }); + }); }); diff --git a/tui.js b/tui.js new file mode 100644 index 00000000..51797d40 --- /dev/null +++ b/tui.js @@ -0,0 +1,2 @@ +export * from "./dist/tui.js"; +export { default } from "./dist/tui.js";