mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-07 16:17:25 +03:00
23 KiB
23 KiB
Built-in GUI plugins
Built-in features of the desktop and web app, each behind the GUI plugin SDK, @opencode/plugin/gui in packages/plugin/src/gui/. The window host lives in packages/app/src/runtime/plugin/, the main-process host in packages/desktop/src/main/plugin/. README.md is the author guide: it explains every rule below, with diagrams and source-checked code from the shipping plugins, and walks through src/pairing/. These are the rules.
Structure
- The SDK and the contracts
file,review,browserandupdaterlive inpackages/plugin/src/gui/; all butupdaterpublish with@opencode/pluginas@opencode/plugin/gui*. Built-ins, hosts and third-party plugin bundles all import them by that name; this package holds only the built-ins. Three subpaths are workspace-only and never published:gui/host(the hosts' protocol),gui/builtin(what only built-ins use) andgui/updater(the app's own updater contract). - Keep the public SDK for third-party plugins. API only built-ins use goes in
@opencode/plugin/gui/builtin(packages/plugin/src/gui/builtin.ts), which no public SDK module imports: importonIdle,BuiltinStore,BuiltinPanel,BuiltinPanelTab,BuiltinCommand,BuiltinSlot,builtinContext,builtinMainContextandbuiltinCatalogfrom there. Each built-in registry token has the public token's id; the hosts read every item through it. - Built-ins declare with
Plugin.definelike any plugin, as TUI built-ins do; "built-in" is only the host's flag for whatsrc/renderer.tsandsrc/main.tsload. A third-party./gui-experimentalbundle default-exports onePlugin.define({ setup?, main?, ...declarations }). No definition declares an id: the host gives a built-in its key incomposeand a bundle the server plugin's id, asctx.id. Main alone lists the plugins on the app's own server and sends windows their bundles; both hosts evaluate bundles only throughevaluatePluginBundlein@opencode/plugin/gui/host, which owns the shape checks. - Every declaration field is declared once, in
Plugin.Declarations(packages/plugin/src/gui/declarations.ts).Plugin.Definitionadds the setups; the host'sEntry(@opencode/plugin/gui/host) adds the lazywindowEntryandmainEntry. Every OS check goes throughrunsOn. A plugin from the config whoseosleaves out this system never runs, and Extensions › Plugins keeps its row with "Not available on this system." - One folder per built-in:
index.ts(Plugin.define({ os?, stores?, i18n })),contract.ts,renderer.tsx, an optionalmain.ts, andi18n/<locale>.ts. src/renderer.tsandsrc/main.tsare the only files that list the built-ins, each throughcomposefrom@opencode/plugin/gui/host, which attaches the lazywindowEntryandmainEntry. Main never imports window code.- Every plugin here ships. Do not add a plugin, fixture or wrapper only to document or demonstrate the SDK; the guide shows shipping code.
- Another plugin may import only your
contract.ts(tokens and schemas, no runtime code). Contracts are not declared: provide your own withctx.provide(contracts from the window, Ipcs from main, each with an id under your plugin's id), call your own main entry withctx.main(Ipc), and follow another plugin's token withctx.use(token). Every built-in keeps working while any other is not active. To avoid loading an Ipc's schemas at startup, import its token only in the chunk that calls it. - A contract that returns components binds each with
bindPluginin the provider's setup, sousePlugininside reads the provider's context, not the consumer's. Annotate the bound component's props (bindPlugin((props: FileTreeProps) => ...)): TypeScript cannot infer them insidectx.provide. Do not wrap data methods such asChanges.diffs. - SDK icon fields take an
IconName: the names of@opencode/ui/icons/catalog, which the SDK carries in a generated file (bun run generateinpackages/plugin);src/icon-name.typecheck.tsfails when they drift. - The APIs the host always provides are context properties (
ctx.layout,ctx.sessions,ctx.storage,ctx.desktop, …; in mainctx.storage,ctx.windows,ctx.serverEndpoints, …).usePlugin<typeof definition>()returns the same context in components. Registries and contracts stay tokens:ctx.add(Registry, item),ctx.provide(Contract, impl); main provides Ipcs withctx.provide(Ipc, impl). - Fonts follow the Fonts section of
packages/app/AGENTS.md. Terminal-rendered output usesctx.appearance.font("mono")(the terminal font); other UI uses the code and UI font CSS variables. Never bundle font files or write literal families, except for documents that carry their own fonts (Office, font previews). - Never import
@opencode/app,@opencode/desktop, or@/paths. Import CSS with?inlineand contribute it throughctx.add(Style, css). No module-level state: keep state insidesetup.bun run lintenforces these rules. - Never call
crypto.randomUUID()directly in window code; useuuid()from@opencode/util/uuidso plain HTTP LAN origins keep working (see the Insecure Origins section ofpackages/app/AGENTS.md). bun run lint:changedmust pass before you finish: every file you add or edit has no oxlint problem at all, including the warn-level anti-slop rules (unknownparameters and returns, unchecked type assertions, widened types, unsafe dictionaries, missing spacing). Touching a file means leaving the whole file clean, older warnings included. Fix the code; suppress only with aSAFETY:comment that states a real checked invariant.
SDK docs
- Every exported declaration in
packages/plugin/src/gui/, every interface member, every field of an object type (options, results, union members) and every parameter has TSDoc. Say what it is, when to use it, its default, and what happens on failure. Document each value of a union. Primitives, token kinds, registries and context properties carry an@example. bun run lintrunsscript/sdk-docs.ts, which fails on a missing doc comment or@param, a@paramthat names no parameter, actx.<member>in the guide or this file that no context declares, and a guide code block marked<!-- source: path -->(a whole file) or<!-- source: path#name -->(the declaration, object property or call namedname, with the comments directly above it) that differs from that shipping source.- When you change the SDK, change its TSDoc and the guide in the same change. When the check names a guide block your change made stale, copy the new source into the guide; do not reword the source to fit the guide.
Host boundary
- Host code must not name a plugin or know its internals: no plugin ids, command ids, DOM selectors, or stored key formats. When the host needs something from plugins, add a generic, documented field to the SDK (for example
PanelTab.file,Command.featured,Command.section). - A plugin owns its settings: declare a store and render the toggle from your
SettingsPage, as a page, a section on a host page (page), or rows in a host section (section). The host exposes no preferences of its own. - Accepted exceptions: the built-in lists, the keybind rename map in
packages/app/src/settings/keybinds/migration.ts, the legacytype: "browser"comment decode inpackages/app/src/composer/schema.ts(drafts and message metadata written before plugin notes), and the crash page's use of the updater contract.
Lifetimes
An instance lives from setup until it is disabled, reloaded, removed, or its window closes. A reload can land at any await, so code that outlives a tick must prove it still belongs to the live instance.
- Everything registered through
ctx(contributions, contracts, Ipc listeners, dialogs, embeds) is withdrawn with the current owner (a component, acreateKeyedrun), else with the instance.ctx.dialogs.openreturns a handle whoseclose()closes that dialog alone. Anything else you start (timers, DOM listeners, subscriptions) needs teardown: in the window, Solid'sonCleanup(setup runs under the plugin's root owner); in main,ctx.onCleanup, which runs at once when the instance is already gone.setupmay instead return a cleanup, as server and TUI plugins do; windowsetupstays synchronous. - Window
setupis synchronous: an async one fails to compile, and the host fails it at runtime. Put async work increateKeyedorcreateLatest, withctx.signalor a signal derived from it. Only mainsetupmay be async. After everyawait, return ifctx.signalaborted before touching state or contributing; there is no owner after anawait, so window teardown that starts later listens toctx.signal. Pass the signal to Ipc calls and long work. Methods without an input schema take options only, e.g.pairing.screenActive({ signal }). - Never keep a value from a shorter lifetime in a longer one. Read a server's
client,dataandurlfrom its liveServerRefeach time: a restarted or re-authenticated server gets a new controller under the same id. Keep per-session state in a session-scoped store, or in a map keyed bysession.keythat you prune. - Each routed session gets its own frozen
MountedSession, and a new one when it moves to another directory (samekeyandvisit, newdirectory). A render receives the next one throughprops.session(panels),input.session(slots) orstate.session(tab labels) without remounting. Read those getters where you use them; never copy the object into a variable or key a cache by it, which would rebuild on every switch. AMountedSessionhas no actions that follow the route: the workspace files, comments and composer belong to the session screen, one object while it routes A, B and A again. Panel callbacks take one named input with a non-null, constantscreen; everysession.*slot gets it too, butwindow.bottomdoes not. Rendertabandsessionfields are live getters; lifecycle callbacks keep their event's tab and call metadata. Key per-screen caches by the screen. Commands and link handlers readctx.screen.current()where they act, never once in setup. It is defined exactly whenctx.sessions.current()is: from just after the screen's first render until the route leaves it, so never on Home or a draft. Renders and render effects use their inputs, not these accessors; pass the owning screen on, asChanges.watch(screen, source)takes it. - Main:
builtinMainContext(ctx).lifecycle.restart(handoff)disposes every other plugin and keeps the caller active until the handoff settles; when it rejects, return to a state the user can retry from.
Failure is part of the contract
ctx.use(token)is aLiveaccessor:pendingwhile the provider loads or restarts,inactivewhile it is disabled or failed. Every Ipc isinactiveon the web, where everyctx.maincall rejects. Every user action branches on it and still answers: offer the action only while it can answer (register it in acreateKeyedrun), or show an unavailable state (as WSL's "WSL unavailable" does). Never a silentreturnor an endless spinner.- An Ipc that goes away is a suspension, not a failure: keep what the user had (for example the browser's tab inventory) and resume when it returns, without a retry timer.
- An Ipc call can reject while the event that explains it is still in flight; events and call replies travel on different channels. When main reports an outcome as an event, let the event decide, and treat a rejection as final only for errors main throws before any event (validation, missing endpoint).
- The host orders Ipc state for you: an event always wins over an older snapshot. Do not re-fetch state to fix ordering.
Stored state
- Desktop storage loads over IPC; web storage is synchronous, so a read before load passes every web e2e test and still breaks desktop. Declare stores (
Store.global,Store.session): a global store is loaded before setup, a session store'svalueis undefined until it loads, andupdatewaits for the load.Storage.store, for keys only known at runtime, returns the samePersisted; derive nothing from it, such as a request, beforevalueis defined. - Main-process state goes through
ctx.storage.store, read synchronously and always loaded. - Stored keys live in your plugin id's namespace (
plugin.<id>.<key>in the window, namespaceplugin.<id>in main). The host imports each built-in store once from its earlierextension.key by itself; do not add that key tofrom. Move values throughfromwith older homes newest first. To import a part of an app key, declare the store withBuiltinStoreand a{ key, sessions?, pick }home (sessionsfor one session's slice). Apickreturns undefined when the old key holds none of its fields, never an object of undefined fields. Main's older homes are{ settings: key, file? }or{ state: [namespace, key] }, throughbuiltinMainContext(ctx).storage. Keep old fields readable until every user has migrated; never drop user data. - When you rename a plugin that has shipped, migrate its stored keys with
from, its command ids in the keybind rename map, and its panel keys withBuiltinPanel.legacy. Its enable setting starts again under the new id. Do not add a migration for an id that never shipped in a release. updatemutates a deep-mutable draft and returns nothing orundefined; a returned replacement fails to compile and throws at runtime in both processes.setreplaces the value. Plain readonlySchema.Structfields work withoutmutableKey. Windowupdateandsetwait for load and apply in call order with each other, including runtime-key stores; main writes reach disk at once. Do not batch main writes yourself.Storage.remove(key, { from })takes the store'sfrom, so the older key is never imported again and the key reads asinitial.
Panels and layout
Panel.focus(input):input.restoredis true only for the selection the side region mounts with (for example after a reload). Run side effects that express user intent, such as switching the file tree's tab, only when it is false. All panel callbacks take one named input; read its getters and do not destructure.- The host selects the first tab with
fallback: truein regular,first(BuiltinPanelTab.first), then pinned tier order while the stored selection is missing or cannot be selected. False or omission opts out. If you will restore a tab, keep listing its stored id, withhidden: truewhile its content is not known yet, so the fallback never runs. - Map keys stored before plugins, or under your plugin's earlier id, with
BuiltinPanel.legacy. Atransientpanel's stored keys are dropped once it stops listing them. - A
PanelTab.transienttab is a launcher (e.g. "Open file"): the next preview replaces it, and narrow screens neither store nor select it.closableonly styles the close button; set both when you want both. Layout.stored(session)returns your stored tab ids. Layout reads return nothing whilesession.locationis undefined (for example after a server re-authenticates); writes made meanwhile wait and apply in order once it is known.- No host API throws before the app interface mounts, as during setup: reads return their documented defaults (
Layout.ready()is false,stateis "closed"), and writes, a server or session store's included, wait and apply in call order once it mounts. A dialog opened meanwhile waits too and shows after the interface's first render; its handle closes it before then. Layout.sidebar.opened()is the inner sidebar preference side panels share, readable outside a panel render (for example in a tab's fields); do not mirrorusePanel().sidebarinto a store.- Narrow screens: a plain open switches to the panel's mobile view. Pass
backgroundwhen the user stays where they are (a palette pick, a composer chip) or the agent opened the tab, andtab: "select"to append and select without replacing the preview.
Solid
Read You Might Not Need an Effect and Solid.js Best Practices before writing reactive code here. In short:
bun run lintbans importingcreateEffect,createRenderEffectandcreateComputedinpackages/gui-plugins/src(the SDK inpackages/plugin/src/gui/wraps them). Run side work per provider generation or value withcreateKeyed(source, fn, { otherwise }), fetch withcreateLatest, and keep per-visit state withcreateVisitState.- Derive, don't sync. A value computed from other state is a
createMemoor a plain function, never an effect that calls a setter. Never mirror state into a second store, signal, orMapthrough an effect. - An effect (
createKeyed) synchronizes with something outside Solid: the DOM, a third-party widget, an embed, an Ipc subscription, a chunk preload. Comment what it syncs with when that isn't obvious. A source that builds an object is a new key on every read; passequalsto compare the fields that should run it again. - Logic caused by a user action belongs in that action's handler, not in an effect that watches the state the action changed. If several handlers share it, call one function from each.
- Reset state on an identity change by keying the subtree (
<Show keyed>) or by storing an id and deriving the selection from it. To forget a selection when the user navigates away and back, usecreateVisitState. Never reset state in an effect. - No effect chains, and no effect that notifies a parent: update everything in the same handler or
batch. - Don't destructure props; read
props.xso the getter stays reactive (splitPropswhen you must). Call signals when passing them as JSX props. Use<Show>and<For>, not&&and.map, in JSX. - Use
createStorefor objects and collections,createSignalfor single values. - Async data goes through
createLatest, a resource, or a guarded promise that ignores stale results, under the Suspense rules below, never an effect that fetches and then sets. - Lifecycles follow the State Machines section of
packages/app/AGENTS.md: one union, a puretransition, selectors for the UI, and a driver whose results carry attempt ids. Keep the machine state in the plugin's store, not in module state. Precedents:src/terminal/machine.ts,src/wsl/machine.ts,src/updater/machine.ts.
Performance
renderer.tsxloads with the app:src/renderer.tsimports every built-in entry eagerly, because the window renders once all of them are active. Keep it small and put heavy UI behindlazy(). Preload a chunk withonIdle(() => void Chunk.preload())(from@opencode/plugin/gui/builtin) from setup, so it compiles while the app idles (e.g. on Home) instead of when a session first opens. Never import a heavy chunk on the startup path.- A
<Suspense>boundary decides what blanks when something beneath it suspends; it does not stop the suspension. Suspend at most once, on first load, and never after first paint:- Read async data through
createLatest, a store with an explicitloadingflag, a resource's.latestguarded onstate === "ready" || state === "refreshing"(unguarded,.latestsuspends until the first value; see the Suspense section ofpackages/app/AGENTS.md), or a resource that resolves once (packages/app/src/runtime/server/runtime.tsx,packages/app/src/providers/connect/controller.ts). Never read a refetchingresource()in render: a refetch blanks the nearest boundary, and without one of yours that is the whole session route, which detaches the screen and resets the timeline scroll. - Wrap each
lazy()component in its own<Suspense>, so a chunk that is still loading blanks only your area. Preload it withonIdle. If the area can be visible at startup (for example a side tab restored open), start the preload insetupfrom the stored state instead, so the first render never enters Suspense and nothing pops in. - Give a fallback the area's size when the area has fixed geometry, so nothing shifts; leave it empty otherwise.
- Make a state change that may suspend inside
startTransition, so the current content stays until the next is ready.
- Read async data through
- Return stable objects from
Panel.listand reactive contributions, so the host never remounts a trigger or a panel. - Never add work to timeline rows. The session header slot is the only timeline surface, besides
CodeBlockandCodeBlockActioncontent: the host renders it only for a complete fenced block of a language a plugin lists, and blocks of other languages cost a set lookup.
Localization
- Each plugin owns its copy in
i18n/en.tswith short keys.ctx.tfalls back to the app's keys only for shared vocabulary such ascommon.*; feature copy belongs to the plugin. Count-sensitive copy goes throughctx.plural. - A plugin bundle's catalog holds every locale inline. A built-in loads its other locales lazily: wrap the catalog in
builtinCatalog({ en, de: () => import("./i18n/de") })from@opencode/plugin/gui/builtin. - Labels follow the language only when the host reads them again. Contribute items with labels in the function form,
ctx.add(Command, () => ({ id, title: ctx.t("open"), run })); a plaintitle: ctx.t("open")in a fixed item never updates. An item with a field of a literal type (hidden: true, asection, anicon, a menu name) needs a return annotation,(): Command => ({ ... }). Objects the host keeps stable keep getters (get title() { return ctx.t("title") }): the tabsPanel.listreturns, and items with arender, such as panels and settings pages. - When moving copy, keep the English byte-for-byte and carry every locale's translation. The localization rules in
packages/app/AGENTS.mdapply.
Tests
- Follow the Tests section of
packages/app/AGENTS.md. - Unit-test pure logic that carries a contract: path and security checks, storage migration, protocol parsing.
- UI behavior is proven by the app e2e keeper suites in
packages/app/e2e/regression/. Do not add unit tests that repeat them. - Test a main-process entry through its
Ipccontract with real inputs, not through Electron mocks. - The hosts own their contracts, so a plugin does not test them again: main storage in
packages/desktop/src/main/plugin/storage.test.ts, the real window host inpackages/app/component-tests/plugin-host.spec.ts, and the built-in graph inpackages/app/component-tests/plugin-graph.spec.ts. - For lifetime and ordering contracts, drive the race the user hits: a reload during async setup, a rejection that beats its event, a store that loads late (the e2e fixtures can hold desktop storage reads), an Ipc that goes away and returns.
- The real window host runs only in the component tests (
packages/app/component-tests/plugin-*.spec.ts, withplugin-host.fixture.tsx); they include the graph matrix, which boots the built-ins with each one disabled in turn. Run them withPLAYWRIGHT_STORIES_PORT=3294 bunx playwright test --config playwright.components.config.ts plugin-frompackages/app.