// TUI / CLI plugin — template // // Docs: https://opencode.ai/v2/docs/build/plugins/cli/ // Loading: https://opencode.ai/v2/docs/cli/plugins // // A TUI plugin is a SEPARATE export from a server plugin. One package can have // both: "." is the server plugin, "./tui" is this file. // PREREQUISITE: install the package at your OpenCode version and restart the // service (see the PREREQUISITE block in promise-plugin.ts). // // This entrypoint also needs its JSX runtime peers: solid-js, @opentui/core, // @opentui/solid, @opencode/theme. Without them the file will not load, and // `tsc` reports that JSX.IntrinsicElements is missing. import { Plugin } from "@opencode/plugin/tui" export default Plugin.define({ id: "acme.cli", // The page's own examples use `async setup(context)` when they await. async setup(context) { // ---------------------------------------------------------------- basics const location = context.location ?? context.data.location.default() void context.app.version void context.options.compact // ---------------------------------------------------------------- toasts context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" }) // -------------------------------------------------------------- commands // The layer factory MUST be pure. OpenCode evaluates it once without // dependency tracking to validate the initial shape, then reactively. context.keymap.layer(() => ({ mode: "global", priority: 10, commands: [ { id: "acme.status", title: "Show Acme status", group: "Acme", bind: "ctrl+g", palette: true, slash: { name: "acme", aliases: ["status"], arguments: true }, enabled: () => true, suggested: true, run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }), }, ], bindings: ["acme.status"], })) // ---------------------------------------------------------------- events // Both return an unsubscribe function. const stopPermission = context.data.on("permission.asked", (event) => { context.ui.toast.show({ message: `Permission ${event.data.id}` }) }) const stopAll = context.data.listen(({ details }) => console.log(details.type)) // ---------------------------------------------------------------- dialog // await context.ui.dialog.alert({ title: "Acme", message: "Ready" }) // const ok = await context.ui.dialog.confirm({ title: "Continue?", message: "Run it?" }) // const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" }) // const mode = await context.ui.dialog.select({ // title: "Mode", // current: "safe", // options: [ // { title: "Safe", value: "safe", description: "Ask before changes" }, // { title: "Fast", value: "fast", category: "Advanced" }, // ], // // `search` owns matching and order. It runs for EVERY query, including // // the empty one, and receives only enabled options. // search: (query, options) => // options // .filter((o) => o.title.toLowerCase().includes(query.toLowerCase())) // .toSorted((l, r) => r.title.localeCompare(l.title)), // // Actions appear in the footer. They do NOT close the dialog or settle // // the promise. Never bind ctrl+n / ctrl+p — the dialog reserves them. // actions: [ // { bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) }, // { bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() }, // ], // }) // ---------------------------------------------------------------- slots // Placement: prepend | append | before | after | replace. // Use "home.footer.status" to ADD to the built-in footer without replacing // it. Status contributions appear after health indicators, before the version. // The page's own snippet RETURNS context.ui.slot(...) from setup, but never // says the return value is a teardown (unlike router.register, where it // does). Treat removeSlot() below as an assumption to verify. const removeSlot = context.ui.slot({ append: "sidebar.content", render: ({ sessionID }) => {context.data.session.get(sessionID)?.title}, }) // ---------------------------------------------------------------- storage // store: persists across restarts, syncs across TUI instances. // memory: survives plugin reloads, discarded when the TUI exits. const [settings, updateSettings] = context.storage.store("settings", { initial: { compact: false }, }) await updateSettings((draft) => { draft.compact = true }) // -------------------------------------------------------------- attention // context.attention.notify({ // title: "OpenCode", // message: "Session done", // notification: { when: "blurred" }, // sound: { name: "done", volume: 0.5, when: "always" }, // }) // ----------------------------------------------------------------- model // current() and variant.list() are reactive in a Solid computation. // variant.set returns false when no model is selected or it has no such // variant. set(undefined) returns to the model default. // const selected = context.ui.model.current() // context.ui.model.variant.set("high") // ------------------------------------------------------------------ tabs // if (context.ui.tabs.enabled()) { // context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged // context.ui.tabs.focus(sessionID) // opens if needed // const tabs = context.ui.tabs.list() // context.ui.tabs.move(backgroundSessionID, tabs.length - 1) // context.ui.tabs.close(sessionID) // } // ------------------------------------------------------------- formatting // const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project") // Return a cleanup function for resources the plugin owns. Separately, a // KEYMAP LAYER is also removed when the plugin is disabled, reloaded, or // fails setup, or when the component/reactive computation that created it // (a slot, route, or dialog) is disposed. Disabling a plugin removes slot // contributions through normal slot cleanup. return () => { stopPermission() stopAll() removeSlot() void settings void location } }, })