Writing bemtvi plugins
A plugin is pure Lua over the btv.* API — no Rust, no Vimscript. The server
owns every UI surface (windows, floats, the completion menu, the statusline); a
plugin supplies data and behavior and reaches the screen through btv.*.
Our prime directive is every feature that can be a plugin is one, so we exercise
our own APIs.
If you’ve written a neovim plugin, the shape is familiar — a lua/<name>/init.lua
module exposing setup(opts) — but the API you call is btv.*, not vim.*. (A
closed whitelist of vim.* muscle-memory aliases exists for config ergonomics; new
plugin code should target btv.* directly.)
Writing plugins with a coding agent
The btv.* model is not in any agent’s training data — left to itself, an agent
writes neovim plugins that fail on bemtvi (vim.uv, blocking vim.wait, a
nvim_buf_set_lines that does not exist). bemtvi-plugin-skills
is a set of agent skills that teach it the real model, the surfaces below, and the
traps; it also bundles a generated snapshot of the btv.* API reference and every
public btv.*/vim.* name in a live editor.
npx skills add https://github.com/bemtvi/bemtvi-plugin-skills # any agent
Claude Code can install it as a plugin instead:
/plugin marketplace add bemtvi/bemtvi-plugin-skills
/plugin install bemtvi-plugin-skills
One skill per surface — scaffolding, async, keymaps/commands/events,
buffers/windows, UI and components, views and docks, decorations, picker,
completion, statusline, LSP and diagnostics, fs/process/net, testing, vimdoc, and
porting from neovim — with the bemtvi skill routing to the right one.
Anatomy of a plugin
A plugin is a directory laid out the way neovim plugins are, so it resolves on the runtimepath:
my-plugin/
├── lua/
│ └── my-plugin/
│ └── init.lua # the module: returns { setup = … }
├── plugin/ # optional: *.lua here is auto-sourced at load
│ └── my-plugin.lua
├── after/plugin/ # optional: sourced after plugin/
├── colors/ # optional: colors/<name>.lua for a colorscheme
└── test/ # optional: *_spec.lua (see Testing)
└── my-plugin_spec.lua
The conventional entry point is a module that exposes setup:
-- lua/my-plugin/init.lua
local M = {}
function M.setup(opts)
opts = opts or {}
btv.keymap.set("n", "<leader>x", function()
btv.notify("hello from my-plugin", "info")
end, { desc = "my-plugin: do the thing" })
end
return M
setup should be idempotent (a user may call it more than once) and side-effect
light at module load — do the wiring in setup, not at require time, so load order
and lazy-loading stay predictable.
Installing & loading
Two paths, both runtimepath-based:
The built-in manager (btv.plugins). Declare plugins in init.lua; the manager
clones, runtimepaths, and loads them (with optional lazy triggers):
btv.plugins({
-- eager:
{ "bemtvi/bemtvi-keys-helper",
config = function() require("bemtvi-keys-helper").setup({}) end },
-- lazy by key / command / event / filetype:
{ "owner/repo", keys = { "<leader>ff" }, cmd = "Find",
config = function() require("repo").setup({}) end },
-- pinned, or a local checkout for development:
{ "owner/repo", tag = "v1.0.0" },
{ name = "my-plugin", dir = "/path/to/my-plugin",
config = function() require("my-plugin").setup({}) end },
})
Then :PluginSync (realize the declared + locked state), :PluginInstall,
:PluginUpdate (fast-forward, advancing past the lockfile), :PluginRestore
(check out the locked commits), :PluginLock, :PluginClean, :PluginList, or the
:Plugins dashboard. Cloned plugins live under stdpath("data")/plugins/<name>.
Each of those verbs takes an optional plugin list — :PluginUpdate my-plugin,
<Tab>-completed — and then touches only those plugins (and their dependencies),
leaving every other checkout and lockfile entry alone; in the dashboard the
lower-case keys (i u s r x) do the same for the row under the cursor. When
you are developing one plugin, that is the verb you want: re-cloning or updating the
whole set to test one change is how a working editor turns into a bisect.
Every install/update records each plugin’s resolved commit in
<config>/bemtvi-lock.json, and installing reproduces those commits — so a config plus
its lockfile pins the exact plugin tree. :PluginSync therefore does not move a
plugin the lockfile pins; advancing past it is :PluginUpdate’s job, and
:PluginRestore goes back. See
Configuration → the lockfile for the full rules. A spec with cmd/event/ft/keys (or
lazy = true) loads on first use; config runs after the plugin is on the
runtimepath, init runs at startup regardless. Restoring a workspace session counts as
first use too: a persisted btv.view
slot names its owner, so a lazy plugin whose sidebar was open when you quit is loaded by
the restore itself — no trigger to press, nothing extra to declare.
Git submodules are initialised by default — the manager clones with
--recurse-submodules and runs git submodule update --init --recursive on update,
so a plugin that vendors its dependencies as submodules lands complete. Set
submodules = false on a spec to opt a plugin out (skips the extra git work for one
you know has none).
By hand. Drop the plugin under <config>/pack/*/start/* and require it from
init.lua — the runtimepath picks it up with no manager involved.
The btv.* surfaces you’ll use
A plugin composes these (each has runnable examples under examples/
and a deeper treatment in the API design):
- Keymaps —
btv.keymap.set(mode, lhs, rhs, opts)/btv.keymap.del; introspect withbtv.keymap.get. Always pass adesc— it surfaces in completion and which-key. - User commands —
btv.command(name, fn, { desc = …, usage = …, complete = … });fnreceives{ args, fargs, bang, … }.usageis the argument signature in vim help notation (usage = "[file]","{name}") — it heads the command’s:-completion docs pane as:Name <usage>, exactly like a built-in.complete("file"/"dir"/ afn(args)) drives<Tab>completion of the argument. - Autocmds / events —
btv.on(event, { pattern = … }, fn)for editor lifecycle events (BufReadPost,FileType, …), orbtv.on(event, fn)with no options. - Options & vars — read/write
btv.o(global),btv.bo(buffer),btv.wo(window), andbtv.g(globals). Edge docks have their own scope too:btv.dock.opt(side)(e.g.btv.dock.opt("left").size = 32), alongsidebtv.bo/btv.wo— per-dockshowtabline,laststatus,size,title,winhighlight, andautohide. - Highlights —
btv.hl.define(ns, name, spec),btv.hl.get,btv.hl.exists. Define your groups as fallbacks (btv.hl.existsguard) so a colorscheme that already styles them wins. - Messages —
btv.notify(msg, level). - Async — the editor is single-threaded and tick-based, so anything that waits
is promise-based:
btv.async/btv.await,btv.promise,btv.run/btv.run_stream(subprocesses),btv.fs(filesystem),btv.timer,btv.utils.debounce, and the scheduling primitivesbtv.schedule(end of the current tick) /btv.on_next_tick/btv.wait_for(pred, opts)(across ticks). Reach foron_next_tick/wait_for— never abtv.schedulere-arm — when waiting on state that only refreshes between ticks (e.g. a freshly-mounted window id). Full guide: Async & promises. - UI — the floating-widget layer
btv.ui.input/select/confirm/float(promise-based, never steals focus forfloat), andbtv.component(reactive state + a pure render + lifecycle) for live popups. Bigger server-owned surfaces:btv.picker(fuzzy finder),btv.complete(completion sources),btv.snippet,btv.statusline(composable segments),btv.decor(viewport decorations / extmarks),btv.dock(edge docks), andbtv.view(a read-only mountable content surface — what a plugin ui is built on).
When something genuinely useful is missing, the convention is to add it to btv.*
for everyone, so let us know if you find anything missing.
A worked example
bemtvi-keys-helper — the
first-party which-key — is a compact, real-world plugin: it subscribes to the
pending-key oracle (btv.on_key_pending), debounces with btv.utils.debounce, and
renders the continuations on a non-focus btv.component{ surface = "float" }. It is
packaged exactly as above (lua/bemtvi-keys-helper/init.lua with setup/add) and
carries its own test suite.
The examples/ directory has ~85 self-contained configs — one per
feature — that double as plugin-authoring references.
Testing
Plugins are pure Lua, so their tests are too. bemtvi ships a native test framework
(btv.test) and a headless runner — write test/*_spec.lua, then:
bemtvi --test-plugin .
The suite drives a real editor (feed keys, assert on buffer / cursor / UI) and
exits 0/1 for CI. See the full guide: Testing plugins.
See also
- bemtvi-plugin-skills —
agent skills for authoring plugins, one per
btv.*surface. - Native plugin API design — the model and six worked API examples.
- ADR 0002 — native plugin system — why
btv.*, and the exactvim.*alias whitelist. - Testing bemtvi plugins —
btv.test+bemtvi --test-plugin. - Architecture — the crate layout, tick model, and Lua bridge.