Skip to content
Merged
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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 `<directory>/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 `<directory>/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

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions src/tui.ts
Original file line number Diff line number Diff line change
@@ -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);
});
},
});
58 changes: 56 additions & 2 deletions src/v2/legacy-client.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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<void>;
};
};

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(": ");
Expand All @@ -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<string>();

const client = {
Expand Down Expand Up @@ -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 }),
},
Expand Down
5 changes: 4 additions & 1 deletion src/v2/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/v2/toast-rpc.ts
Original file line number Diff line number Diff line change
@@ -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;
};
14 changes: 14 additions & 0 deletions tests/plugin-v2-loader-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
65 changes: 65 additions & 0 deletions tests/tui-companion.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
65 changes: 65 additions & 0 deletions tests/v2-legacy-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
2 changes: 2 additions & 0 deletions tui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./dist/tui.js";
export { default } from "./dist/tui.js";
Loading