TUI feature plugins
Every built-in plugin under packages/tui/src/feature-plugins — what it registers, which commands it owns, where its state lives.
Overview
Almost nothing the nikcli TUI shows is hard-coded in app.tsx. The
home tips, the sidebar panels, the plugin manager, the wallpaper, the loop and
mission managers, the connector and chat-bot dashboards — each one is a
self-contained feature plugin under
packages/tui/src/feature-plugins/, loaded through the
exact same runtime that loads third-party plugins from npm.
That is the point: the internal plugins are the reference implementations of the public plugin API. If you want to know how to register a command, own a dialog, paint into a sidebar slot, or persist per-machine state, one of the directories on this page already does it.
14 built-in plugins
Registered in plugin/internal.ts and listed by the plugin manager alongside anything installed from npm.
Two API generations
Slot-only plugins use Plugin.define (v2). Command-owning plugins export a TuiPluginModule with a tui(api) setup function (v1).
Toggleable at runtime
Every internal plugin except the manager itself can be deactivated live; the enabled flag persists in the TUI key-value store.
Same runtime as external
Internal plugins get no privileges an npm plugin lacks — they are simply pre-registered.
Anatomy of a plugin
A command-owning plugin is a module with an id and an async
tui(api) setup function. Everything it registers is scoped to the
plugin's lifetime — the runtime unregisters on deactivate and on TUI shutdown.
import type { TuiPlugin, TuiPluginModule } from "@nikcli-ai/plugin/tui"
import { DialogBrowserUse } from "@tui/component/dialog-browser-use"
const id = "internal:browser"
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
commands: [
{
name: "browser.sessions",
title: "Browser",
namespace: "Tool",
description: "Inspect and manage active background browser sessions",
slashName: "browser",
run() {
api.ui.dialog.replace(() => <DialogBrowserUse />)
},
},
],
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin
A pure-UI plugin has no commands at all — it only fills a slot, and uses the
terser v2 Plugin.define form:
import { Plugin } from "@nikcli-ai/plugin/v2/tui"
export default Plugin.define({
id: "internal:sidebar-lsp",
setup(ctx) {
ctx.ui.slot("sidebar.content", () => <View />)
},
}) | API surface | Used for | Example plugin |
|---|---|---|
api.keymap.registerLayer() | Commands, slash names/aliases, keybindings. Pass a function instead of an array to make the layer reactive. | background (its command titles change with settings) |
api.slots.register() / ctx.ui.slot() | Render into app, home.bottom, home.footer, sidebar.content, sidebar.footer. | loops, every sidebar/* |
api.ui.dialog.replace() | Own the dialog stack: DialogSelect, DialogPrompt, DialogAlert, DialogConfirm. | connectors, chatbot |
api.kv.get() / set() | Per-machine state in <state>/kv.json — never in nikcli.json. | background, loops, fusion |
api.client | The typed SDK against the same server every other client talks to. | mission, loops, fusion |
api.state | Read-only synced snapshot: config, providers, sessions, todos, diffs, MCP, LSP. | all sidebar/* |
api.lifecycle.onDispose() | Teardown on deactivate and on shutdown. Do not rely on process exit. | island, fusion, loops |
onDispose runs when the user deactivates the plugin and when
the TUI quits. If a teardown should only happen on a real deactivation, check the
persisted flag — system/fusion reads kv["plugin_enabled"][id] === false
so quitting the app does not disable every fusion preset.
The registry
plugin/internal.ts is the whole registry. Order matters only for slot
ordering ties; the plugin manager sorts its own list.
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeTips,
SidebarContext, SidebarMcp, SidebarLsp, SidebarTodo, SidebarFiles, SidebarFooter,
PluginManager, Fusion,
Missions, Loops, Brain, Browser, Chatbot, Computer, Connectors, Observability,
Background,
...(Flag.NIKCLI_ISLAND ? [Island] : []),
]
Only island is conditional: it is registered exclusively when the CLI
was started with --island, so the macOS notch bridge never loads on a
run that did not ask for it.
| Directory | Plugin id | What it owns |
|---|---|---|
home/ | internal:home-tips | Rotating tip line under the home splash. |
sidebar/ | internal:sidebar-* (6) | Context, MCP, LSP, Todo, Modified Files, footer. |
system/ | internal:plugin-manager, internal:fusion | Plugin list/install; OpenRouter Fusion presets. |
background/ | internal:background | Terminal wallpaper: renderable, pixel pipeline, picker. |
loops/ | internal:loops | Autonomous loop manager + sidebar. |
mission/ | internal:missions | Multi-milestone mission manager + sidebar. |
brain/ | internal:brain | Consolidation scheduler + on-demand run. |
browser/ | internal:browser | /browser → browser-use control dialog. |
computer/ | internal:computer | /computer → desktop capability probe. |
connectors/ | internal:connectors | Connector health, refresh, credential removal. |
chatbot/ | internal:chatbot | Chat-platform bots: start/stop, webhook path. |
observability/ | internal:observability | OTLP settings + live span stream. |
island/ | internal:island | macOS notch bridge toggle and status. |
feature-plugins/deepsec/ exists but contains no files and is not in
the registry. There is no deep-security TUI plugin today; treat any older
reference to one as stale.
Shell — home & sidebar
These plugins have no commands. They exist purely to fill slots, and they are the shortest possible examples of the v2 API.
home
home/tips.tsx fills the home.bottom slot with one randomly
chosen tip from a ~100-entry list in home/tips-view.tsx. The tip is
shown only once at least one session exists, so a brand-new install is not greeted
with advice about features it has not reached yet.
It also registers a single palette command, tips.toggle (keybind
tips_toggle), which flips the tips_hidden KV flag. Tips
support a {highlight}…{/highlight} markup that renders the
wrapped span in the foreground color — that is how keybinds and slash commands stand
out inside a tip.
sidebar
Six independent plugins, each one panel. They are separate on purpose: a user (or a
third-party plugin) can turn off just the MCP panel without losing the Todo panel.
All six read from useSync(), so they never issue their own fetches.
| Plugin | Slot | Renders | Hidden when |
|---|---|---|---|
sidebar/context.tsx | sidebar.content | Token total for the last assistant message, percentage of the model's context limit, and cumulative session cost in USD. | never (shows zeros) |
sidebar/mcp.tsx | sidebar.content | Each MCP server with a status dot — connected, failed (with error), disabled, needs auth, needs client ID. | no MCP servers configured |
sidebar/lsp.tsx | sidebar.content | Connected language servers and their roots; explains whether LSP is disabled or simply not activated yet. | never |
sidebar/todo.tsx | sidebar.content | The session's todo list via the shared TodoItem component. | list empty or fully completed |
sidebar/files.tsx | sidebar.content | Modified files for the session with per-file +additions / -deletions. | no diff for the session |
sidebar/footer.tsx | sidebar.footer | Working directory (with ~ collapsing) plus branch, and the NIKCLI version line. Also the dismissible “Getting started” card when no paid provider is connected. | never |
MCP, LSP, Todo and Files collapse on click, but only when they hold more than two
rows — a two-line panel gains nothing from a disclosure triangle. The collapsed MCP
header summarizes as (3 active, 1 error).
System
system/plugins — the plugin manager
The dialog that lists every plugin, internal and external, and the only way to
install one from inside the TUI. Reached from the command palette
(Plugins) or the plugin_manager keybind.
| Key | Action |
|---|---|
space / enter | Activate or deactivate the highlighted plugin, live. The new state persists in kv["plugin_enabled"]. |
shift+i | Install an npm package. tab inside the prompt switches between local and global scope. |
Rows are grouped into External, Server and Internal, with externals first. Server-target plugins and the manager itself are disabled rows — you cannot toggle a server plugin from the TUI, and you cannot switch off the thing you are switching things off with.
Install is two-phase: plugins.install(mod) writes the package, then
plugins.add(mod) loads it into the running session. A package with no TUI
target installs successfully and says so rather than pretending to load; a package
that installs but fails to load tells you to restart rather than leaving a broken row.
The dialog widens with the terminal — medium below 96 columns, large at 96, xlarge at 128 — because the description column holds a full filesystem path for external plugins.
system/fusion — OpenRouter Fusion presets
OpenRouter's Fusion presets are surfaced as model variants on
openrouter/fusion. This plugin is their manager: built-ins ship in
provider/transform.ts, customs live in global config under
provider.openrouter.models["openrouter/fusion"].variants.
| Key | Action |
|---|---|
space / enter | Enable or disable the preset (writes disabled through client.config.update). |
shift+i | Create a preset: name → analysis models (comma/space separated OpenRouter slugs) → primary model. |
shift+d | Remove a custom preset. Built-ins can be disabled but never removed. |
A config merge cannot delete keys, so removed customs are remembered in
fusion.removed and filtered out on read. And because turning the
plugin off should hide the variants from the model picker without forgetting your
choices, the set of enabled presets is snapshotted into fusion.snapshot
on master-off and restored on master-on.
Writes are optimistic: the row flips immediately from an in-session override map because the synced config state can lag a tick behind the update call.
Appearance
background — a real image behind the TUI
The largest internal plugin, and the one that most clearly shows how far a plugin can go. It paints an actual decoded image behind every route using truecolor half-blocks, so you get a terminal wallpaper whether or not your terminal supports one.
| Command | Slash | Does |
|---|---|---|
background.configure | /background · /bg · /wallpaper | Opens the settings dialog. Its description shows the current source, so the palette itself tells you what is set. |
background.choose | — | Jumps straight to the filesystem picker. |
background.toggle | — | Stop painting without forgetting the image. Disabled until a source is set. |
background.shuffle | — | Next image, when the source is a folder. Hidden until a source is set. |
The command layer is registered as a function, not an array, so the titles re-evaluate: the toggle row reads “Hide background image” or “Show background image” depending on the current setting.
Settings
| Setting | Values | Default |
|---|---|---|
source | Absolute path, a directory to rotate through, file://, http(s)://, or a data: URL. | empty |
opacity | 0.05 – 0.9, cycled in 0.05 steps (wraps at the ends). | 0.3 |
fit | cover (fill, crop) or contain (letterbox). | cover |
scope | all (every route) or home (splash only). | all |
grayscale | Drop color, keep luminance. | false |
enabled | Paint or don't, keeping the source. | true |
Everything lives under the single KV key background_image — not in
nikcli.json, because the wallpaper is a personal, per-machine choice and
a config round-trip would be noise in a shared repo. A bare string is accepted and
normalized into the full object, and sources are cleaned of the quoting and
backslash-escaping a terminal adds when you drag a file onto the prompt, with
~ expanded.
How it renders
- Resolve.
source.tsturns the setting into one concrete location; a directory resolves to one of its images by index, which is all “shuffle” has to bump. - Decode. Bytes are read (25 MB cap for remote and local files), decoded via
@nikcli-ai/tui-image, and cached per location so a route change or a terminal resize never re-decodes a photo. - Compose.
pixels.tscrops to the terminal aspect, resamples at 2× per axis, optionally desaturates, and blends over the theme background at the configured opacity — pure and synchronous, so it is unit-testable without a renderer. - Paint.
<nikcli_background>hands the RGBA buffer to OpenTUI's native Zig super-sampler, which writes▀cells with per-cell foreground/background in one call.
The plugin owns the settings, dialogs and commands — but app.tsx renders
BackgroundImage directly. Through a slot the image would nest under a
SlotRenderable, where zIndex: -1 only sorts against that
node's own children; against the app it would keep mount order and paint over the
tabs and the prompt. Being a direct child of the app root is what puts it behind.
The native super-sampler reads a 2×2 pixel block per cell and does not bounds-check. If the pixel buffer does not match the frame buffer exactly — which happens for one frame after a resize — the renderable keeps the previous frame instead of drawing past the end of the buffer.
# Trace the render path to a file while debugging
NIKCLI_BG_DEBUG=/tmp/bg.log nikcli Orchestration
loops
Define an objective and a trigger once; nikcli drives the Goal system toward it on a schedule or on demand. See Loops for the engine itself — the plugin is the TUI half.
| Command | Slash | Does |
|---|---|---|
loops.manage | /loops · /loop | Manager: every loop, its live status and success rate. |
loops.new | — | Creation wizard — template, blank, or AI-generated. |
The plugin also fills sidebar_content at order 250 with a
live list and a “N running” badge. Definitions and history are persisted server-side so
loops keep running with the TUI closed; on activation the plugin syncs the server's view
into a local KV cache (loops, loops:history) and subscribes to
the engine's bus events. The local cache is only there to keep the sidebar snappy when
the server is unreachable — the server stays the source of truth.
mission
Same shape as loops, one level up: multi-milestone autonomous missions
with feature-level progress. See Missions.
| Command | Slash | Does |
|---|---|---|
missions.manage | /mission · /missions | Manager with per-mission status and done/total feature counts. |
missions.new | — | Planning wizard — template, blank, or AI-generated. |
Its sidebar slot registers at order 240, deliberately ahead of Loops
(250), so Missions wins the bottom of the sidebar. KV cache keys:
missions, missions:history.
brain
Wires the Brain consolidation engine into the TUI. On
activation it arms the background scheduler that periodically consolidates sessions
into long-term memory; /brain (command brain.run) forces a
pass right now.
The run is fire-and-forget: the dialog closes, a toast says it started, and when the pass finishes another toast reports how many sessions were reviewed — then the TUI navigates to the resulting Brain session. Both the scheduler and the engine are imported lazily, because eagerly pulling the brain/provider chain during TUI module load would cost startup time on every run that never touches Brain.
Engines & integrations
browser
/browser (command browser.sessions, namespace
Tool) opens the browser-use control dialog: active background browser
sessions, navigation and the action log for the browser tool. The engine
is documented in Browser control.
computer
/computer · /computer-use (command
computer.capabilities) probes the host through
@nikcli-ai/computer-use and reports exactly what the computer
tool can do on this machine — platform, screenshot availability, input availability,
and a detail line naming the helpers in use (on macOS,
screencapture + System Events + cliclick) or the ones that are missing.
A failed probe surfaces as an error toast, not a broken dialog. See
Computer use.
connectors
/connectors · /connector (command
connectors.manage) lists every configured connector with a live health
badge, resolved by running the real health check — not by reading config.
| Status | Shown as |
|---|---|
connected | ● connected |
needs_auth | ○ needs auth |
disabled | – disabled |
failed | ✗ failed: <error> |
Selecting a connector offers Refresh status (invalidate the cached
status and re-run the check) and Remove credentials (drop the stored
token, then invalidate). A top-level Refresh all invalidates every
cached status and the tool list. With nothing configured, the dialog says so and tells
you to add one under connectors in nikcli.json or run
nikcli connector add. Full reference: Connectors.
@/connectors pulls the ai package chain. Importing it at
module scope would drag that chain into TUI startup, so the plugin imports it inside
the handlers instead. The same pattern appears in brain and
chatbot.
chatbot
/bots · /chatbot (command chatbot.manage) manages
the Chat SDK bots — Discord, Slack, Teams, Google Chat, Linear, GitHub. The list is the
intersection of configured connectors whose type is a chat platform and the
set of bots currently running, marked ● running / ○ stopped.
| Action | Effect |
|---|---|
| Start bot | Creates the bot and registers the AI mention handler. If credentials are missing it says so and points at nikcli bot auth rather than failing silently. |
| Stop bot | Disconnects the bot from its platform. |
| Webhook path | Shows the exact path that platform must be pointed at, derived per platform and bot name. |
With nothing configured, the dialog points at nikcli bot add.
observability
Two commands over the OpenTelemetry pipeline. See Observability.
| Command | Slash | Opens |
|---|---|---|
otel.settings | /otel · /telemetry · /opentelemetry | Settings panel: AI SDK telemetry on/off, resolved OTLP endpoint, export status, service resource. |
otel.live | — | The streaming span viewer, straight from the palette. |
island
Loaded only when the CLI was started with --island. Activating it turns the
macOS notch bridge on for this session; deactivating it from the plugin manager clears
this session's snapshot and stops writing, and the toggle persists like any other
plugin's enabled state.
/island (command island.status) reports whether the bridge is
enabled, whether the companion app is running, and how many live sessions it is
tracking — or says plainly that Island is macOS-only on other platforms. Full
documentation: Island.
nikcli run and nikcli serve self-activate through
Bus.publish independently, gated by --island and
NIKCLI_ISLAND_DISABLE. Turning the plugin off in the TUI does not stop them.
Command index
Every command the built-in plugins register, and how to reach it.
| Command | Slash | Namespace | Plugin |
|---|---|---|---|
background.configure | /background, /bg, /wallpaper | Appearance | background |
background.choose | — | Appearance | background |
background.toggle | — | Appearance | background |
background.shuffle | — | Appearance | background |
brain.run | /brain | System | brain |
browser.sessions | /browser | Tool | browser |
chatbot.manage | /bots, /chatbot | System | chatbot |
computer.capabilities | /computer, /computer-use | System | computer |
connectors.manage | /connectors, /connector | System | connectors |
fusion.presets | — | System | system/fusion |
island.status | /island | System | island |
loops.manage | /loops, /loop | Loops | loops |
loops.new | — | Loops | loops |
missions.manage | /mission, /missions | Missions | mission |
missions.new | — | Missions | mission |
otel.settings | /otel, /telemetry, /opentelemetry | System | observability |
otel.live | — | System | observability |
plugins.list | — | System | system/plugins (keybind plugin_manager) |
plugins.install | — | System | system/plugins |
tips.toggle | — | System | home (keybind tips_toggle) |
Commands without a slashName — the plugin manager, Fusion presets, the
loop and mission wizards, live telemetry — are reachable from the command palette
(Ctrl+P) or their keybind, never by typing a slash.
Writing your own
An external plugin uses the same API. Install it with shift+i in the plugin
manager, or add it to config; see Plugins & Skills for
packaging and the server-side plugin target.
import type { TuiPlugin, TuiPluginModule } from "@nikcli-ai/plugin/tui"
const tui: TuiPlugin = async (api) => {
// A command with a slash alias and a reactive title.
api.keymap.registerLayer({
commands: () => [
{
name: "notes.open",
title: api.kv.get("notes.pinned", false) ? "Notes (pinned)" : "Notes",
namespace: "Tool",
slashName: "notes",
run() {
const Select = api.ui.DialogSelect
api.ui.dialog.replace(() => (
<Select
title="Notes"
options={[{ title: "Pin", value: "pin", onSelect: () => api.kv.set("notes.pinned", true) }]}
/>
))
},
},
],
})
// A sidebar panel, unregistered automatically on deactivate.
api.slots.register({
order: 300,
slots: { sidebar_content: () => <MyPanel /> },
})
api.lifecycle.onDispose(() => {
// stop timers, close handles
})
}
export default { id: "my-plugin", tui } satisfies TuiPluginModule & { id: string } Three rules the internal plugins follow, and yours should too:
- Import heavy chains lazily. Anything that pulls the
aipackage or a provider chain belongs inside the handler, not at module scope — it is charged to every TUI startup otherwise. - Clean up in
onDispose. Process exit is not a teardown path; the runtime disposes plugins explicitly. - Put per-machine state in KV, shared state in config. Anything a teammate should not inherit from your checkout belongs in
api.kv.
Source anchors
| Concern | File |
|---|---|
| Registry | packages/tui/src/plugin/internal.ts |
| Host API construction | packages/tui/src/plugin/api.tsx |
| Lifecycle / activate / deactivate | packages/tui/src/plugin/runtime.ts |
| Keymap layers & bindings | packages/tui/src/plugin/keymap.ts |
| Slot registry | packages/tui/src/plugin/slots.tsx |
| Public plugin types | packages/plugin/src/tui.ts, packages/plugin/src/v2/tui/plugin.ts |
| All feature plugins | packages/tui/src/feature-plugins/ |
| Background renderable | feature-plugins/background/renderable.ts, pixels.ts, source.ts |
| Background mount point | packages/tui/src/app.tsx → <BackgroundImage /> |
| KV store | packages/tui/src/context/kv.tsx → <state>/kv.json |