Skip to content

feat(plugin): add OpenCode Desktop GUI plugins - #55246

Merged
Hona merged 26 commits into
anomalyco:devfrom
Hona:curious-planet
Oct 27, 2026
Merged

Hona merged 26 commits into
anomalyco:devfrom
Hona:curious-planet

Conversation

@Hona

@Hona Hona commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Plugins can now add UI to OpenCode Desktop. A plugin package ships one more export, ./gui-experimental, beside its server and ./tui entries, and the app's own features are built-in plugins on the same SDK. Settings > Plugins lists both.

Authoring

One package, one @opencode/plugin dependency, any combination of parts:

{
  "exports": {
    ".": "./dist/server.js",                             // @opencode/plugin
    "./tui": "./src/tui.tsx",                            // @opencode/plugin/tui
    "./gui-experimental": "./dist/gui-experimental.cjs"  // @opencode/plugin/gui
  }
}

The GUI part is one prebuilt CommonJS file holding both processes:

import { Command, Plugin } from "@opencode/plugin/gui"

export default Plugin.define({
  i18n: { en: { open: "Open Acme", ping: "Ping Acme" } },
  // Every app window. No id: the host assigns the server part's (ctx.id).
  setup(ctx) {
    ctx.add(Command, () => ({ id: "open", title: ctx.t("open"), run: () => void ctx.client.rpc(AcmeRpc).open({}) }))
    ctx.add(Command, () => ({ id: "ping", title: ctx.t("ping"), run: () => void ctx.main(Acme).ping() }))
  },
  // Once, in the Electron main process.
  main(ctx) {
    ctx.provide(Acme, { ping: () => `pong for ${ctx.options.team}` })
  },
})
import { gui } from "@opencode/plugin/gui/vite"

export default defineConfig({ plugins: [gui()] })
Server / TUI part GUI part
Entry { id, server } / { id, tui } Plugin.define({ setup, main })
Id declared assigned: the server part's id, else the package name
Options plugin options ctx.options (window and main)
Server context.client ctx.client
Cleanup returned returned (window setup stays synchronous)
  • Contracts are runtime calls. ctx.provide(token, impl) accepts only tokens under the plugin's own id. ctx.use(token) follows another plugin's contract as Live (pending / active / inactive). ctx.main(ipc) calls the plugin's own main.
  • What a plugin can add: commands, panels, menus, settings pages, servers, link handlers, titlebar items, slots, and ComposerPill, a pill above a session's composer.
  • The Vite preset compiles Solid JSX and emits one CommonJS file. It keeps external exactly the modules the app provides, plus Electron and Node built-ins, and fails the build on any other effect/*, solid-js/* or @opencode/* import. Each process gets inert placeholders for the other's modules.
  • Reserved ids: built-in ids, ids nested under them, and the opencode namespace.

Load path

sequenceDiagram
  participant S as Desktop's own server
  participant M as Desktop main
  participant W as App windows
  S-->>M: plugin.updated
  M->>S: plugin.list() (features.gui)
  M->>S: GET /api/plugin/gui?id=acme (sidecar credential)
  S-->>M: { code, revision, options }
  M->>M: evaluate main (os filter, reserved ids)
  M->>W: PluginGuisChanged { plugins, reserved }
  W->>W: evaluate setup
Loading
  • Trust: GUI code comes only from the desktop's own server, which already runs the plugin from the user's config. Windows never list plugins or supply main-process code.
  • One list: main follows its server, evaluates each bundle once per revision, and sends every window the same set. A failed listing or read keeps what already runs.
  • Hot reload: vite build --watch changes only the GUI revision, so the GUI reloads in every window and in main without restarting the server part.
  • Isolation: the host checks each definition at the boundary, so a malformed or version-skewed bundle fails only its own plugin.
  • Web: the web app and remote SSH, WSL and HTTP servers load no third-party GUIs yet.

Changes

  • Server:
    • Host.resolve finds ./gui-experimental (.cjs/.js only). A local directory with a package.json name and exports resolves every part through them; otherwise it uses root file names.
    • A package with only a GUI part loads from opencode.json. Its id is the package name, or the directory name for a local directory without a name.
    • New GET /api/plugin/gui, Plugin.Features.gui and Plugin.GuiBundle { code, revision, options }.
  • SDK (packages/plugin/src/gui):
    • Public: @opencode/plugin/gui, /gui/main, /gui/{file,review,browser}, /gui/vite.
    • Workspace-only: /gui/host, /gui/builtin, /gui/updater. The shared pack() exports them as null (workspaceOnly).
    • Build/ctx.build is now App/ctx.app. Main's server endpoints use sameMachine (the app's own server); the never-set username is gone.
  • Built-ins: packages/gui-extensions is now packages/gui-plugins. Its 13 built-ins use Plugin.define and take their id from compose({ id: definition }). APIs only built-ins need live in @opencode/plugin/gui/builtin.
  • App and desktop:
    • Settings > Plugins replaces the dev-only Extensions page. Built-ins show a Built-in label and always run. Config plugins show their other parts (Server, TUI), and their switch turns off only the desktop UI.
    • Main runs plugin main setups with ctx.options, a sidecar ctx.client and the same context as built-ins.
    • The unshipped .ocdx archive installer, its RPCs and the oc://extensions protocol are gone.
  • Docs: one Build > Plugins > GUI page, a Server page for the existing server plugin docs, and a short Desktop section on Plugins.

Settings > Plugins listing each part of a plugin and its switch

Stored data

Data Before After
Window stores extension.<id>.<key> plugin.<id>.<key>
Main storage extension.<id> plugin.<id>
Enable state extension table plugin table, read-only extension view
Archive files extension_file table dropped
  • Each store copies its pre-rename value once and leaves the old key, so an older build still reads it.
  • Keybinds of renamed commands, such as summary.toggle → details.toggle, migrate.

Known gaps

  • A downgraded app (2.0.24 to 2.0.32) shows plugin switches through the read-only view but cannot change them.
  • A disabled plugin's bundle still evaluates its top-level code on each new revision; only its setups stay off.
  • Config plugin ids are checked only against built-ins, so foo and foo.bar can clash.
  • A plugin whose server part fails ships no GUI, so its GUI and Settings row disappear until it loads.
  • Main reads every bundle again on each plugin change.
  • Clearing a built-in's stored value removes its extension.* copy in main but not in windows.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-linecap="round">
  <rect x="4.5" y="4.5" width="231" height="63" rx="5" opacity=".5"/>
  <line x1="5" y1="15.5" x2="235" y2="15.5" opacity=".3"/>
  <g fill="currentColor" stroke="none" opacity=".35"><circle cx="11" cy="10" r="1.5"/><circle cx="16" cy="10" r="1.5"/><circle cx="21" cy="10" r="1.5"/></g>
  <g opacity=".3"><line x1="12" y1="24" x2="40" y2="24"/><line x1="12" y1="31" x2="34" y2="31"/><line x1="12" y1="38" x2="38" y2="38"/></g>
  <line x1="48.5" y1="16" x2="48.5" y2="67" opacity=".2"/>
  <g opacity=".3"><line x1="58" y1="24" x2="140" y2="24"/><line x1="58" y1="31" x2="120" y2="31"/></g>
  <rect x="56.5" y="52.5" width="100" height="9" rx="3" opacity=".45"/>
  <g>
    <rect x="56.5" y="42.5" width="30" height="6" rx="3" fill="currentColor" fill-opacity=".15"/>
    <animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;.3;.45;.9;1" dur="4s" repeatCount="indefinite"/>
  </g>
  <g>
    <rect x="166.5" y="20.5" width="62" height="41" rx="3" fill="currentColor" fill-opacity=".08"/>
    <g opacity=".5"><line x1="173" y1="28" x2="212" y2="28"/><line x1="173" y1="35" x2="221" y2="35"/><line x1="173" y1="42" x2="203" y2="42"/></g>
    <animateTransform attributeName="transform" type="translate" values="72 0;72 0;0 0;0 0;72 0" keyTimes="0;.1;.3;.9;1" dur="4s" repeatCount="indefinite"/>
    <animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;.1;.3;.9;1" dur="4s" repeatCount="indefinite"/>
  </g>
</svg>

Added by the opencode bot (AI-generated).

Related: #43132

Public API: adds GET /api/plugin/gui, Plugin.Features.gui, the ./gui-experimental export and @opencode/plugin/gui* (runtime ctx.provide/use/main contracts, ComposerPill registry); GUI-only packages now load without a server part, and a local plugin's source.path becomes its directory (mostly compatible)

@Hona
Hona requested a review from Brendonovich as a code owner October 13, 2026 22:10
Copilot AI balanced review requested due to automatic review settings October 13, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sound overall. On the server side, the ./gui path works: I ran serve from source with a local plugin directory, and plugin.list reports features.gui. GET /api/plugin/gui returns the bundle and its revision, and an unknown id returns a 400. Tests in core, plugin, desktop and app pass, and typecheck is clean in every changed package. I couldn't exercise the desktop or window side (Electron isn't available here).

Public API

  • Added: GET /api/plugin/gui (plugin.gui, query id, returns Plugin.GuiSource { source, revision }), the optional Plugin.Features.gui flag, the ./gui package export / local gui.js resolved by Host.resolve (Entrypoints.gui), and the Plugin.ID schema identifier in OpenAPI. Generated clients get plugin.gui. All of these are additive.
  • Removed: the desktop-only .ocdx archive install, remove and source RPCs, and the oc://extensions protocol. These never shipped.
  • Changed: @opencode/gui-extensions becomes publishable with its SDK and contract entries, and a bundle's main setup moves from the default export to main.
  • Packaged builds: the extension manager (enable/disable/reload) no longer refuses requests in packaged builds.

Concerns

  1. revision is the raw JSON.stringify([operation, version]), so it carries the plugin's config options verbatim. I confirmed an apiKey comes back in plain text from /api/plugin/gui. Window code then sends that string to main over an IPC schema capped at 16,384 characters (inline).
  2. Main replaces its plugin map wholesale with whichever window reported last (inline).

Related: #43132

Comment thread packages/core/src/plugin.ts Outdated
Comment thread packages/desktop/src/main/extension/host.ts Outdated
@Hona Hona changed the title feat(desktop): ship GUI extensions through plugin ./gui exports feat(desktop): ship GUI plugins through ./gui exports Oct 13, 2026
@Hona
Hona force-pushed the curious-planet branch 3 times, most recently from de43696 to c1f1fd3 Compare October 14, 2026 03:32
@esafak

esafak commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Nice work, but it is too big to review manually. Personally I would keep this one for reference and carve it up. Speaking about reference, I could not access the doc.

As I was poking around, I thought of some plugins I would actually like to make, as I find application-oriented thinking grounds design. Suppose we want to put some CI affordances like CI status and a PR button inside the Composer, at the top or bottom. In order to support this I propose to add named slots inside components, like the composer.

Composer has no extension point, while every neighboring chrome does. Titlebar (TitlebarItem), panel strip (session.panel.end / session.panel.sidebar + Panel), menus (MenuItem), settings (SettingsPage), servers (Server) all have registries. The composer footer in ComposerEditor (AddMenu + agent/model/variant controls + alternate-delivery / exit-shell / submit) exposes only data-slot DOM hooks, no SDK token, and the session composer region mounts no ExtensionSlot. SlotMap stays at its shipped keys with no composer key. window.bottom works today but is global chrome below everything, not “above the input”.

Proposed — relative to blocks that already exist, no new strip keys (all names proposed):

  • rows: ComposerStrip{anchor: "region"|"attachments"|"editor"|"footer", order} — sign picks side (negative before, positive after); host merges the shared gaps (attachments+/editor- are one gap) and sorts each gap by order. E.g. CI line is {anchor:"region", order:-10} (outside, above the box).
  • pills: ComposerAction{box: "controls"|"actions", order, after?} — after-+, between pickers, or leading in the fixed action box with submit pinned trailing.
flowchart LR
  subgraph Form["composer form"]
    direction TB
    Att["attachments"] --> Edit["editor"] --> Footer["footer"]
  end
  subgraph FooterBoxes["footer boxes"]
    direction LR
    Add["+ AddMenu"] --> Controls["controls"] --> Actions["actions"]
  end
  Form --> FooterBoxes
  style Att fill:#000,color:#fff
  style Edit fill:#000,color:#fff
  style Footer fill:#000,color:#fff
  style Controls fill:#000,color:#fff
  style Actions fill:#000,color:#fff
Loading

You can, of course, generalize this to other components. Feel free to contact me on Discord; I'm happy to be a 'design partner' or a devil's advocate.

@olobarius

Copy link
Copy Markdown

Use case from a server plugin that would like to ship a small ./gui part on top of this PR.

We maintain a security plugin (server entry only today) that replaces secrets and passwords with placeholders before a request leaves for a cloud provider, optionally double-checks texts with a local LLM, and enforces routing rules for restricted data sources. It keeps per-session statistics: values replaced (by category, never the values), requests sent and blocked, local-LLM scans (calls, time, tokens, findings). Right now the only way to show them is a slash command whose output lands in the transcript.

The natural home for this in the desktop app is the existing Context panel (context:main, opened from the context ring in the session header), next to tokens and cost. In 2.0.32 that panel renders a fixed list (session, messages, provider, model, context limit, tokens, cost, timestamps, system prompt, raw messages). The slots I can see are window.bottom, session.header, session.panel.end and session.panel.sidebar, but none of them is inside that panel.

Two questions, in case they help shape the first cut:

  1. Could the Context panel expose a slot, for example session.context rendered with { session } below the built-in stats? A plugin could then add a short section there instead of opening its own side-panel tab. Usage and quota plugins (see the opencode-quota comment in [FEATURE]: Plugin UI surface for the web/desktop app, mirroring the TUI plugin API #43132) would fit the same slot.
  2. For session.header: will plugin contributions be ordered next to the built-in context ring (order: 10), so that a small status badge (an icon plus a count that opens the plugin's panel) is possible without crowding the header?

As I read the PR, the data path would be the plugin's own RPC: the server entry registers methods and an event, and the GUI part calls ctx.client.rpc(...) and subscribes to the event. That covers everything we need. Thanks for the work on this.

@Hona

Hona commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Hi all

We're not ready to launch this all the way, yet.
But I will add more Slots/Registries later
And also edit the doco.

but the generalised idea seems pretty stable.

Seems you both want more slots?
Which we can always add more later!
otherwise it seems solid

@esafak

esafak commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Yes. Slots inside existing components, not just around them. Looking forward to using it.

@Hona

Hona commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

yeah.

the first cut i kept pretty conservative slots.

but once i merge all this foundational work, i will probably take a pass at way more slots - using community feedback like urs!

Hona added 8 commits October 27, 2026 08:03
The story took the first session.panel item, which depends on which plugin finishes setup first; it now asks for the browser's item by plugin.
The main plugin storage tests closed the database while a state store still had a write-behind flush queued; it fired later against the closed database and failed whichever test ran then (Failed query: begin).
… only

Layout.command runs app commands by the app's own ids, which are not a plugin contract, and session.location.workspace replaces the app's own project menu row, which one contribution owns. Both move to @opencode/plugin/gui/builtin (BuiltinLayout, BuiltinSlot, BuiltinSlotMap); the host renders slots through BuiltinSlot. Panel.closed's comment now says what comes back where: the side region's reopen shortcut takes the newest closed tab, and Recently closed lists file tabs.
Main logs failed plugin listings and bundle reads, and a new plugin whose bundle cannot be read gets a Settings row with the error. An Ipc snapshot that fails to encode is logged instead of killing the Ipc, turning a plugin off clears its last error in both hosts, a connection lists plugins once, and bundles for another OS are not evaluated again. A missing shared module names the module. The built-in guard lives in the manager. SDK: signal lives on BaseContext, Desktop.window is gone, contract modules import their sources, and TSDoc and docs examples are corrected; the GUI page states the local folder resolution rule. Fixtures and comments match the current behavior.
…eMachine

ServerEndpoint.local meant the server runs on the app's machine, while the window's ServerRef.local means the app's own or a loopback server. The main one is now sameMachine.
A local folder or package with only a TUI part is the TUI's own again, as before GUI parts: the server lists a plugin with a server or GUI part, so opencode plugin list and Settings > Plugins do not show TUI-only folders, and they no longer clash with other plugins' ids there.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both commits look fine. A TUI-only package in opencode.json is again left to the TUI, as on dev, while GUI-only packages still load without a server part; the updated test covers both. ServerEndpoint.local is renamed sameMachine, and the type checks find nothing new. The core plugin, gui-plugins and desktop plugin tests pass. The packages/plugin/src/host.ts thread (malformed package.json) is still open.

Public API: the server no longer accepts TUI-only packages listed in opencode.json, matching dev. In the unshipped GUI main SDK, ServerEndpoint.local becomes ServerEndpoint.sameMachine.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rebase onto dev looks fine: apart from the rebase, only the first commit changed. The plugin's publish step now uses dev's shared pack() helper with workspaceOnly. I packed @opencode/plugin: ./gui/host, ./gui/builtin, ./gui/updater and ./gui/* map to null in the tarball, the host and built-in modules aren't shipped, and the public GUI entries point at the build output. The plugin, core plugin, desktop plugin, app plugin-runtime and gui-plugins tests pass. The packages/plugin/src/host.ts thread (malformed package.json) is still open. The public API is unchanged since the previous review.

Host.resolve parsed a local plugin directory's package.json with JSON.parse inside Effect.sync, so one malformed manifest threw out of the plugin listing and no plugin loaded, built-ins included. A manifest that is not a JSON object now counts as missing: that directory falls back to its root file names, and the rest load.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The malformed package.json point is fixed. A local plugin's package.json that isn't valid JSON is now treated as missing, so that plugin resolves from its root files and the rest still load. I ran a server from source with the config that broke before (a plugin with an invalid package.json plus a valid GUI plugin): the listing now has all the built-ins and both plugins, where it used to be empty. The new host test covers the case, and the plugin package type check is clean. The public API is unchanged since the previous review.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both commits look fine. The regenerated PluginGuiInput client type now lists location and id directly; the type is unchanged, and the client type check is clean. The Plugins page's Desktop section is shortened, and the remaining card's link resolves. The public API is unchanged.

Hona added 3 commits October 27, 2026 09:25
Renames the remaining extension names for plugin concepts: the new-session server kind, a host APIs variable, session tab placement, comments and test names. Drops the unused SettingsPageItem type, and leaves the location dialog lint-clean.
The plugin package's Electron dev dependency was 44.4.5 while desktop uses 44.9.1, so installs fetched a second Electron binary.
Fixes claims the code does not back: Ipc dispose leaves windows pending, the embed example misses open windows, SetupContext owns stores, TUI-only packages go in cli.json, and the local entry and ID rules. Updates stale paths and line references in AGENTS.md files and skills.
@Hona Hona changed the title feat(desktop): ship GUI plugins through ./gui exports feat(desktop): let plugins add panels, commands, and settings via ./gui-experimental Oct 26, 2026
@Hona Hona changed the title feat(desktop): let plugins add panels, commands, and settings via ./gui-experimental feat(plugin): add OpenCode Desktop GUI plugins Oct 26, 2026
Hona added 7 commits October 27, 2026 09:44
A reload or activation that cleared a plugin's last failure never told windows, so Settings kept showing Failed with the old error after a fixed bundle loaded, and ctx.main calls rejected as failed during the restart. Disabling a plugin now also tells windows before main's teardown, which can take up to its 3 s deadline.
Any enable or listing change reran the enable watcher, which activated plugins whose setup had failed, rerunning their side effects. A failed plugin now waits for Reload or a new revision, as main does.
Ipc is inactive on the web and its calls reject; sameMachine means the app's own server; config plugins run only in the desktop app; async main cleanup waits at most 3 s; and other claims the hosts do not back.
Windows never sent a user name and the app always authenticates as opencode, so ServerEndpoint.username and ServerEntry.http.username were always undefined. Remove them from the SDK, the IPC schema and main's endpoint resolution.
2.0.22 published the details toggle as summary.toggle, and the rename dropped its mapping, so a custom binding from that release was lost. Restore it, with a migration test row.
Built-ins no longer declare an id, so stories read undefined: plugin stylesheets never loaded and ctx.id was undefined. Use the folder name, which matches the compose key.
The IpcProvider and provide examples used the workspace-only Updater contract. Also correct the release range in the SQLite rename comments.
@Hona
Hona merged commit dae4077 into anomalyco:dev Oct 27, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants