# 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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

```
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 

1

2

3

4

5

6

7

8

```
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 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 

1

2

3

4

5

6

7

8

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

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.

### 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 |

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.

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

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.

| 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

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
```

$ shell · paste into your terminal

## Orchestration

### loops

Define an objective and a trigger once; nikcli drives the Goal system toward it on a schedule or on demand. See [Loops](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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.

| 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](/docs/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](/docs/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.

| 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`)_ |

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](/docs/plugins) for packaging and the server-side plugin target.

my-plugin/src/tui.tsx

tsx 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

```
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

| 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` |

## Related

-   [TUI The renderer, worker model, routes, dialogs and keybinds these plugins hook into.](/docs/tui)
-   [Plugins & Skills Packaging, distribution, server-target plugins and skills.](/docs/plugins)
-   [Loops The engine behind the loops plugin.](/docs/loops)
-   [Missions The engine behind the mission plugin.](/docs/missions)
-   [Connectors Connector types, auth and health semantics.](/docs/connectors)
-   [Island The macOS notch companion the island plugin toggles.](/docs/island)

---

Source: https://nikcli-ai.dev/docs/tui-plugins
