Technical reference for the current TUI plugin system.
tui.json.@kirincode-ai/plugin/tui.server or tui, never both.package.json["oc-themes"] without a ./tui entrypoint.Example:
{
"$schema": "https://kirincode.ai/tui.json",
"theme": "smoke-theme",
"leader_timeout": 2000,
"keybinds": {
"leader": "ctrl+x",
"command_list": "ctrl+p",
"session_new": "<leader>n"
},
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": {
"acme.demo": false
},
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4,
"sound_pack": "opencode.default",
"sounds": {
"error": "/Users/me/sounds/error.mp3"
}
}
}
plugin entries can be either a string spec or [spec, options].file:// URLs, relative paths, or absolute paths.tui.json must be a TUI module (default export { id?, tui }) and must not export server.plugin_enabled is keyed by plugin id, not by plugin spec.id. For npm plugins, it is the exported id or the package name if id is omitted.plugin_enabled is only for explicit overrides, usually to disable a plugin with false.enabled: false to be registered but inactive by default; plugin_enabled and runtime KV can still enable them by id.plugin_enabled is merged across config layers.plugin_enabled; that KV state overrides config on startup.attention.enabled defaults to false; when false, it disables all api.attention.notify(...) delivery.attention.notifications and attention.sound independently control terminal-mediated desktop notifications and built-in sounds.attention.volume sets the default built-in sound volume from 0 to 1.attention.sound_pack selects the initial semantic sound pack. Persisted runtime selection in KV can override it.attention.sounds overrides individual semantic sound slots such as error, done, or subagent_done.leader_timeout is a top-level TUI setting.keybinds is a flat object keyed by command id; values are key binding values (false, "none", a key string/object, a binding object, or an array of key strings/objects/binding objects).keybinds.leader sets the key used by <leader> shortcuts.Package entrypoint:
@kirincode-ai/plugin/tui.@kirincode-ai/plugin exports ./tui and declares optional peer deps on @opentui/core and @opentui/solid.Minimal module shape:
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule } from "@kirincode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.keymap.registerLayer({
commands: [
{
name: "demo.open",
title: "Demo",
category: "Plugin",
namespace: "palette",
slashName: "demo",
run() {
api.route.navigate("demo")
},
},
],
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
})
api.route.register([
{
name: "demo",
render: () => (
<box>
<text>demo</text>
</box>
),
},
])
}
const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
export default plugin
default export { id?, tui }; including server is rejected.server and tui.tui signature is (api, options, meta) => Promise<void>.exports contains ./tui, the loader resolves that entrypoint.exports exists, loader only resolves ./tui or ./server; it never falls back to exports["."].package.json main as a fallback entry.package.json main is only used for server plugin entrypoint resolution../tui entrypoint and no valid oc-themes, it is skipped with a warning (not a load failure)../tui entrypoint but has valid oc-themes, runtime creates a no-op module record and still loads it for theme sync and plugin state.exports (./server and ./tui) so each target resolves to a target-only module.id.id; package name is used.package.json main.package.json main../plugin can resolve to ./plugin/index.ts (or index.js) when package.json is missing../plugin -> ./plugin/index.* fallback applies to both server and TUI v1 loading.tui.json.Install target detection is inferred from package.json entrypoints and theme metadata:
server target when exports["./server"] exists or main is set.tui target when exports["./tui"] exists.tui target when oc-themes exists and resolves to a non-empty set of valid package-relative theme paths.oc-themes rules:
oc-themes is an array of relative paths.file:// paths are rejected.oc-themes causes manifest read failure for install.Example:
{
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/server.js",
"exports": {
"./server": {
"import": "./dist/server.js",
"config": { "custom": true }
},
"./tui": {
"import": "./dist/tui.js",
"config": { "compact": true }
}
},
"engines": {
"kirincode": "^1.0.0"
}
}
npm plugins can declare a version compatibility range in package.json using the standard engines field:
{
"engines": {
"kirincode": "^1.0.0"
}
}
engines.kirincode is 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, and patchPluginConfig.
opencode plugin <module> and TUI install both run install → manifest read → config patch.
Alias: opencode plug <module>.
-g / --global writes into the global config dir.
Local installs resolve target dir inside patchPluginConfig.
For local scope, path is <worktree>/.kirincode only when VCS is git and worktree !== "/"; otherwise <directory>/.kirincode.
Root-worktree fallback (worktree === "/" uses <directory>/.kirincode) is covered by regression tests.
patchPluginConfig applies all detected targets (server and/or tui) in one call.
patchPluginConfig returns structured result unions (ok, code, fields by error kind) instead of custom thrown errors.
patchPluginConfig serializes per-target config writes with Flock.acquire(...).
patchPluginConfig uses targeted jsonc-parser edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
npm plugin package installs are executed with --ignore-scripts, so package install / postinstall lifecycle scripts are not run.
exports["./server"].config and exports["./tui"].config can provide default plugin options written on first install.
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.
Explicit npm specs with a version suffix (for example pkg@1.2.3) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
Bare npm specs (pkg) are treated as latest and can refresh when the cached version is stale.
Tuple targets in oc-plugin provide 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 .kirincode dir or KIRINCODE_CONFIG_DIR, KirinCode installs @kirincode-ai/plugin into that dir and writes:
package.jsonbun.locknode_modules/.gitignoreThat is what makes local config-scoped plugins able to import @kirincode-ai/plugin/tui.
Top-level API groups exposed to tui(api, options, meta):
api.app.versionapi.attention.notify(input)api.keys.formatSequence(parts), formatBindings(bindings)api.keymapapi.mode.current(), api.mode.push(mode)api.route.register(routes) / api.route.navigate(name, params?) / api.route.currentapi.ui.Dialog, DialogAlert, DialogConfirm, DialogPrompt, DialogSelect, Slot, Prompt, ui.toast, ui.dialogapi.tuiConfigapi.kv.get, set, readyapi.stateapi.theme.current, selected, has, set, install, mode, readyapi.clientapi.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)api.keymap exposes the raw Keymap<Renderable, KeyEvent> instance from the host.default keys, metadata fields, and enabled fields) plus KirinCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer.api.keymap.registerLayer({ commands: [...] }).bindings: [{ key, cmd, desc }] in the same layer or a separate layer.api.keymap.acquireResource(...) for shared plugin addon setup that should ref-count against the host keymap.namespace: "palette" and provide metadata such as title, category, desc, suggested, hidden, enabled, slashName, and slashAliases on the command.api.keymap.dispatchCommand(name) for user-style execution semantics and api.keymap.runCommand(name) only for forced programmatic execution.api.keymap registrations and acquireResource(...) are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to api.lifecycle.onDispose(...) yourself.keybinds command ids such as which_key_toggle, not plugin options.KirinCode registers a mode layer field on the host keymap. Plugins can use it to keep bindings active only in the relevant UI state.
Built-in modes:
base: normal app, route, and prompt interaction.modal: host dialog stack is open, including dialogs rendered through api.ui.dialog and api.ui.Dialog* components.autocomplete: host prompt autocomplete is open.api.mode.current() returns the active top mode, or base when no pushed mode is active.Example: register a command and shortcut that are active only in normal app mode:
api.keymap.registerLayer({
mode: "base",
commands: [
{
name: "demo.open",
title: "Demo",
category: "Plugin",
namespace: "palette",
run() {
api.route.navigate("demo")
},
},
],
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
})
Layers without mode are not mode-gated and can remain active while dialogs or autocomplete are open. Use that only for intentionally global commands or low-level keymap extensions.
Plugins that own a full-screen route or modal-like UI can temporarily push a plugin-specific mode with api.mode.push(...). Use a plugin-scoped mode name. The returned disposer pops that specific stack entry and is idempotent, so popping an older mode while a newer mode is on top leaves the newer mode active.
import { onCleanup } from "solid-js"
api.route.register([
{
name: "demo",
render: () => {
const popMode = api.mode.push("acme.demo")
onCleanup(popMode)
return (
<box>
<text>demo</text>
</box>
)
},
},
])
api.keymap.registerLayer({
mode: "acme.demo",
bindings: [{ key: "escape", cmd: () => api.route.navigate("home"), desc: "Close demo" }],
})
Mode pushes are automatically tracked by the plugin runtime. If a plugin is disabled, fails during activation, or the TUI shuts down before the plugin calls the disposer, KirinCode pops the plugin's pushed modes during plugin cleanup. Calling the disposer yourself is still recommended for component lifetimes; cleanup remains idempotent.
api.keys exposes host-formatted shortcut display helpers for plugin UI.formatSequence(parts) formats parsed key sequence parts using the host's display policy.formatBindings(bindings) formats binding lists and returns undefined when there is nothing to show.createBindingLookup from @kirincode-ai/plugin/tui.api.attention.notify({ title?, message, notification?, sound? }) requests user attention while keeping terminal focus, notifications, and audio owned by the host.message is required; title defaults to "kirincode"; notification defaults to enabled with when: "blurred"; sound defaults to enabled with when: "always".when: "always" requests delivery regardless of terminal focus state.when: "focused" only requests delivery after the terminal is known focused; when: "blurred" only requests delivery after the terminal is known blurred.notification: { when: "blurred" }, sound: { name: "question", when: "always" } plays sound while focused but only triggers system notifications when blurred."default", "question", "permission", "error", "done", and "subagent_done".sound: true plays the "default" sound; sound: { name: "question" } plays a named semantic sound.sound: { volume } overrides volume for that call; sound: false disables sound for that call; notification: false disables system notification for that call.api.attention.soundboard.registerPack({ id, name?, sounds }) registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation.api.attention.soundboard.activate(id, { persist }) selects the active pack. persist: true writes the selected pack id to TUI KV state, not tui.json.api.attention.soundboard.current() and list() expose the active/registered packs for plugin UX.attention.sounds overrides active-pack sounds by slot. Failed loads fall back to the active pack and then opencode.default."A question needs your input"; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them.home and session.api.route.current returns one of:
{ name: "home" }{ name: "session", params: { sessionID, initialPrompt? } }{ name: string, params?: Record<string, unknown> }api.route.navigate("session", params) only uses params.sessionID. It cannot set initialPrompt.go home action.ui.Dialog is the base dialog wrapper.ui.DialogAlert, ui.DialogConfirm, ui.DialogPrompt, ui.DialogSelect are built-in dialog components.ui.Slot renders host or plugin-defined slots by name from plugin JSX.ui.Prompt renders the same prompt component used by the host app and accepts sessionID, workspaceID, ref, and right for the prompt meta row's right side.ui.toast(...) shows a toast.ui.dialog exposes the host dialog stack:
replace(render, onClose?)clear()setSize("medium" | "large" | "xlarge")size, depth, openapi.kv is the shared app KV store backed by state/kv.json. It is not plugin-namespaced.api.kv exposes ready.api.tuiConfig and api.state are live host objects/getters, not frozen snapshots.api.state exposes synced TUI state:
readyconfigproviderpath.{state,config,worktree,directory}vcs?.branchsession.count()session.diff(sessionID)session.todo(sessionID)session.messages(sessionID)session.status(sessionID)session.permission(sessionID)session.question(sessionID)part(messageID)lsp()mcp()api.client always reflects the current runtime client.api.event.on(type, handler) subscribes to the TUI event stream and returns an unsubscribe function.api.renderer exposes the raw CliRenderer.api.theme.current exposes the resolved current theme tokens.api.theme.selected is the selected theme name.api.theme.has(name) checks for an installed theme.api.theme.set(name) switches theme and returns boolean.api.theme.mode() returns "dark" | "light".api.theme.install(jsonPath) installs a theme JSON file.api.theme.ready reports theme readiness.Theme install behavior:
api.theme.install(...) and oc-themes auto-sync share the same installer path.tui-theme:<dest>.updated.updated, host skips rewrite when tracked mtime/size is unchanged.updated, host can still persist theme metadata when destination already exists..kirincode/themes area near the plugin config source.themes dir.Current host slot names:
appapp_bottomhome_logohome_prompt with props { workspace_id?, ref? }home_prompt_right with props { workspace_id? }session_prompt with props { session_id, visible?, disabled?, on_submit?, ref? }session_prompt_right with props { session_id }home_bottomhome_footersidebar_title with props { session_id, title, share_url? }sidebar_content with props { session_id }sidebar_footer with props { session_id }Slot notes:
theme.api.slots.register(plugin) returns the host-assigned slot plugin id.api.slots.register(plugin) does not return an unregister function.pluginId, pluginId:1, pluginId:2, and so on.id is not allowed.home_logo, home_prompt, and session_prompt with replace, home_footer, sidebar_title, and sidebar_footer with single_winner, and app, app_bottom, home_prompt_right, session_prompt_right, home_bottom, and sidebar_content with the slot library default mode.app_bottom is rendered in normal layout flow below the active route, while app is rendered afterward for global app-level UI.api.slots.register(...) and render them from plugin UI with ui.Slot.api.plugins.list() returns { id, source, spec, target, enabled, active }[].enabled is the persisted desired state. active means the plugin is currently initialized.api.plugins.activate(id) sets enabled=true, persists it into KV, and initializes the plugin.api.plugins.deactivate(id) sets enabled=false, persists it into KV, and disposes the plugin scope.api.plugins.add(spec) trims the input and returns false for an empty string.api.plugins.add(spec) treats the input as the runtime plugin spec and loads it without re-reading tui.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.add(spec) can load theme-only packages (oc-themes with no ./tui) as runtime entries.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. Call api.plugins.add(spec) to load after install.enabled=true and active=false.api.lifecycle.signal is aborted before cleanup runs.api.lifecycle.onDispose(fn) registers cleanup and returns an unregister function.meta passed to tui(api, options, meta) contains:
state: first | updated | sameid, source, spec, targetrequested, versionmodifiedfirst_time, last_time, time_changed, load_count, fingerprintMetadata is persisted by plugin id.
target|modified.target|requested|version.state: "same".tuiConfig.plugin.--pure / KIRINCODE_PURE skips external TUI plugins only../tui entrypoint and valid oc-themes are loaded as synthetic no-op TUI plugin modules.api.plugins.list() and plugin manager rows like other external plugins../tui entrypoint and no valid oc-themes are skipped with warning.oc-themes runs before plugin tui(...) execution and only on metadata state first or updated.true when the spec is already loaded.lifecycle.onDispose(...) handlersinternal:home-tipsinternal:sidebar-contextinternal:sidebar-mcpinternal:sidebar-lspinternal:sidebar-todointernal:sidebar-filesinternal:sidebar-footerinternal:plugin-managerSidebar 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.
plugin_manager.none.active.plugins.install with title Install plugin.shift+i opens the install prompt.tab toggles local/global.api.state.path.directory is available; current guard message is Paths are still syncing. Try again in a moment..api.plugins.install(spec, { global }).tui target (tui=false), manager reports that and does not expect a runtime load.tui target detection includes exports["./tui"] and valid oc-themes.tui=true, manager then calls api.plugins.add(spec)..kirincode/plugins/tui-smoke.tsx.kirincode/plugins/tui-vim.tsx.kirincode/tui.json.kirincode/plugins/smoke-theme.json