Repository navigation
feat(plugin): add OpenCode Desktop GUI plugins - #55246
Conversation
There was a problem hiding this comment.
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, queryid, returnsPlugin.GuiSource { source, revision }), the optionalPlugin.Features.guiflag, the./guipackage export / localgui.jsresolved byHost.resolve(Entrypoints.gui), and thePlugin.IDschema identifier in OpenAPI. Generated clients getplugin.gui. All of these are additive. - Removed: the desktop-only
.ocdxarchive install, remove and source RPCs, and theoc://extensionsprotocol. These never shipped. - Changed:
@opencode/gui-extensionsbecomes publishable with its SDK and contract entries, and a bundle's main setup moves from thedefaultexport tomain. - Packaged builds: the extension manager (enable/disable/reload) no longer refuses requests in packaged builds.
Concerns
revisionis the rawJSON.stringify([operation, version]), so it carries the plugin's configoptionsverbatim. I confirmed anapiKeycomes 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).- Main replaces its plugin map wholesale with whichever window reported last (inline).
Related: #43132
de43696 to
c1f1fd3
Compare
|
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 ( Proposed — relative to blocks that already exist, no new strip keys (all names proposed):
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
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. |
|
Use case from a server plugin that would like to ship a small 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 ( Two questions, in case they help shape the first cut:
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 |
|
Hi all We're not ready to launch this all the way, yet. but the generalised idea seems pretty stable. Seems you both want more slots? |
|
Yes. Slots inside existing components, not just around them. Looking forward to using it. |
|
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! |
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
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.
Plugins can now add UI to OpenCode Desktop. A plugin package ships one more export,
./gui-experimental, beside its server and./tuientries, and the app's own features are built-in plugins on the same SDK. Settings > Plugins lists both.Authoring
One package, one
@opencode/plugindependency, 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:
{ id, server }/{ id, tui }Plugin.define({ setup, main })ctx.options(window and main)context.clientctx.clientctx.provide(token, impl)accepts only tokens under the plugin's own id.ctx.use(token)follows another plugin's contract asLive(pending/active/inactive).ctx.main(ipc)calls the plugin's ownmain.ComposerPill, a pill above a session's composer.effect/*,solid-js/*or@opencode/*import. Each process gets inert placeholders for the other's modules.opencodenamespace.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 setupvite build --watchchanges only the GUI revision, so the GUI reloads in every window and in main without restarting the server part.Changes
Host.resolvefinds./gui-experimental(.cjs/.jsonly). A local directory with apackage.jsonnameandexportsresolves every part through them; otherwise it uses root file names.opencode.json. Its id is the package name, or the directory name for a local directory without aname.GET /api/plugin/gui,Plugin.Features.guiandPlugin.GuiBundle { code, revision, options }.packages/plugin/src/gui):@opencode/plugin/gui,/gui/main,/gui/{file,review,browser},/gui/vite./gui/host,/gui/builtin,/gui/updater. The sharedpack()exports them asnull(workspaceOnly).Build/ctx.buildis nowApp/ctx.app. Main's server endpoints usesameMachine(the app's own server); the never-setusernameis gone.packages/gui-extensionsis nowpackages/gui-plugins. Its 13 built-ins usePlugin.defineand take their id fromcompose({ id: definition }). APIs only built-ins need live in@opencode/plugin/gui/builtin.mainsetups withctx.options, a sidecarctx.clientand the same context as built-ins..ocdxarchive installer, its RPCs and theoc://extensionsprotocol are gone.Stored data
extension.<id>.<key>plugin.<id>.<key>extension.<id>plugin.<id>extensiontableplugintable, read-onlyextensionviewextension_filetablesummary.toggle→details.toggle, migrate.Known gaps
fooandfoo.barcan clash.extension.*copy in main but not in windows.Visual
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)