* tweak: use theme tokens for debug bar surface * chore: update nix node_modules hashes * feat(tui): add heap snapshot functionality for TUI and server (#19028) * ci * change model for changelog * release: v1.3.2 * fix(opencode): skip typechecking generated models snapshot (#19018) * Revert "fix(app): more startup efficiency (#18985)" This reverts commit98b3340cee. * Revert "fix(app): startup efficiency (#18854)" This reverts commit546748a461. * effectify Worktree service (#18679) * fix: increase operations-per-run to 1000 and pin stale action to v10.2.0 The stale-issues workflow was hitting the default 30 operations limit, preventing it from processing all 2900+ issues/PRs. Increased to 1000 to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility. * Add close-issues script and GitHub Action - Create script/github/close-issues.ts to close stale issues after 60 days - Add GitHub Action workflow to run daily at 2 AM - Remove old stale-issues workflow to avoid conflicts * Fix close-issues workflow permissions - Add contents: read permission for checkout - Use github.token instead of secrets.GITHUB_TOKEN * Process issues sequentially to avoid rate limits * Change issue close reason from not_planned to completed * fix(opencode): avoid snapshotting files over 2MB (#19043) * fix: provide merge context to beta conflict resolver (#19055) * tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) * fix: beta resolver typecheck + build smoke check (#19060) * fix: unblock beta conflict recovery (#19068) * electron: add createDirectory to open directory picker (#19071) * electron: remove file extension from electron-store wrapper (#19082) * app: pre-warm project globalSync state when navigate project via keybind (#19088) * fix(app): move message navigation off cmd+arrow (#18728) * Reapply "fix(app): startup efficiency (#18854)" This reverts commita379eb3867. * Reapply "fix(app): more startup efficiency (#18985)" This reverts commitcbe1337f24. * fix(app): hash inline script for csp * Revert "fix(app): startup efficiency" * Reapply "fix(app): startup efficiency" This reverts commit898456a25c. * fix(app): opencode web server url * chore(app): markdown playground in storyboard * chore(app): markdown playground in storyboard * feat(core): initial implementation of syncing (#17814) * chore: generate * chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064) * chore: storybook tweaks * feat: restore git-backed review modes with effectful git service (#18900) * chore: generate * chore: update nix node_modules hashes * chore: cleanup * chore: remove dead code for todoread tool (#19128) * chore: storybook tweaks * fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> * fix(task): respect agent permission config for todowrite tool (#19125) * fix(app): agent normalization (#19169) * fix: Windows e2e stability (CrossSpawnSpawner, snapshot isolation, session race guards) (#19163) * fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042) * effectify Bus service: migrate to Effect PubSub + InstanceState (#18579) * file: use Effect.cached for scan deduplication (#19164) * ignore: update disavowed list (#19184) * skill: use Effect.cached for load deduplication (#19165) * chore: generate * fix: bump gitlab-ai-provider to 5.3.3 for DWS tool approval support (#19185) * test: restore 5 workers on Windows e2e (#19188) * fix(opencode): image paste on Windows Terminal 1.25+ with kitty keyboard (#17674) * chore: update nix node_modules hashes * wip: zen * wip: zen * go: do not respect disabled zen models * fix: ensure enterprise url is set properly during auth flow (#19212) * revert: roll back git-backed review modes (#19295) * chore: generate * tui: bypass local SSE event streaming in worker (#19183) * feat: embed WebUI in binary with proxy flags (#19299) Co-authored-by: BlankParticle <blankparticle@gmail.com> * release: v1.3.3 * chore: generate * changelog ci tweaks * refactor(lsp): effectify LSP service with InstanceState (#19150) * chore: generate * feat: add gpt prompt so non codex gpt models have their own system prompt modeled after codex cli (#19220) * feat(core): remove workspace server, WorkspaceContext, start work towards better routing (#19316) * effectify Config service (#19139) * chore: generate * refactor(config): use cachedInvalidateWithTTL, bump effect to beta.37 (#19322) * fix(mcp): close transport on failed/timed-out connections (#19200) * fix(app): more startup perf (#19288) * chore: generate * chore: update nix node_modules hashes * fix(app): don't bundle fonts (#19329) * chore: generate * fix(app): default shell tool to collapsed * fix(app): remove fork session button * fix(ui): reduce markdown jank while responses stream (#19304) * fix: web ui bundle build on windows (#19337) * refactor(effect): yield services instead of promise facades (#19325) * chore: generate * refactor(vcs): replace async git() with ChildProcessSpawner (#19361) * fix(opencode): ignore generated models snapshot files (#19362) * fix(ui): keep partial markdown readable while responses stream (#19403) * chore: update nix node_modules hashes * fix(app): persist queued followups across project switches (#19421) * refactor(tool-registry): yield Config/Plugin services, use Effect.forEach (#19363) * chore: generate * tui plugins (#19347) * chore: generate * effectify Skill service internals (#19364) * chore: update nix node_modules hashes * effectify Plugin service internals (#19365) * refactor(core): split out instance and route through workspaces (#19335) * chore(app): more spacing controls * fix(ui): make streamed markdown feel more continuous (#19404) * fix(app): resize layout viewport when mobile keyboard appears (#15841) * fix(desktop-electron): match dev dock icon inset on macOS (#19429) * fix(app): default file tree to closed with minimum width (#19426) * fix flaky plugin tests (no mock.module for bun) (#19445) * tweak: add additional overflow error patterns (#19446) * no theme override in dev (#19456) * feat: AI SDK v6 support (#18433) * refactor(session): effectify Session service (#19449) * refactor(core): move more responsibility to workspace routing (#19455) * chore: update nix node_modules hashes * refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) * chore: generate * Single target plugin entrypoints (#19467) * refactor(session): effectify SessionCompaction service (#19459) * feat(ci): use Azure Artifact Signing for Windows releases (#15201) * fix(app): more startup efficiency (#19454) * update effect to 4.0.0-beta.42 (#19484) * chore: update nix node_modules hashes * tweak: adjust bash tool description to increase cache hit rates between projects (#19487) * refactor(session): move context into prompt footer (#19486) * refactor(prompt): remove variant cycle display from footer (#19489) * feat: add model variant selection dialog (#19488) * fix: restore subagent footer and fix style guide violations (#19491) * tweak(session): add top spacing and remove obsolete docs prompt * upgrade opentui to 0.1.91 (#19440) * refactor(file): use AppFileSystem instead of raw Filesystem (#19458) * chore: generate * chore: update nix node_modules hashes * kv theme before default fallback (#19523) * feat: open dialog for model variant selection instead of cycling (#19534) * refactor(session): effectify session processor (#19485) * feat: dialog variant menu and subagent improvements (#19537) * use theme color for prompt placeholder (#19535) * fix: update opencode-gitlab-auth to 2.0.1 (#19552) * chore: update nix node_modules hashes * prompt slot (#19563) * fix: respect semver build identifiers for nix (#11915) * fix: nix embedded web-ui support (#19561) * ignore: kill todo (#19566) * chore: update nix node_modules hashes * wip: zen * wip: zen * zen: ZDR policy * ci: cancel stale nix-hashes runs (#19571) * release: v1.3.4 * tweak: adjust gpt prompt to be more minimal, fix file reference annoyances (#19585) * core: fix plugin hooks to properly handle async operations ensuring plugins can execute async code without errors (#19586) * release: v1.3.5 * refactor: kilo compat for v1.3.5 * fix: Fix typecheck issues * style(kilo-vscode): adjust formatting in parts-util and PopupSelector Reindent boolean expression in isCompletionResult to remove extra indentation, and reformat PopupSelectorProps generic interface extends clause across multiple lines. --------- Co-authored-by: Jay V <air@live.ca> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Brendan Allan <brendonovich@outlook.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: André Cruz <acruz@cloudflare.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: BlankParticle <blankparticle@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: Burak Yigit Kaya <byk@sentry.io> Co-authored-by: Caleb Norton <n0603919@outlook.com> Co-authored-by: Imanol Maiztegui <imanol.mzd@gmail.com>
16 KiB
TUI plugins
Technical reference for the current TUI plugin system.
Overview
- TUI plugin config lives in
tui.json. - Author package entrypoint is
@kilocode/plugin/tui. - Internal plugins load inside the CLI app the same way external TUI plugins do.
- Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export
serverortui, never both. - Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
TUI config
Example:
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": {
"acme.demo": false
}
}
pluginentries can be either a string spec or[spec, options].- Plugin specs can be npm specs,
file://URLs, relative paths, or absolute paths. - Relative path specs are resolved relative to the config file that declared them.
- A file module listed in
tui.jsonmust be a TUI module (default export { id?, tui }) and must not exportserver. - Duplicate npm plugins are deduped by package name; higher-precedence config wins.
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
plugin_enabledis keyed by plugin id, not by plugin spec.- For file plugins, that id must come from the plugin module's exported
id. For npm plugins, it is the exportedidor the package name ifidis omitted. - Plugins are enabled by default.
plugin_enabledis only for explicit overrides, usually to disable a plugin withfalse. plugin_enabledis merged across config layers.- Runtime enable/disable state is also stored in KV under
plugin_enabled; that KV state overrides config on startup.
Author package shape
Package entrypoint:
- Import types from
@kilocode/plugin/tui. @kilocode/pluginexports./tuiand declares optional peer deps on@opentui/coreand@opentui/solid.
Minimal module shape:
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule } from "@kilocode/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [
{
title: "Demo",
value: "demo.open",
onSelect: () => api.route.navigate("demo"),
},
])
api.route.register([
{
name: "demo",
render: () => (
<box>
<text>demo</text>
</box>
),
},
])
}
const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
export default plugin
- Loader only reads the module default export object. Named exports are ignored.
- TUI shape is
default export { id?, tui }; includingserveris rejected. - A single module cannot export both
serverandtui. tuisignature is(api, options, meta) => Promise<void>.- If package
exportscontains./tui, the loader resolves that entrypoint. Otherwise it uses the resolved package target. - If a package supports both server and TUI, use separate files and package
exports(./serverand./tui) so each target resolves to a target-only module. - File/path plugins must export a non-empty
id. - npm plugins may omit
id; packagenameis used. - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
- If a path spec points at a directory, that directory must have
package.jsonwithmain. - There is no directory auto-discovery for TUI plugins; they must be listed in
tui.json.
Package manifest and install
Package manifest is read from package.json field oc-plugin.
Example:
{
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/index.js",
"engines": {
"opencode": "^1.0.0"
},
"oc-plugin": [
["server", { "custom": true }],
["tui", { "compact": true }]
]
}
Version compatibility
npm plugins can declare a version compatibility range in package.json using the standard engines field:
{
"engines": {
"opencode": "^1.0.0"
}
}
-
The value is a semver range checked against the running OpenCode version.
-
If the range is not satisfied, the plugin is skipped with a warning and a session error.
-
If
engines.opencodeis absent, no check is performed (backward compatible). -
File plugins are never checked; only npm package plugins are validated.
-
Install flow is shared by CLI and TUI in
src/plugin/install.ts. -
Shared helpers are
installPlugin,readPluginManifest, andpatchPluginConfig. -
opencode plugin <module>and TUI install both run install → manifest read → config patch. -
Alias:
opencode plug <module>. -
-g/--globalwrites into the global config dir. -
Local installs resolve target dir inside
patchPluginConfig. -
For local scope, path is
<worktree>/.opencodeonly when VCS is git andworktree !== "/"; otherwise<directory>/.opencode. -
Root-worktree fallback (
worktree === "/"uses<directory>/.opencode) is covered by regression tests. -
patchPluginConfigapplies all declared manifest targets (serverand/ortui) in one call. -
patchPluginConfigreturns structured result unions (ok,code, fields by error kind) instead of custom thrown errors. -
Without
--force, an already-configured npm package name is a no-op. -
With
--force, replacement matches by package name. If the existing row is[spec, options], those tuple options are kept. -
Tuple targets in
oc-pluginprovide default options written into config. -
A package can target
server,tui, or both. -
If a package targets both, each target must still resolve to a separate target-only module. Do not export
{ server, tui }from one module. -
There is no uninstall, list, or update CLI command for external plugins.
-
Local file plugins are configured directly in
tui.json.
When plugin entries exist in a writable .opencode dir or KILO_CONFIG_DIR, OpenCode installs @kilocode/plugin into that dir and writes:
package.jsonbun.locknode_modules/.gitignore
That is what makes local config-scoped plugins able to import @kilocode/plugin/tui.
TUI plugin API
Top-level API groups exposed to tui(api, options, meta):
api.app.versionapi.command.register(cb)/api.command.trigger(value)api.route.register(routes)/api.route.navigate(name, params?)/api.route.currentapi.ui.Dialog,DialogAlert,DialogConfirm,DialogPrompt,DialogSelect,ui.toast,ui.dialogapi.keybind.match,print,createapi.tuiConfigapi.kv.get,set,readyapi.stateapi.theme.current,selected,has,set,install,mode,readyapi.client,api.scopedClient(workspaceID?),api.workspace.current(),api.workspace.set(workspaceID?)api.event.on(type, handler)api.rendererapi.slots.register(plugin)api.plugins.list(),activate(id),deactivate(id),add(spec),install(spec, options?)api.lifecycle.signal,api.lifecycle.onDispose(fn)
Commands
api.command.register returns an unregister function. Command rows support:
title,valuedescription,categorykeybindsuggested,hidden,enabledslash: { name, aliases? }onSelect
Command behavior:
- Registrations are reactive.
- Later registrations win for duplicate
valueand for keybind handling. - Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and
command.trigger(value)ifenabled !== false.
Routes
- Reserved route names:
homeandsession. - Any other name is treated as a plugin route.
api.route.currentreturns one of:{ name: "home" }{ name: "session", params: { sessionID, initialPrompt? } }{ name: string, params?: Record<string, unknown> }
api.route.navigate("session", params)only usesparams.sessionID. It cannot setinitialPrompt.- If multiple plugins register the same route name, the last registered route wins.
- Unknown plugin routes render a fallback screen with a
go homeaction.
Dialogs and toast
ui.Dialogis the base dialog wrapper.ui.DialogAlert,ui.DialogConfirm,ui.DialogPrompt,ui.DialogSelectare built-in dialog components.ui.toast(...)shows a toast.ui.dialogexposes the host dialog stack:replace(render, onClose?)clear()setSize("medium" | "large" | "xlarge")- readonly
size,depth,open
Keybinds
api.keybind.match(key, evt)andprint(key)use the host keybind parser/printer.api.keybind.create(defaults, overrides?)builds a plugin-local keybind set.- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
- Returned keybind set exposes
all,get(name),match(name, evt),print(name).
KV, state, client, events
api.kvis the shared app KV store backed bystate/kv.json. It is not plugin-namespaced.api.kvexposesready.api.tuiConfigandapi.stateare live host objects/getters, not frozen snapshots.api.stateexposes synced TUI state:readyconfigproviderpath.{state,config,worktree,directory}vcs?.branchworkspace.list()/workspace.get(workspaceID)session.count()session.diff(sessionID)session.todo(sessionID)session.messages(sessionID)session.status(sessionID)session.permission(sessionID)session.question(sessionID)part(messageID)lsp()mcp()
api.clientalways reflects the current runtime client.api.scopedClient(workspaceID?)creates or reuses a client bound to a workspace.api.workspace.set(...)rebinds the active workspace;api.clientfollows that rebind.api.event.on(type, handler)subscribes to the TUI event stream and returns an unsubscribe function.api.rendererexposes the rawCliRenderer.
Theme
api.theme.currentexposes the resolved current theme tokens.api.theme.selectedis the selected theme name.api.theme.has(name)checks for an installed theme.api.theme.set(name)switches theme and returnsboolean.api.theme.mode()returns"dark" | "light".api.theme.install(jsonPath)installs a theme JSON file.api.theme.readyreports theme readiness.
Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename.
- Install is skipped if that theme name already exists.
- Local plugins persist installed themes under the local
.opencode/themesarea near the plugin config source. - Global plugins persist installed themes under the global
themesdir. - Invalid or unreadable theme files are ignored.
Slots
Current host slot names:
apphome_logohome_bottomsidebar_titlewith props{ session_id, title, share_url? }sidebar_contentwith props{ session_id }sidebar_footerwith props{ session_id }
Slot notes:
- Slot context currently exposes only
theme. api.slots.register(plugin)returns the host-assigned slot plugin id.api.slots.register(plugin)does not return an unregister function.- Returned ids are
pluginId,pluginId:1,pluginId:2, and so on. - Plugin-provided
idis not allowed. - The current host renders
home_logowithreplace,sidebar_titleandsidebar_footerwithsingle_winner, andapp,home_bottom, andsidebar_contentwith the slot library default mode. - Plugins cannot define new slot names in this branch.
Plugin control and lifecycle
api.plugins.list()returns{ id, source, spec, target, enabled, active }[].enabledis the persisted desired state.activemeans the plugin is currently initialized.api.plugins.activate(id)setsenabled=true, persists it into KV, and initializes the plugin.api.plugins.deactivate(id)setsenabled=false, persists it into KV, and disposes the plugin scope.api.plugins.add(spec)trims the input and returnsfalsefor an empty string.api.plugins.add(spec)treats the input as the runtime plugin spec and loads it without re-readingtui.json.api.plugins.add(spec)no-ops when that resolved spec (or resolved plugin id) is already loaded.api.plugins.add(spec)assumes enabled and always attempts initialization (it does not consult config/KV enable state).api.plugins.install(spec, { global? })runs install -> manifest read -> config patch using the same helper flow as CLI install.api.plugins.install(...)returns either{ ok: false, message, missing? }or{ ok: true, dir, tui }.api.plugins.install(...)does not load plugins into the current session. Callapi.plugins.add(spec)to load after install.- For packages that declare a tuple
tuitarget inoc-plugin,api.plugins.install(...)stages those tuple options so a followingapi.plugins.add(spec)uses them. - If activation fails, the plugin can remain
enabled=trueandactive=false. api.lifecycle.signalis aborted before cleanup runs.api.lifecycle.onDispose(fn)registers cleanup and returns an unregister function.
Plugin metadata
meta passed to tui(api, options, meta) contains:
state:first | updated | sameid,source,spec,target- npm-only fields when available:
requested,version - file-only field when available:
modified first_time,last_time,time_changed,load_count,fingerprint
Metadata is persisted by plugin id.
- File plugin fingerprint is
target|modified. - npm plugin fingerprint is
target|requested|version. - Internal plugins get synthetic metadata with
state: "same".
Runtime behavior
- Internal TUI plugins load first.
- External TUI plugins load from
tuiConfig.plugin. --pure/KILO_PUREskips external TUI plugins only.- External plugin resolution and import are parallel.
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
- File plugins that fail initially are retried once after waiting for config dependency installation.
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
- Runtime add skips duplicates by resolved spec and returns
truewhen the spec is already loaded. - Runtime install and runtime add are separate operations.
- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
- TUI runtime tracks and disposes:
- command registrations
- route registrations
- event subscriptions
- slot registrations
- explicit
lifecycle.onDispose(...)handlers
- Cleanup runs in reverse order.
- Cleanup is awaited.
- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
Built-in plugins
internal:home-tipsinternal:sidebar-contextinternal:sidebar-mcpinternal:sidebar-lspinternal:sidebar-todointernal:sidebar-filesinternal:sidebar-footerinternal:plugin-manager
Sidebar content order is currently: context 100, mcp 200, lsp 300, todo 400, files 500.
The plugin manager is exposed as a command with title Plugins and value plugins.list.
- Keybind name is
plugin_manager. - Default keybind is
none. - It lists both internal and external plugins.
- It toggles based on
active. - Its own row is disabled only inside the manager dialog.
- It also exposes command
plugins.installwith titleInstall plugin. - Inside the Plugins dialog, key
shift+iopens the install prompt. - Install prompt asks for npm package name.
- Scope defaults to local, and
tabtoggles local/global. - Install is blocked until
api.state.path.directoryis available; current guard message isPaths are still syncing. Try again in a moment.. - Manager install uses
api.plugins.install(spec, { global }). - If the installed package has no
tuitarget (tui=false), manager reports that and does not expect a runtime load. - If install reports
tui=true, manager then callsapi.plugins.add(spec). - If runtime add fails, TUI shows a warning and restart remains the fallback.
Current in-repo examples
- Local smoke plugin:
.opencode/plugins/tui-smoke.tsx - Local smoke config:
.opencode/tui.json - Local smoke theme:
.opencode/plugins/smoke-theme.json