Skip to content
nikcli
Docs
Documentation

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.

feature-plugins/

14 built-in plugins

Registered in plugin/internal.ts and listed by the plugin manager alongside anything installed from npm.

v1 + v2

Two API generations

Slot-only plugins use Plugin.define (v2). Command-owning plugins export a TuiPluginModule with a tui(api) setup function (v1).

Ctrl+P → Plugins

Toggleable at runtime

Every internal plugin except the manager itself can be deactivated live; the enabled flag persists in the TUI key-value store.

TuiPluginRuntime

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.

feature-plugins/browser/index.tsx
tsx
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:

feature-plugins/sidebar/lsp.tsx
tsx
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 surfaceUsed forExample 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.clientThe typed SDK against the same server every other client talks to.mission, loops, fusion
api.stateRead-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 fires on both paths

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.

packages/tui/src/plugin/internal.ts
ts
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.

DirectoryPlugin idWhat it owns
home/internal:home-tipsRotating tip line under the home splash.
sidebar/internal:sidebar-* (6)Context, MCP, LSP, Todo, Modified Files, footer.
system/internal:plugin-manager, internal:fusionPlugin list/install; OpenRouter Fusion presets.
background/internal:backgroundTerminal wallpaper: renderable, pixel pipeline, picker.
loops/internal:loopsAutonomous loop manager + sidebar.
mission/internal:missionsMulti-milestone mission manager + sidebar.
brain/internal:brainConsolidation scheduler + on-demand run.
browser/internal:browser/browser → browser-use control dialog.
computer/internal:computer/computer → desktop capability probe.
connectors/internal:connectorsConnector health, refresh, credential removal.
chatbot/internal:chatbotChat-platform bots: start/stop, webhook path.
observability/internal:observabilityOTLP settings + live span stream.
island/internal:islandmacOS notch bridge toggle and status.
deepsec is an empty directory

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.

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.

PluginSlotRendersHidden when
sidebar/context.tsxsidebar.contentToken total for the last assistant message, percentage of the model's context limit, and cumulative session cost in USD.never (shows zeros)
sidebar/mcp.tsxsidebar.contentEach MCP server with a status dot — connected, failed (with error), disabled, needs auth, needs client ID.no MCP servers configured
sidebar/lsp.tsxsidebar.contentConnected language servers and their roots; explains whether LSP is disabled or simply not activated yet.never
sidebar/todo.tsxsidebar.contentThe session's todo list via the shared TodoItem component.list empty or fully completed
sidebar/files.tsxsidebar.contentModified files for the session with per-file +additions / -deletions.no diff for the session
sidebar/footer.tsxsidebar.footerWorking directory (with ~ collapsing) plus branch, and the NIKCLI version line. Also the dismissible “Getting started” card when no paid provider is connected.never
Collapsible panels

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.

KeyAction
space / enterActivate or deactivate the highlighted plugin, live. The new state persists in kv["plugin_enabled"].
shift+iInstall 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.

KeyAction
space / enterEnable or disable the preset (writes disabled through client.config.update).
shift+iCreate a preset: name → analysis models (comma/space separated OpenRouter slugs) → primary model.
shift+dRemove a custom preset. Built-ins can be disabled but never removed.
Two KV keys carry the state config cannot

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.

CommandSlashDoes
background.configure/background · /bg · /wallpaperOpens 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

SettingValuesDefault
sourceAbsolute path, a directory to rotate through, file://, http(s)://, or a data: URL.empty
opacity0.05 – 0.9, cycled in 0.05 steps (wraps at the ends).0.3
fitcover (fill, crop) or contain (letterbox).cover
scopeall (every route) or home (splash only).all
grayscaleDrop color, keep luminance.false
enabledPaint 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

  1. Resolve. source.ts turns the setting into one concrete location; a directory resolves to one of its images by index, which is all “shuffle” has to bump.
  2. 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.
  3. Compose. pixels.ts crops 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.
  4. 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.
Why app.tsx mounts the view, not a slot

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.

Resize safety

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.

Terminal
bash
# 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.

CommandSlashDoes
loops.manage/loops · /loopManager: 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.

CommandSlashDoes
missions.manage/mission · /missionsManager 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.

StatusShown 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.

Lazy imports are load-bearing here

@/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.

ActionEffect
Start botCreates 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 botDisconnects the bot from its platform.
Webhook pathShows 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.

CommandSlashOpens
otel.settings/otel · /telemetry · /opentelemetrySettings 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.

Non-TUI entrypoints ignore this toggle

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.

CommandSlashNamespacePlugin
background.configure/background, /bg, /wallpaperAppearancebackground
background.choose—Appearancebackground
background.toggle—Appearancebackground
background.shuffle—Appearancebackground
brain.run/brainSystembrain
browser.sessions/browserToolbrowser
chatbot.manage/bots, /chatbotSystemchatbot
computer.capabilities/computer, /computer-useSystemcomputer
connectors.manage/connectors, /connectorSystemconnectors
fusion.presets—Systemsystem/fusion
island.status/islandSystemisland
loops.manage/loops, /loopLoopsloops
loops.new—Loopsloops
missions.manage/mission, /missionsMissionsmission
missions.new—Missionsmission
otel.settings/otel, /telemetry, /opentelemetrySystemobservability
otel.live—Systemobservability
plugins.list—Systemsystem/plugins (keybind plugin_manager)
plugins.install—Systemsystem/plugins
tips.toggle—Systemhome (keybind tips_toggle)
No slash entry means palette-only

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.

my-plugin/src/tui.tsx
tsx
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 ai package 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

ConcernFile
Registrypackages/tui/src/plugin/internal.ts
Host API constructionpackages/tui/src/plugin/api.tsx
Lifecycle / activate / deactivatepackages/tui/src/plugin/runtime.ts
Keymap layers & bindingspackages/tui/src/plugin/keymap.ts
Slot registrypackages/tui/src/plugin/slots.tsx
Public plugin typespackages/plugin/src/tui.ts, packages/plugin/src/v2/tui/plugin.ts
All feature pluginspackages/tui/src/feature-plugins/
Background renderablefeature-plugins/background/renderable.ts, pixels.ts, source.ts
Background mount pointpackages/tui/src/app.tsx → <BackgroundImage />
KV storepackages/tui/src/context/kv.tsx → <state>/kv.json