Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

bemtvi is a modal, vim-style editor written in Rust. It is a headless, asynchronous editor server with thin UI clients (a terminal client, a native GPU GUI, and a client-side WebAssembly build) talking over msgpack-RPC. The editor logic lives in one place; every front end shares identical editing behavior.

It speaks vim at the keyboard — keystrokes, modes, ex-commands, and options track vim/neovim’s observable editing behavior — but every API is bemtvi’s own. Configuration and plugins target the btv.* Lua namespace, where the server owns every UI surface and plugins provide data and behavior. There are a few vim.* aliases over the native btv.* API for convenience.

How to read this book

  • Getting started — install or build bemtvi and run it.
  • Configuration — point bemtvi at your init.lua and set options through btv.*.
  • Plugin Development — the anatomy of an bemtvi plugin and a worked example.
  • btv.* API Reference — the public Lua API, generated directly from the prelude source so it always matches the running editor.
  • Architecture — the crate layout, client-server model, RPC and View protocols, the rope text model, and the Lua bridge.

This book is itself generated from the repository: the narrative chapters and the long-form architecture and plugin-authoring docs come from docs/, and the API reference is extracted from crates/bemtvi-lua/src/prelude/.

Getting started

Install a pre-built binary

Grab a binary from the latest release (or the rolling edge build from main). Binaries are published for Linux (x86_64/aarch64, glibc), macOS (x86_64/aarch64, signed & notarized .pkg/.dmg), and Windows (x86_64). Each release ships both the terminal editor (bemtvi) and the native GUI (bemtvi-gui); on Linux the GUI is published as a single-file, desktop-integrated .AppImage (alongside a plain .tar.gz).

Then run it on a file (the argument is optional):

bemtvi file.txt        # terminal editor
bemtvi-gui file.txt    # native GUI (mitsuami + wgpu)

Downloads ship with checksums and SLSA build provenance — see Verifying downloads.

Truecolor terminal recommended. bemtvi emits 24-bit color escapes; use a modern terminal with truecolor support.

The terminal editor is the whole thing in one binary: it embeds the server on its own thread and runs the client on the main thread, joined over the same msgpack-RPC the UI clients speak.

Build it yourself

You need a Rust toolchain. Then:

# Build and run (the file argument is optional)
cargo build --release
./target/release/bemtvi file.txt

# …or straight from cargo
cargo run -p bemtvi -- file.txt

# the native GUI
cargo run -p bemtvi-gui -- file.txt

Try it in the browser

A fully client-side WebAssembly build runs the entire editor core in the browser with no server. Try the live demo at https://bemtvi-demo.netlify.app: use :eo to open a local file, and :setf <lang> / :TSInstall to activate treesitter highlighting for a language.

Configuration

bemtvi reads a Lua config. On startup it resolves a config directory — the first of $BEMTVI_CONFIG, $XDG_CONFIG_HOME/bemtvi, or the platform default — and sources <config>/init.lua before the first frame. The runtimepath is that dir plus every pack/*/start/* entry under it:

~/.config/bemtvi/
├── init.lua                      # sourced at startup
└── pack/
    └── plugins/
        └── start/
            └── myplugin/       # a plugin; its lua/ and colors/ are found here
-- ~/.config/bemtvi/init.lua
require("myplugin").setup()

Where the directories are

The layout follows neovim’s, so the XDG_* variables win everywhere and the platform default is only what they fall back to. btv.stdpath("config") (and "data" / "state" / "cache") always reports the resolved path.

whatholdsLinux / macOSWindows
configinit.lua, the runtimepath root~/.config/bemtvi%LOCALAPPDATA%\bemtvi
datainstalled plugins, tree-sitter grammars~/.local/share/bemtvi%LOCALAPPDATA%\bemtvi-data
stateshada (marks, registers, session), logs~/.local/state/bemtvi%LOCALAPPDATA%\bemtvi-data
cachediscardable scratch~/.cache/bemtvi%TEMP%\bemtvi-data

On Windows the managed directories take a -data suffix so they do not land on top of the config tree you hand-edit — the split neovim spells nvim / nvim-data.

btv.* vs vim.*

The editor’s own config API is the btv.* namespace — see the API reference. The only vim.* is a closed whitelist of muscle-memory aliases (vim.g, vim.o/vim.opt, vim.cmd, vim.keymap.set, autocmds, vim.notify, and friends), each a 1:1 alias over its btv.* equivalent, so config can be written in familiar spellings:

vim.g.mapleader = " "
vim.o.number = true
btv.keymap.set("n", "<leader>w", ":w<CR>", { desc = "Save" })

The full whitelist lives in ADR 0002. A neovim colorscheme reaches for a handful of those aliases (notably the nvim_set_hl highlight helper) and nothing more.

Built-in clipboard chords — Ctrl+C / Ctrl+V

bemtvi ships the desktop copy/paste keys bound to the system clipboard (the "+ register), so they work without any config:

KeyModeDoes
<C-c> / <C-S-c>visualcopy the selection to the system clipboard ("+y)
<C-v> / <C-S-v>normalpaste it at the cursor ("+P)
<C-v> / <C-S-v>insertinsert it at the caret and keep typing (<C-r>+)
<C-v> / <C-S-v>command lineinsert it into the line being typed (<C-r>+)

Paste is P, not p: the text lands at the cursor (charwise) or above the line (linewise), where a modeless editor puts it. Nothing vim-ish is displaced — bemtvi has no blockwise visual mode for <C-v> to open and no i_CTRL-V literal insert, and <Esc> is how you leave visual mode. The shifted twins exist because a GUI, a browser tab, or a terminal speaking the kitty keyboard protocol reports Ctrl+Shift+C as its own chord; a plain terminal folds it onto <C-c>, which is mapped anyway.

They are overridable defaults, so your own map wins, and mapping one to an empty function turns it off:

btv.keymap.set("n", "<C-v>", function() end)  -- disable the normal-mode paste
btv.keymap.set("v", "<C-x>", '"+d')           -- add a cut to match the copy

Copying needs a clipboard to copy to: the host clipboard (Windows’, or a tool: wl-copy / pbcopy / xclip), a terminal that speaks OSC 52 (the ssh case), or the browser’s navigator.clipboard. With none of them a copy says so rather than quietly going nowhere.

The intro screen

Started with no file, bemtvi shows an intro screen the way vim and neovim do: the app icon, the name and version, and the commands to quit and to manage plugins. The icon is drawn with text, two pixels per cell in half block characters (▀ / ▄), so it needs a truecolor terminal; elsewhere only the text is shown. It sits beside the empty [No Name] buffer without touching it, and goes for good on the first edit, on :e file, or when a split shows the buffer twice.

To start without it:

btv.g.intro = false

:intro (or btv.intro.show()) draws it on demand in any empty, unnamed buffer; :enew gives you one. Its colours come from BtvIntroTitle, BtvIntroText and BtvIntroKey, which link to Title, Comment and Special unless your config or colorscheme defines them. examples/intro walks through it.

Option scopes — which buffers a setting reaches

A buffer-local option (tabstop, expandtab, foldmethod, …) has two values: the local one on each buffer, and the global one every newly created buffer is born from. Which you write is what decides whether a config line applies to the files you open later — the same model as vim:

vim.opt.tabstop = 3        -- :set       — this buffer AND the global value
vim.opt_local.tabstop = 8  -- :setlocal  — this buffer only
vim.opt_global.tabstop = 2 -- :setglobal — the global value only

vim.o and vim.opt are the ones a config almost always wants: a vim.opt.tabstop = 3 in init.lua sets the buffer you happen to be in and the value every file opened afterwards starts from. vim.bo / vim.wo (and vim.opt_local) are the per-instance surfaces — what a filetype rule uses, so one buffer’s indent does not become everyone’s default:

btv.on("FileType", { pattern = "go", callback = function()
  vim.opt_local.expandtab = false   -- Go files only
end })

Reads follow the same split: vim.o.tabstop / vim.bo.tabstop report the current buffer’s value, vim.go.tabstop the global one. The ex commands :set / :setlocal / :setglobal are the exact equivalents, and :setglobal x? reads the global value where :set x? reads the buffer’s. The by-name API spells the same three: nvim_set_option_value(name, value, {}) is a :set (both tiers), and a scope or a buf / win target narrows it to one of them.

vim.api.nvim_set_option_value("tabstop", 3, {})                    -- :set
vim.api.nvim_set_option_value("tabstop", 8, { scope = "local" })   -- :setlocal
vim.api.nvim_set_option_value("tabstop", 2, { scope = "global" })  -- :setglobal

Three buffer options — commentstring, foldexpr and foldmarker — resolve their global value as a fallback when the buffer has none of its own, rather than copying it at creation. So a :setglobal commentstring=… reaches buffers that are already open, where a :setglobal tabstop=3 only reaches ones created afterwards. A buffer that sets its own with :setlocal still wins either way. (This is a deliberate departure from vim, where every buffer always holds a local value.)

A few buffer options have no global value, because the read decides them — fileencoding, bomb, fileformat, endofline — as does modifiable, a per-buffer marker, and the two nouns derived per buffer, filetype and ts_highlight. :setglobal on one of those tells you so instead of storing a value nothing would read.

Window options (number, scrolloff, signcolumn, …) carry the same two tiers, with one extra rule from vim: a split copies the window it came from, so your init.lua settings follow you into new splits whichever tier you wrote. The global value is what :setglobal / vim.go read, and what a window created with no source window to copy — a dock, the quickfix tab — is born from.

Plugins — the built-in :Plugins manager

Dropping a checkout under pack/*/start/* works, but the ergonomic path is the built-in package manager: there is no third-party manager layer because the manager ships with bemtvi. You declare a set of plugins in init.lua with btv.plugins{}; it clones/updates them over the async runtime (via btv.git_local — first-party gix, no git binary) and loads each one — adds its directory to the runtimepath so require and its colors/ / queries/ / lsp/ resolve without a restart, sources its plugin/ scripts, and runs its config. Nothing blocks: every step is a promise, so the UI paints before plugins finish loading.

-- ~/.config/bemtvi/init.lua
btv.plugins({
  -- "owner/repo" shorthand expands to a GitHub clone.
  { "bemtvi/bemtvi-keys-helper",
    config = function() require("bemtvi-keys-helper").setup({}) end },

  -- Lazy-load on a trigger: any of cmd / event / ft / keys makes it lazy.
  { "someone/markdown-tools", ft = "markdown" },

  -- Pin a ref, rename, add dependencies.
  { "owner/repo", tag = "v1.2.0", name = "repo",
    dependencies = { "owner/dep" } },
})

Each spec is the repo ("owner/repo" shorthand, or src / url, or a local dir) plus optional fields: name, branch, tag (alias version), commit, dependencies (alias deps), enabled, init (run before load) and config (run after). Lazy triggers — cmd, event, ft, keys — defer loading until first use; set lazy = false to force eager load even with a trigger. Clones land under the data dir (not your config repo), which the manager owns.

Run :Plugins to open the dashboard — a lazy.nvim-style floating UI listing every declared plugin grouped by load state, with live per-plugin progress (a spinner while a clone/pull runs, ✓/✗ on finish) and verb keymaps in two scopes. Upper-case acts on everything: I install · U update · S sync · R restore · X clean (plus <C-r> refresh, <CR> details, q quit). Lower-case acts on the plugin under the cursor: i install · u update · s sync · r restore · x remove (delete just that clone — x then i is the “give me a fresh copy” move). The same operations are available as ex-commands:

CommandAction
:PluginSyncRealize the declared + locked state: install missing, check out what the lockfile pins, fast-forward the rest
:PluginInstallClone any declared plugin not yet on disk
:PluginUpdateFast-forward every unpinned plugin, advancing past the lockfile
:PluginCleanRemove cloned dirs no spec declares
:PluginLockRecord every installed plugin’s commit to the lockfile
:PluginRestoreCheck out every plugin at the commit the lockfile records
:PluginListPrint a one-line status (installed / loaded / missing) per plugin
:PluginsWelcomeReopen the first-run offer of recommended plugins

Every verb command above (all but :PluginList / :PluginsWelcome) takes an optional plugin list — :PluginUpdate bemtvi-tree, :PluginSync bemtvi-dap bemtvi-line — and <Tab> completes the declared names. Scoped that way it acts on those plugins (and their dependencies, which a plugin needs to load at all) and leaves every other plugin’s checkout and its lockfile entry exactly as they were: you get the one fix you were waiting for, not eleven other people’s changes. The same scope is a plugins option on the Lua verbs — btv.plugins.update({ plugins = "bemtvi-tree" }). :PluginClean is the one verb whose scope does not pull in dependencies (deleting a checkout nobody named is destruction by inference), and it refuses outright to delete a local dir plugin’s checkout — that is your own working tree, not a managed clone.

bemtvi ships minimal; on a fresh setup it offers a recommended first-party set as one decision, with c opening a checklist to pick individually — see Recommended plugins for what’s in it. See Writing plugins for authoring your own.

The lockfile

Every install / update / sync records the commit each managed plugin resolved to in <config>/bemtvi-lock.json, and installing reproduces those commits. Commit it alongside your init.lua: config + lockfile is the pair that pins the exact plugin tree, so a second machine gets the same code instead of whatever each remote’s HEAD happens to be that day.

{
  "catppuccin": { "branch": "main", "commit": "0b0a9a1…" },
  "bemtvi-line": { "commit": "ada94b5…", "tag": "v2.1.0" }
}

It is generated, so treat it as output: a flat map keyed by plugin name, encoded with sorted keys and 2-space indentation so a diff is one line per plugin that moved. An entry records the commit and the declaration it resolved — the branch the plugin tracks, the tag it was pinned to — which is what lets a changed spec invalidate it (below). A dev dir plugin never contributes a commit (a working checkout is not a reproducible artifact), and neither does one that isn’t installed yet — but neither loses an entry another machine wrote for it, since the file you commit must not be strip-mined by whichever machine happens to sync. Only a plugin your config no longer declares is dropped. btv.plugins.lock() writes it on demand (:PluginLock) and btv.plugins.locked() returns the current contents; a malformed lockfile — including an entry with no commit — is a hard error naming the file rather than being treated as “nothing pinned”. Relocate it with btv.plugins.setup_manager{ lockfile = … }.

Which revision wins. Highest first:

  1. commit = "…" in the spec — a hand-written pin is an instruction, and always beats the lockfile, which is only a record.
  2. the lockfile entry — it outranks the floating refs below, because pinning a floating ref is exactly what it is for.
  3. tag / version, then branch.
  4. the remote’s default branch.

…but only while the entry still records what your spec asks for. Bump tag = "v1" to "v2", or point branch somewhere else, and the entry describes a resolution of a question you are no longer asking: it is discarded and the new ref re-resolved, the way Cargo.lock gives way to a changed Cargo.toml. Otherwise a record would outrank the config that produced it, and your edit would be silently ignored forever.

A plugin with no lock entry and no pin still gets a fast depth = 1 clone; reaching a locked commit is the only reason to pay for a full one. If the lockfile names a commit the remote no longer has (force-pushed away), the install fails loud and names the file to fix — quietly installing something other than what the lockfile says would defeat the point of having one.

Reproduce vs advance — the two verbs mean different things, like cargo build and cargo update:

  • :PluginSync reproduces the lockfile. Missing plugins are installed at their locked commits, and a plugin whose checkout has drifted from what the file records is moved back onto it — so pulling a colleague’s newer bemtvi-lock.json and syncing gets you the tree it names. Realizing your declared state never moves a plugin past the recorded revision. (:PluginInstall only clones what is missing; it never moves an existing checkout, and records only the clones it made.)
  • :PluginUpdate advances past it: each unpinned plugin is fast-forwarded to its branch tip and the lockfile is re-recorded. A plugin sitting on a locked (detached) commit is re-attached to the branch it tracks first, so a lock is never a permanent freeze.

Neither verb moves a plugin past an explicit commit / tag pin — that is what pinning means — but both realize one: change the pin and the next sync checks the plugin out at it. And if a plugin is detached with no branch recorded anywhere (a hand-written lock entry with only a commit), :PluginUpdate fails loud rather than guessing a branch: add branch = "…" to its spec.

Going back. :PluginRestore (or R in the dashboard) checks every plugin out at the commit the lockfile records — the “that update broke my editor” verb, and the reason recording commits is worth anything. Check out an older bemtvi-lock.json from your config repo and restore to get exactly that plugin tree back.

Restore reaches commits a shallow clone does not contain: it deepens the clone (fetch --unshallow) and retries, so an unpinned depth = 1 install is no obstacle. A plugin already at its locked commit is left alone rather than re-checked-out. A commit that is genuinely gone from the remote is reported loud and by name, and that plugin’s checkout is left untouched — a rollback that silently skipped a plugin while reporting success is the one failure you must not miss.

:PluginList and the dashboard mark a plugin drifted when its checkout is at a different commit than the lockfile records — the signal that the tree no longer matches what your config promises.

Runnable examples

The examples/ directory has ~85 self-contained, end-to-end-verified configs — one per feature (treesitter, LSP, floats, registers, tabs, mouse, statusline, completion, picker, snippets, decor, docks, quickfix, image previews, …). Each is a config dir you point bemtvi at:

BEMTVI_CONFIG=examples/treesitter cargo run -p bemtvi -- examples/treesitter/sample.rs

The recommended plugins

bemtvi ships minimal by design — the editor core has no bundled plugins. Everything below is optional, installed only if you say so, and removable with one line in your config.

On a brand-new setup bemtvi offers this set once, at startup:

╭──  Welcome to bemtvi  ────────────────────────────────────────╮
│  bemtvi ships minimal by design — no bundled plugins.         │
│                                                              │
│  Install the recommended set? 11 plugins from github.com/…   │
│                                                              │
│    <CR>    Install all of them                               │
│    c       Customize — see every plugin and pick individually │
│    ?       What's in the set — opens this page in a browser  │
│    <Esc>   Skip — :PluginsWelcome reopens this any time      │
╰──────────────────────────────────────────────────────────────╯
  • <CR> installs the whole set.
  • c opens the checklist: every plugin with its exact source, pre-ticked, untickable with <Space> (a toggles all). Only what stays ticked gets installed.
  • ? opens this page.
  • <Esc> skips. bemtvi asks at most once ever — a marker under the data dir records that it asked — but :PluginsWelcome reopens the offer whenever you want it, and :Plugins opens the manager dashboard.

Whatever you accept is written to a managed lua/plugins.lua next to your init.lua, as ordinary btv.plugins specs. From then on it is your config: edit, extend, or delete any of it by hand.

Each bemtvi plugin in the set is pinned to a release (tag = "v1.0.0"), so a fresh setup gets the version this editor was tested against, not whatever the plugin’s default branch holds that day. :PluginUpdate leaves a pinned plugin where it is. To move to a newer release, change its tag. To follow the default branch instead, delete the tag line.

The set

Every one of these is a first-party plugin built on the public btv.* API — no compatibility layer, no privileged access (The btv.* model). Each links to its own repository, where the full documentation lives; once bemtvi-help is installed, the same docs are in the editor as :help <plugin-name>.

PluginWhat you getLoaded
bemtvi-lineA lualine-style statuslineat startup
bemtvi-treeThe file-explorer sidebaron <leader>e
bemtvi-keys-helperA which-key popup of what can follow the keys you typedat startup
bemtvi-helpVim-style :help, including every plugin’s own docsat startup
bemtvi-lspconfigReady-made configs for 407 language serversat startup
bemtvi-efmls-configs~150 linters & formatters, via efm-langserverat startup
bemtvi-snippetsA snippet engine + the friendly-snippets collectionat startup
bemtvi-editorconfigA project’s .editorconfig applied to its buffersat startup
bemtvi-diffSide-by-side diff & merge-conflict vieweron :DiffGit
bemtvi-markdown-previewLive markdown preview in your browserat startup
bemtvi-dapA debugger front end (DAP)on <F5>

“Loaded” is when the plugin’s code actually runs: the lazy ones cost nothing until you press the key or run the command that triggers them.

Appearance

The colours need no plugin. bemtvi’s own colorscheme, bemtvi, is built in and loads by default in the GUI, the browser and any truecolor terminal; :colorscheme neutral switches to the bundled One Dark. Every scheme neovim ships is built in too, ported to bemtvi: habamax, retrobox, sorbet, catppuccin, lunaperche, morning and the rest, plus neovim’s own theme as neovim and vim’s as vim (:colorscheme <Tab> lists them). :colorscheme default is the same as :colorscheme bemtvi. Their authors are credited in runtime/colors/README.md. Any scheme a plugin installs is a :colorscheme away as well.

bemtvi-line — configure it the way you’d configure lualine.nvim: sections a–z, a component library (mode, branch, diff, diagnostics, filename, filetype, progress, location, LSP, …), themes that recolour by mode, powerline separators. It compiles that config down onto bemtvi’s native btv.statusline registry, so the hot path stays in Rust.

Getting around

bemtvi-tree — a lazy, watched file tree in a dock: <leader>e toggles it. Full file operations, git status per entry, glob filters, mouse support, Nerd-Font icons with an ASCII fallback.

bemtvi-keys-helper — press a prefix (<leader>, g, z, <C-w>) and pause: a popup lists every key that can follow, with descriptions. It subscribes to bemtvi’s pending-key oracle rather than intercepting input, so it never disturbs the sequence you’re typing.

bemtvi-help — :help {topic} in a read-only split, with a fuzzy topic picker on <leader>fh. Its tag index is merged across the runtimepath, which is what makes every other plugin’s documentation readable in the editor: install this one and :help bemtvi-tree works.

Writing code

bemtvi-lspconfig — curated btv.lsp configs for 407 language servers, driven onto bemtvi’s own LSP control surface. It configures servers; it does not install them — you still install gopls, rust-analyzer, pyright, and friends yourself.

bemtvi-efmls-configs — presets for ~150 linters and formatters (eslint, stylua, black, luacheck, …) run through efm-langserver, which speaks their output back over LSP. Installed with languages = "*defaults", so the bundled default tool per filetype is wired lazily on first open. Needs the efm-langserver binary, plus whichever tools you want it to run.

bemtvi-snippets — the LSP snippet grammar (tabstops, placeholders, choices, mirrors, transforms) with VSCode-format collection loading. It installs friendly-snippets as a dependency, so there are snippets for most languages from the start: type a trigger, accept the completion row with <C-y>, jump between tabstops with <C-j> / <C-k>.

bemtvi-editorconfig — reads the .editorconfig files above each file you open and applies indentation, line-ending and charset settings to that buffer. No configuration; switch it off per buffer or globally with vim.g.editorconfig = false.

Reviewing and debugging

bemtvi-diff — :DiffGit diffs the current file against git HEAD; :DiffConflict opens conflict markers as a 3-way diff. Panes scroll in lockstep, changed lines are tinted, and the Lua API renders any 2- or 3-pane spec you build.

bemtvi-markdown-preview — :MarkdownPreview serves your open markdown buffers over the editor’s own HTTP mount and opens them in your browser, re-rendering as you type (no :w needed), with highlighted code fences and mermaid diagrams. Because it’s a mount rather than a bound port, it works the same in the browser build.

bemtvi-dap — a Debug Adapter Protocol client: breakpoints (conditional / hit / log), stepping, a scopes-stack-watches sidebar with inline value editing, a REPL, and multiple concurrent sessions. <F5> or :DapToggleBreakpoint loads it. You supply the adapter (debugpy, codelldb, …) and a configuration per filetype — its README shows both.

What you still need to install yourself

Plugins configure tools; they don’t ship them. Depending on what you accepted:

  • Language servers for bemtvi-lspconfig (gopls, rust-analyzer, lua-language-server, …).
  • efm-langserver plus the linters/formatters you want, for bemtvi-efmls-configs.
  • Debug adapters for bemtvi-dap.
  • A Nerd Font for the tree’s file icons (it falls back to ASCII without one).

Everything else — the picker, completion, tree-sitter highlighting, LSP itself, quickfix, terminals, docks — is already in bemtvi. See What bemtvi adds.

Changing your mind

The set is not special after installation: it is ordinary spec data in your own lua/plugins.lua.

To drop one, delete its entry from lua/plugins.lua and run :PluginClean (which removes the checkouts nothing declares any more). To add anything else, declare it the same way and run :PluginSync:

btv.plugins({
  { "owner/repo", config = function() require("repo").setup() end },
})
  • :Plugins — the dashboard: what’s declared, installed, loaded, or drifted, with install / update / sync / restore / clean.
  • :PluginsWelcome — reopen the offer and re-pick from the recommended set. Note it overwrites the managed lua/plugins.lua with your new choice.

To start from nothing, skip the offer (<Esc>) — or, if you’re writing a config for others, register your own set with btv.plugins.recommend{...}, which replaces bemtvi’s default one; btv.plugins.recommend({}) suppresses the offer entirely.

Beyond the set

Not offered on first run, but first-party and worth knowing about:

  • bemtvi-workspaces — per-project editor configuration as a committable .bemtvi/config.json (language servers, save actions, options) instead of hand-written rc Lua. See Workspaces.
  • bemtvi-remotes — teaches :connect the ssh:// and container:// schemes, so the filesystem, processes and language servers live on a remote while the editing stays local.
  • bemtvi-lspconfig-base — the same LSP configs trimmed to the most widely-used servers; a smaller alternative to bemtvi-lspconfig, not a companion to it.

See also

  • First-party plugins — the same plugins read as reference implementations: which btv.* surfaces each one is worth studying for.
  • Writing plugins — the manager’s full spec vocabulary (lazy triggers, dependencies, pinning, the lockfile) and the authoring guide.

Beyond vim — what bemtvi adds

bemtvi speaks vim at the keyboard: keystrokes, modes, ex-commands, and options track vim/neovim’s observable editing behavior. On top of that baseline it grows a handful of features vim and neovim don’t have natively — modern editing and UI surfaces that fit the modal grammar rather than fighting it.

This page is the index. Each feature below has a full guide linked from its name; the one-liner is the elevator pitch.

Editing

FeatureWhat it is
Helix mode (selection-first)An opt-in selection-first editing model (:helix): noun→verb on a persistent anchor..head range, multi-selection as the default. Motions re-select, verbs act now with no operator-pending wait — match mode, surround, regex-select, and per-selection everything.
Multi-cursor modeHelix/Sublime-style multi-editing: drop N cursors in a dedicated placement mode, then have motions, operators, visual mode, and insert all act on every cursor at once.
Keyboard macrosvim’s record-and-replay on bemtvi’s keys: <F2>{reg} records what you typed (mappings included, so a Lua keymap replays), {count}<F3>{reg} plays it back, and a failed keystroke ends the run — so 99<F3>a stops at the end of the buffer. The macro is a plain register holding readable key notation.
Fonts'guifont' as a fallback list, tried in order for every character in the GUI and the browser, each font with its own weight, scale and OpenType features (btv.ui.font). A font that isn’t installed is reported.
Modeless editingAn opt-in GUI and web editing model (:set modeless): a buffer rests in Insert, Shift with an arrow selects, typing replaces a selection, and the familiar Ctrl (Command on macOS) chords copy, paste, undo, find and open the command palette. No Normal mode to fall into; the terminal client refuses it.
The context menuA right-click opens a menu at the pointer in every session: undo, the clipboard verbs, the LSP verbs and the command palette. It is built from groups (btv.contextmenu) that each buffer lists in its own order, so a config or a plugin can reorder it, edit a group, or add one whose rows load asynchronously.
Undo stepsvim’s undo tree with GUI-sized steps: typing undoes about a word at a time (a step ends at a separator, a line break, a switch between typing and deleting, a pause, or a cursor move), while a paste, a mapping’s output, a snippet and a . repeat each stay one step.
ExpressionsHand bemtvi a small Lua expression where a fixed rule is not enough: :s/…/\=…/ computes each replacement, 'foldexpr' decides fold levels, and btv.fold.text / btv.indent.expr / btv.filetype.detect / btv.picker.scorer answer the questions the core will not guess at. They run in a stateless, deadline-bounded second Lua VM.
Smooth scrollingViewport scrolls slide instead of teleporting (neoscroll.nvim built in), interpolated client-side so it stays smooth even over a remote link. On by default.
Indent detectionA file’s own indentation sets its 'expandtab' / 'shiftwidth' on every read (vim-sleuth built in), per buffer, on every leg. On by default ('indentdetect').
Image previewsOpen an image file and the picture renders inline — ratatui-image in the terminal, a GPU texture in the GUI, an <img> in the browser.

UI surfaces

FeatureWhat it is
UI primitivesA layered toolkit for plugin UIs — a Vue-shaped reactive component model (btv.component), plugin-owned content surfaces (btv.view), ready-made async widgets (btv.ui input/confirm/select), and floating windows — all server-owned and sharing one geometry vocabulary.
Permanent docksVSCode-style editable edge panels (file tree, terminals, problem lists) that are global across tabs, toggle independently of windows, and carry their own tabs and options.
Fuzzy pickerbtv.picker: a server-owned fuzzy finder with streaming Lua sources, live (dynamic) sources, a file/location preview pane, and fully rebindable keys.
Quickfix & named-list dock tabsThe quickfix list and named lists open as bottom-dock tabs by default ('qfdock') — several searches side by side, entries jumping into the main area — with the btv.qf.* sinks and the picker’s <C-q> / <Tab> multi-select. Location lists keep vim’s split behavior.
Gutter textA column of a plugin’s own text between the line numbers and the text (the gutter_text extmark key): a diff’s old and new line numbers, a blame hash and author. It isn’t part of the line, so a sideways scroll, a click, $ and wrapping all keep working.

Platform

FeatureWhat it is
Native btv.* plugin APIbemtvi’s own Lua API where the server owns every UI surface and plugins provide data and behavior.
WorkspacesThe VSCode “open a folder” model: --workspace <dir> opens a directory as a persistent project session, restoring its layout/tabs/buffers and carrying per-workspace option overrides (btv.wso).
Browser editorThe full editor — core, the Lua VM, and the server tick — compiled to WebAssembly, running entirely client-side with no server.
Edit-host split (remote editing)Edit on a remote machine with zero typing lag: the editor and Lua run locally while an bemtvi --daemon serves the filesystem, processes, and watching over ssh or QUIC.
Lua plugin testingbtv.test (describe/it/expect + async) plus a bemtvi --test-plugin runner, so pure-Lua plugins test themselves.

Helix mode — selection-first editing

An opt-in selection-first editing model, alongside — not replacing — bemtvi’s vim grammar. Where vim is verb→noun on a point cursor (d waits for a motion), Helix is noun→verb on a persistent anchor..head range: a motion re-selects on every keystroke, and a verb acts on the current selection immediately, with no operator-pending wait. Every cursor is a selection; a bare cursor is a width-1 selection, and multi-selection is the always-on default.

This is the Helix editing model ported natively — not its keybindings bolted on. The hard part isn’t rebinding keys; it’s that a motion means something different: w moves and re-selects the next word on every selection, so wd deletes a word with no motion-wait in between. That semantic change is why the model is native Rust (a genuine core-grammar change), while the key layout ships as the bundled btv.helix plugin.

Vim behavior is untouched. Helix is per-session and off by default.

Turning it on

-- ~/.config/bemtvi/init.lua
btv.helix.enable()          -- enter Helix normal mode at startup

Or interactively: :helix toggles it (and btv.helix.disable() turns it off). Both are idempotent. The status line reads HELIX (normal) or HELIX-SEL (select); the mode codes are hn / hs, distinct from vim’s n/v so plugins and ModeChanged can tell them apart.

Search defaults to smart-case in Helix without touching the global ignorecase/smartcase; opt out with btv.helix.enable{ smart_case = false }.

The two modes

ModeStatusWhat motions do
Helix normal (hn)HELIXword/find motions move and re-select; plain char/line motions collapse to a point at the target
Helix select (hs)HELIX-SELevery motion moves only the head, extending the selection (anchor held)

v toggles between them (keeping the selection and its shape). <Esc> in select drops to normal; in normal it collapses to a point and drops secondary selections.

Motions

Motions return a range, so the selection follows the cursor as you move.

KeysSelection
w / Wthe next word / WORD plus trailing whitespace, stopping before the next word (vim’s w lands on it)
b / B, e / Eback to the previous word start / forward to the next word end
h j k l, 0 ^ $, G, arrowsplain moves — collapse to a point at the target (in normal), extend (in select)
f{c} / t{c} / F{c} / T{c}select forward/backward to (or till) {c} — and, unlike vim, they scan the whole document, matching the next occurrence on any line
; , (after a find)repeat / reverse the last find

Word motions are Helix-semantic (a line’s leading indentation is its own word; blank lines are skipped; a selection never spans a newline). W/B/E collapse the word/punct classes so a run of any non-blank chars is one WORD.

Verbs act now — no operator-pending

A verb operates on the current selection immediately, across every selection, as one undo group:

KeyEffect
ddelete the selection
cdelete and enter (multi-cursor) Insert — <Esc> resumes Helix normal
yyank (the selection stays highlighted, Helix-style)
> / <indent / unindent
=reindent
~switch case (keeps the selection)
r{c}replace every selected character with {c}
Rreplace the selection with the yank
Jjoin the lines the selection spans
p / Ppaste after / before the selection (not the cursor char); with multiple selections each pastes its own slice of the last multi-yank

So wd = select a word, delete it. wcXyz<Esc> = change a word to “Xyz”. No motion-wait ever.

Selection verbs

Transforms on the selection set itself — these have no direct vim analog:

KeyEffect
x / Xextend the selection to whole lines; repeat grows one line down / up
%select the whole file
_trim each selection to its non-whitespace content
;collapse each selection to its head (a caret)
, / Alt-,keep only the primary selection / drop the primary, keep the rest
Alt-;flip anchor and head
( / )rotate which selection is primary through document order
Alt-( / Alt-)rotate the contents (text moves between selections; ranges stay put)
&align every selection’s start onto the same column (“align the = signs”)

Multiple selections

Multi-selection is the default, so most verbs already fan out. To grow a selection set:

  • C / Alt-C — copy the primary selection onto the next / previous line(s), each copy becoming the new primary (walk it down/up with repeats).
  • The regex spawners below (s / S).

Insert entry fans out too: i a I A collapse each selection to its insert point and open a multi-cursor Insert at all of them; o / O open a fresh line at every selection.

Match mode (m) — brackets, text objects, surround

m opens a sub-grammar:

KeysEffect
mmjump to the matching bracket (like vim’s %)
mi{o} / ma{o}select the inner / around text object {o} at each selection
ms{c}surround each selection with the {c} delimiter pair
md{c}delete the {c} pair surrounding each selection
mr{from}{to}replace the {from} surrounding pair with {to} (two-stage, with a live preview)

The text-object alphabet is the full shared set — vim objects (w W p s, pairs ( { [ <, quotes " ' `) and the tree-sitter captures f function / a argument / c comment / t class — so mif selects the enclosing function body, maf the whole function, mia the argument. (Requires a grammar with textobjects.scm; btv.textobject.map keys work here too.)

Regex selection (s / S / K / Alt-K)

Each opens a /-style prompt that transforms the selection set; the would-be result previews live as you type:

KeyEffect
sselect each regex match within the selection (one selection per match)
Ssplit the selection on the regex
K / Alt-Kkeep / remove selections that contain a match

s over % (whole file) then a verb is the “change every occurrence” workflow — e.g. %sfoo<CR>c….

Registers, view, and more

  • Register select "{reg} — target register {reg} for the next y/d/c/p/P/R (one-shot), reusing vim’s registers.
  • View menu z — zt / zz / zb reposition the viewport around the cursor line, leaving the selection put.
  • Search / ? n N — a match becomes the selection in normal mode; in select mode each search / n adds the match as a new selection (growing a multi-selection).
  • Undo / redo — u / U.

The bundled btv.helix plugin binds the keys a bare grammar key can’t reach:

  • Insert entry — i a I A o O (all per-selection).
  • Goto menu g — gg file start, ge last line, gh / gl line start/end, gs first non-blank, plus LSP gd/gy/gr/gi (definition / type / references / implementation).
  • Space menu <Space> — the picker + LSP leader: find files, global search, buffers, document/workspace symbols, diagnostics, hover, rename, code action, jumplist, marks.

which-key

The native which-key (btv.on_key_pending, see UI primitives) lights up for Helix too — both the plugin menus (g, <Space>) and the native sub-grammars: m match mode, mi/ma (the object alphabet, tree-sitter captures included), z view, f/t find, r replace, and " register.

Rebinding

Both Helix modes share one keymap bucket, helix, which falls through to the native grammar on no match (so Helix stays fully usable with no config — the plugin only adds keys). Every verb is exposed by name through btv.helix.actions.<name>, so any key is rebindable:

-- alias X to "extend line" (x); user maps win over defaults
btv.keymap.set("helix", "X", btv.helix.actions.extend_line_below)

-- add a goto entry: gm -> last line (a count typed before it still applies)
btv.keymap.set("helix", "gm", btv.helix.actions.goto_last_line, { desc = "Last line" })

The action names mirror Helix’s own command names (extend_line_below, select_regex, flip_selections, rotate_selection_contents_forward, …), so a Helix user’s muscle memory carries over. An unknown name fails loud.

Try it

The examples/helix config is a runnable, annotated walkthrough:

BEMTVI_CONFIG=examples/helix cargo run -p bemtvi -- examples/helix/sample.txt

How it works (in brief)

Helix reuses vim’s machinery rather than forking it. A Range { anchor, head } / Selections vocabulary projects over the existing stores — the primary lives in the vim cursor + visual anchor, secondaries in the same CURSOR_NS / ANCHOR_NS extmarks the multi-cursor mode uses — so selections ride the buffer’s edit choke point and undo for free. Mode::HelixNormal / HelixSelect have their own parse step (handle_helix) but share the operator / multi-cursor engine; the one predicate Mode::shows_selection() (visual or Helix) lets Helix reuse the visual selection’s rendering and multi-cursor sweep wholesale.

For the full design — the projection seam, the grammar, the named-action registry, and every verb — see the Helix editing model design record.

Multi-cursor mode

A Helix/kakoune/Sublime-style multi-cursor, built on top of bemtvi’s vim grammar rather than replacing it. You drop several cursors, then every motion, operator, visual selection, and insert acts at all of them at once — cw, x, typing, o, p, even per-cursor visual mode.

It is single-buffer multi-editing: cursors are per-buffer and shown only in the focused window.

The two phases

Multi-cursor is split into two phases with a mode boundary between them, so you never have to think about “placement” and “editing” at the same time.

Normal ──<A-c>──▶ MultiCursor ──<Esc>──▶ Normal (cursors live) ──<Esc>──▶ Normal
                  │  motions move primary           │  motions/edits → all cursors
                  │  c / {n}c{motion} drop cursors  │
                  └─ /search keeps cursors          └─ /search or n clears them
  1. Placement — enter with <A-c> (Alt+c). The status line reads MULTICURSOR and the active cursor is recolored amber. Motions move only that active cursor — you navigate around (including /-search) and drop secondary cursors at the spots you want.
  2. Editing — press <Esc> to leave placement. The dropped cursors stay, and the status line returns to NORMAL. Now motions and operators apply at every cursor at once. A second <Esc> collapses back to a single cursor.

On leaving placement the primary cursor snaps onto the nearest placed cursor, so the final set is exactly the cursors you dropped — never an extra one where you happened to stop navigating.

Placement grammar

While in MULTICURSOR mode:

KeysEffect
<A-c> / <M-c>Enter placement and drop a cursor at the active position
h j k l w b / n …Move only the active cursor (pure navigation)
cToggle a cursor at the active cell — drop if empty, clear if already there
c{motion}Move by {motion} and drop a cursor there (cj = one line down)
{count}c{motion}Drop cursors along the motion’s span — 3cj drops on relative lines 0–3 (4 cursors)
cc / {count}ccDrop one cursor per line over count lines
<Esc>Finish placement → Normal, cursors persist (first cancels a half-typed {n}c…)

The spawn key is Alt+c, not Helix’s bare C — that’s already vim’s change-to-end-of-line. On macOS the terminal must send Option as Meta for <A-c> to arrive: in Terminal.app enable Use Option as Meta key; in iTerm2 set Left Option = Esc+.

Editing with the cursor set

Back in Normal mode with cursors live, the whole normal grammar replays at every cursor. A motion is re-resolved per cursor, so w lands at the right word for each one independently. What’s wired:

  • Motions and the operators over them — dw yw cw =w, dd yy cc, text objects like diw / ci".
  • The standalone edits — x X D C s J ~ r.
  • Insert — typing, Enter, and Backspace apply at every cursor; so do the insert-entry keys a A i I (each cursor moves to its own target column — line-end for A, first-non-blank for I) and o / O.
  • Paste — p / P with per-cursor registers: a multi-cursor yank (yy, yiw, …) captures each cursor’s own text, and a later paste gives each cursor back its own slice. When the captured count doesn’t match the live cursor count (a single-source yank, or the set changed), every cursor pastes the active register instead — plain vim p, broadcast.

Cursors that converge onto the same cell after a motion (e.g. everyone hits 0 or gg) automatically merge, so you never get a silent pile of cursors editing one spot.

Per-cursor visual mode

Each cursor carries its own selection. Press v or V over a placed set and every cursor gets a one-wide selection; visual motions extend each independently. Operators (d, y, c, …) bracket each cursor’s own selection. Visual o swaps to the other end of the selection at every cursor (there’s no visual-block mode yet, so O aliases o). <Esc> collapses the selections but keeps the cursor heads; a second <Esc> collapses those too.

Search clears the set (in Normal)

A committed search — / ? n N * # — in Normal mode is treated as navigating away: it abandons the multi-cursor session and collapses to a single cursor. In placement mode the same search instead jumps to a match so you can drop a cursor there, keeping the set. (Incsearch preview never clears anything — only the committed search does.)

Undo

Undo restores cursors to where they were before the undone edit, not where the edit shifted them. Each multi-cursor edit undoes as a single step.

Custom keymaps while placing

Placement mode has its own keymap bucket — mode code 'm' — so a binding can fire only while placing:

vim.keymap.set('m', '<Tab>', 'wc')  -- in MULTICURSOR: jump a word, drop a cursor

This is isolated the way vim isolates modes: a plain 'n' (normal) map does not fire while placing, and an 'm' map does not fire in normal mode. The all-mode '' map (:map) still covers placement. Any key the 'm' trie doesn’t bind passes straight through to the built-in placement grammar, so h/j/c/{count}c{motion}/<Esc> keep working.

How it works (in brief)

The load-bearing trick is that secondary cursors are stored as extmarks in a reserved namespace, so the buffer’s single edit choke point shifts them all for free — an edit at one cursor keeps every other cursor’s position correct with no bespoke fix-up, and cursors ride the undo snapshot. The editing phase then just replays an ordinary single-cursor command at each cursor in turn. The primary cursor stays the existing self.cursor; secondary cursors are a purely additive layer.

For the full design — the extmark model, the replay primitives (for_each_cursor / edit_each_cursor), per-cursor visual anchors, undo cursor baking, and rendering — see the multi-cursor design spec.

Keyboard macros

Record a sequence of keys once, then replay it as often as you like. The behaviour is vim’s; the bindings are bemtvi’s.

<F2>{reg}     start recording into register {reg}   (uppercase appends)
<F2>          stop recording
<F3>{reg}     play register {reg} back
{count}<F3>{reg}   play it {count} times
<F3><F3>      play the last register again
<F3>:         re-run the last ex command

While a recording is open the message line reads recording @a, exactly as vim announces it, and the macro statusline segment shows the same.

Why not q and @?

q is the close key on half of bemtvi’s read-only surfaces (a btv.view pane, a dock, the plugin dashboard), and vim’s q shadows a genuinely useful key for every user who never records a macro. So the trigger moved somewhere nothing else wants. If your fingers disagree, one line brings vim’s spelling back:

btv.keymap.set("n", "q", "<F2>")
btv.keymap.set("n", "@", "<F3>")

That works — rather than merely typing the F-key’s name — because of how recording is built: see below.

A macro is a register, and a register is text

A recording is stored as ordinary key notation in an ordinary register:

<F2>a  0ciwbeta<Esc>j  <F2>        →  register a = "0ciwbeta<Esc>j"

Which means everything you already know about registers applies. "ap pastes the macro into the buffer as editable text. :registers lists it. It persists across sessions through shada, and syncs over a daemon or browser session like any other register. And you can write one by hand, no recording needed:

btv.reg.set("a", "0ciwbeta<Esc>j")

The one thing to know: because the register is parsed as keys, playing a register that happens to hold ordinary yanked text will read any <...> in that text as a key name rather than as literal characters.

Recording captures what you typed, mappings included

bemtvi records the keys you pressed — not what they resolved to — and replays them through the keymap matcher, so the mappings fire again. This matters here more than it does in vim, because so much of bemtvi is Lua keymaps: the LSP gd, the completion triggers, every plugin’s bindings. A recording that captured only what reached the editor would silently drop them and the replay would do nothing.

So a macro over <leader>f records <leader>f and re-fires it on playback. It is also why remapping q to <F2> above works at all.

Keys that were not typed stay out: a mapping’s own RHS, nvim_feedkeys typeahead, and the keys of a macro that is playing back. Recording <F3>b stores those two keys, not everything b expands to.

A failed keystroke ends the playback

The classic idiom is to record one line’s worth of edit and then replay it far more times than there are lines:

<F2>a  I- <Esc>j  <F2>       record: prefix this line, go down
99<F3>a                       …and do that until it stops

j on the last line fails, and a failure ends the whole run — every remaining repeat and every suspended macro. Without that, the extra repeats would keep prefixing the last line. Anything vim would beep at counts: a motion already at the buffer edge, an unmatched f{char}, a search that finds nothing, any E### error.

Macros can call macros

<F3>b inside macro a suspends a, runs b to completion, and resumes. A macro that calls itself is legal — vim relies on the first failing command to end the recursion, and so does bemtvi — with a depth cap and a key budget behind it that report loudly rather than hanging.

From Lua

btv.macro.recording()   -- register being recorded into, or nil
btv.macro.executing()   -- register playing back right now, or nil (innermost)
btv.macro.play("a", 2)  -- play register a twice

vim.fn.reg_recording() / vim.fn.reg_executing() are the vim-shaped aliases (they return "" rather than nil). btv.macro.executing() is the cheap way for a plugin to skip work no human is watching — an expensive redraw during a long 100<F3>a, say.

The macro statusline segment is built in:

btv.statusline.setup({ left = { "mode", "macro", "filename" }, right = { "location" } })

Fonts

'guifont' is a list of fonts. For every character, the GUI and the browser use the first font in the list that has it, the way wezterm’s font_with_fallback does. So a coding font can come first, a Nerd Font symbols font next for icons, then a CJK font and an emoji font, and each character comes from the right one. The order is yours for every script: a CJK font you list beats the one the system would pick.

btv.ui.font({
  { "JetBrains Mono", features = { "+zero", "-calt" } },
  { "Symbols Nerd Font Mono", scale = 0.9 },
  "Noto Sans CJK JP",
  "Noto Color Emoji",
}, { size = 14 })

That writes the option, which you can also set directly:

:set guifont=JetBrains\ Mono[features=+zero\ -calt],Symbols\ Nerd\ Font\ Mono[scale=0.9]:h14

The --font flag and BEMTVI_GUI_FONT take the same syntax, for a GUI started without a config. 'guifont' wins over them once a config sets it.

Each font’s options

Options go in brackets after a font’s name, separated by ;:

OptionValueDoes
weight1–1000, or thin, extralight, light, normal, medium, semibold, bold, extrabold, blackThe weight of its regular text. Bold text uses the font’s bold.
scalea factor in (0, 4]Draws its glyphs that much bigger or smaller. A glyph that no longer fits its cell is shrunk to fit unless 'guiglyphoverflow' lets it spill into the next cell.
featuresspace-separated +tag, -tag, tag or tag=NOpenType features: +ss01 turns a stylistic set on, -calt turns contextual alternates (most ligatures) off, cv01=2 picks an alternate.

After the list, :h<size> sets the size. Other : flags (:b, :i, :w…, :#e-…) are accepted and ignored, as in Neovide.

A font draws only the weights it has, so text never leaves it for lack of one. A font with no bold face draws bold text at its nearest weight, not in another font and not with a synthesized bold. The same goes for a weight it has no face for.

When something is wrong

A malformed value is refused, whichever way you set it (:set, btv.o, btv.ui.font), and the font you had stays:

E474: Invalid argument: guifont: unknown option wieght on JetBrains Mono

A font that isn’t installed is skipped and named on the message line: guifont: not installed: Fira Kode. If it was the first in the list, the system monospace takes its place, so plain text never comes from an icon or emoji font further down.

In the browser

The browser takes the list, the size and each font’s weight and scale. It can’t set OpenType features per font, so the first font’s features apply to all the text, which is where they’re almost always wanted. Features on a later font are reported on the message line as not applied in the browser.

With no font set

The GUI uses the platform’s monospace, the first of these that’s installed: Menlo then Monaco on macOS; Cascadia Mono, Consolas then Courier New on Windows; Noto Sans Mono, DejaVu Sans Mono then Liberation Mono elsewhere. If none is, it takes any installed monospaced font with an upright face.

Symbols Nerd Font Mono comes after it, if installed, so icons work without a config. The same happens when the first font in your list isn’t installed, with your other fonts ahead of it. A list whose first font is installed is used exactly as written.

Limits

  • The terminal client draws with the terminal’s own fonts and ignores the option.
  • No fonts are bundled: the list uses what’s installed.

Modeless editing

:set modeless makes bemtvi edit the way VS Code, Sublime or a text field in a browser do. You type and the text goes in. Shift with an arrow selects, and typing over a selection replaces it. Ctrl-C, Ctrl-X and Ctrl-V use the system clipboard, and Ctrl-Z undoes about a word at a time (Undo steps). There is no Normal mode to fall into, and <Esc> never takes you out of typing.

It is a setting for the GUI and the web client. The terminal client refuses it.

In the GUI and the browser the cursor is a blinking bar while you type or select, at the moving end of a selection, and the mouse pointer is an I-beam over text, read-only text included. It stays the arrow over the line numbers, status lines and popups, and over a terminal.

-- init.lua
btv.o.modeless = true

How it works

Every buffer rests in Insert. Whatever leaves it in Normal (closing the command line, finishing an ex command, switching windows) lands back in Insert before the next key. There is no Normal mode anywhere in the session.

A read-only buffer (a file tree, the quickfix list, help, a panel) rests in Insert too, with the same caret, arrows, Shift-selection, mouse selection and keys. What it refuses is the edit: typing, <CR>, <BS>, paste, cut and undo there say E21: Cannot make changes, 'modifiable' is off and change nothing. Copy, Select All and find work as anywhere else. The keys a read-only buffer answers to (the quickfix list’s <CR>, a panel’s q) are its own maps in the modeless layer; see Plugins.

A terminal rests in the running program: its keys go to the process, and <C-\><C-n> lands straight back there. Once the program exits, the buffer is ordinary text and rests in Insert.

<Esc> closes what is open over the text (the completion popup, signature help, a snippet session) and drops a selection, and that is all.

A selection is Select mode, the mode vim’s snippets use: typing replaces it, and replacing it writes no register, so typing over text never clobbers the clipboard. A mouse drag, a double click (word) and a triple click (line) select the same way.

Undo and redo bring the selection back. Undoing a type-over, a cut or an indent leaves the text it changed selected again, facing the way you selected it, and redo returns to whatever was selected when you undid.

Keys

On macOS the layout uses Command (<D-…>) where this table says Ctrl.

KeyDoesKeyDoes
<C-z>undo<C-S-z>, <C-y>redo
<C-c>copy (the line, with nothing selected)<C-x>cut (likewise)
<C-v>paste<C-a>select all
<C-s>save<C-w>close the buffer
<C-f>find (the / line, holding the selected text; the match comes up selected)<C-g> / <C-S-g>next / previous match
<C-h>replace (a :%s/ line, holding the selected text as its pattern; <A-D-f> on macOS)<C-;>the : line
<C-p>open a file<C-S-p>command palette
<C-Tab> / <C-S-Tab>next / previous buffer<C-]>indent
<C-BS> / <C-Del>delete a word back / forward (<A-BS> / <A-Del> on macOS)<C-S-d>duplicate the line
<A-Up> / <A-Down>move the line up / down<Tab> / <S-Tab>indent / outdent a multi-line selection

The language keys are VS Code’s. They run the btv.lsp verbs, so they need a language server attached to the buffer, and they stand in for vim’s gd, gr, K and ]d, which are Normal-mode letters a modeless session can’t reach:

KeyDoesKeyDoes
<F12>go to definition<C-F12>go to implementation
<S-F12>find references<F2>rename the symbol
<C-k><C-i>show the hover<C-S-Space>signature help
<C-.>quick fix / code actions<S-A-f>format the document
<C-S-o>go to a symbol in the file<C-t>go to a symbol in the workspace
<F8> / <S-F8>next / previous problem (diagnostic)

On macOS the Ctrl ones use Command, as above; the F-keys and <S-A-f> (Shift+Option+F) are the same. <C-k> starts the hover chord, so an Insert-mode <C-k> (the signature help bemtvi binds when a language server attaches, or one of your own) now waits 'timeoutlen' for a second key; signature help has <C-S-Space> here.

The arrows move the caret the way a GUI editor does: <Right> at the end of a line goes to the next one, <C-Left> and <C-Right> move by word, <Home> goes to the first non-blank and then to column 0, <C-Home> / <C-End> go to the buffer’s ends, and <PageUp> / <PageDown> slide the view a page with the caret keeping its row on screen.

On macOS, Command with an arrow goes to the line’s edges or the buffer’s ends, and Option with Left or Right moves by word. Shift with any of them selects.

The mouse wheel scrolls the view and nothing else. The caret and any selection stay where they are, even off screen, and the first key that types or moves the caret brings the view back to it, as does an input method starting to compose. A page key pages from the caret’s place. Each window keeps its own scroll while focus is elsewhere, including one you scrolled without focusing it. Shift with the wheel scrolls sideways (down goes right), as does a tilting wheel or a touchpad; a long line only scrolls sideways with 'wrap' off. Sideways scrolls slide like vertical ones.

Dragging a selection to the top or bottom of a window scrolls it while the pointer stays there, and with 'wrap' off so does dragging it to the window’s left or right edge, so a selection can grow past what is on screen in any direction.

Each window shows where its view is: a slim thumb along the right edge for the lines in view, and with 'wrap' off one along the bottom of the text for the columns, measured against the widest line on screen (as far as you can scroll). A thumb shows only when there is something to scroll to. It marks the place and does not take clicks or drags yet. Floating windows have none.

The command palette lists every picker (Live Grep, Buffers, Find Files, and any a plugin registers with a title) and every ex command. A picker row opens that picker. A command that needs an argument opens the : line with its name typed, ready for the rest.

Every key is a default you can replace. The layout lives in its own keymap layer, modeless, which is consulted ahead of the Insert maps while a modeless session is typing or selecting, so your Insert maps (completion, snippets, your own) still work. The verbs are named, so you can bind them anywhere:

btv.keymap.set("modeless", "<C-d>", btv.modeless.actions.duplicate_line)
btv.keymap.set("modeless", "<C-s>", function() end) -- free the key

The named actions are copy, cut, paste, undo, redo, select_all, delete_word_back, delete_word_forward, indent, outdent, duplicate_line, move_line_up, move_line_down, search_next, search_prev, save, find, replace, command_line, palette, files, close, next_buffer, prev_buffer, reveal_caret (bring a scrolled-away view back to the caret), the language verbs definition, implementation, references, hover, signature_help, rename, code_action, format, document_symbol and workspace_symbol, and next_diagnostic / prev_diagnostic. In a read-only buffer the verbs that don’t edit (copy, select_all, search_next, search_prev, reveal_caret and the commands) run as anywhere else, and the editing ones refuse with E21.

The context menu

A right-click opens a menu at the pointer. A click inside the selection keeps it, so the menu acts on it; anywhere else the caret moves to the click first. The menu offers Undo and Redo, Cut, Copy, Paste and Select All, the LSP verbs when a language server is attached, and the command palette, and each row shows the key that does the same thing. A read-only window (help, the quickfix list) gets Copy and Select All.

The same menu works in a vim session, and it is built from groups you can reorder, edit and add to: see the context menu. For a key hint in a row of your own, btv.modeless.key_hint(action) gives the chord that runs a named action.

In the browser

On Windows and Linux, Chrome keeps <C-w>, <C-t>, <C-n> and <C-Tab> for itself. The web client sends <C-w> when you press Alt-W instead; <C-Tab> has no stand-in, so use the command palette for :bnext. On a Mac the client forwards the layout’s Command chords to the editor, except Command-V (the browser’s own paste does the same job) and Command-W (the browser keeps it).

Of the language keys, the browser keeps a bare F12 (its developer tools), so go to definition is in the right-click menu and the command palette there; <C-F12> and <S-F12> reach the editor. <C-t> (symbols in the workspace) is Alt-T on Windows and Linux, like <C-w>, and has no key on a Mac, where the browser keeps Command-T.

Plugins

A plugin with a read-only buffer (a tree, a sidebar, a results list, a dialog) binds its keys for both models. A modeless session never reaches Normal mode, so an "n" map never fires there, and nothing guesses one into the modeless layer for you. Bind the same keys in both:

local view = btv.view.create({ name = "[Tree]" })
-- …
btv.keymap.set({ "n", "modeless" }, "<CR>", open_entry, { buffer = view })
btv.keymap.set({ "n", "modeless" }, "q", close_tree, { buffer = view })

buffer = view binds to the view’s buffer, which is created after the chunk that creates the view, as soon as it exists. Until then view:bufnr() reads false, and a buffer option given false is an error rather than a global map.

A printable key such as q is safe in the modeless layer on a read-only buffer: typing is refused there anyway, so the map takes nothing away. A buffer-local map in the layer beats the layout’s own key on the same chord, so a view can bind <C-v> to “open in a split” without losing it to paste. btv.view installs its <CR>, and with an on_select its double-click, this way, and so do the quickfix list, the panels, the explorer and the :Plugins screens. On an editable buffer, keep to keys that don’t type (chords, function keys), since the layer comes ahead of typing.

The rest of a plugin written for the vim model mostly works unchanged. Three things differ in a session that never leaves Insert:

  • TextChanged and InsertLeave don’t fire while you type; TextChangedI does. A [+] indicator should listen to BufModifiedSet, which fires the same way in either model.
  • btv.diagnostic.config({ update_in_insert = false }) (“hold until InsertLeave”) holds updates until typing goes quiet for 3 seconds instead.
  • btv.cmd("normal! …") and keys fed with btv._feedkeys still run as Normal-mode keys: an <Esc> in them leaves Insert, and the session comes back once they end.

btv.ui.caps() reports client ("gui", "web", "tui") and platform ("macos", "linux", "windows"), which is how the layout picks Command.

Limits

  • The terminal client: :set modeless fails there, and a terminal attaching to a session that has it on turns it off.
  • Helix mode and modeless editing can’t both be on.
  • Several cursors at once (<A-c> placement) keep their own motions; the GUI gestures for them (Ctrl-D, Alt-click) aren’t bound yet.

The context menu

A right-click opens a menu at the pointer, in every session: vim or modeless, the GUI, the browser or the terminal. Before the menu opens, the click moves the cursor to where you clicked. A click inside the selection keeps the selection instead, so the menu acts on it.

In an editable buffer the menu offers Undo and Redo; Cut, Copy, Paste and Select All; the LSP verbs while a language server is attached; and the command palette. A read-only buffer (help, the quickfix list) offers Select All, plus Copy: always in a modeless session, and when text is selected in a vim one.

One click chooses a row, and the row under the pointer is highlighted. A click or right-click anywhere else dismisses the menu, and a right-click opens a new one where it landed. The keys work too: <Down> / <Up> move, <CR> chooses and <Esc> dismisses.

A runnable tour of everything below, with its own spec, is examples/context-menu/:

BEMTVI_CONFIG=examples/context-menu cargo run -p bemtvi -- examples/context-menu/sample.txt

Groups

The menu is built from groups. Each buffer has its own ordered list of group names, and each name refers to a registered group that gives rows. Groups that give no rows are dropped, and a separator goes between the rest.

btv.contextmenu.list(0)   --> { "undo", "clipboard", "lsp", "commands" }
GroupRowsModeless sessionVim session
undoUndo, Redothe modeless verbs:undo / :redo
clipboardCut, Copy, Paste, Select Allthe modeless verbs"+d, "+y, "+P, ggVG
lspGo to Definition, Find References, Rename Symbol, Format Document, Code Actionsbtv.lsp.*btv.lsp.*
commandsCommand Palettethe palettethe palette
  • Helix’s modes are a vim session here, with Helix’s % for Select All and x then y for Copy with nothing selected, which leaves out the line’s end.
  • undo, and Cut and Paste, show only in a buffer that is 'modifiable'.
  • Copy with nothing selected copies the line, in any modeless buffer and in an editable vim one. A read-only buffer in a vim session needs a selection to copy. In a vim session Cut needs a selection too; a modeless Cut takes the line.
  • lsp shows while a server is attached, and greys out a verb no attached server answers.
  • In a modeless session each row shows the key that does the same thing, read from the live layout, so a rebound key shows its new chord. A vim session shows no keys: vim’s keys are verbs plus motions, not one chord.

Changing the menu

A buffer’s list is live: edit it and that buffer’s menu changes, and no other’s.

local list = btv.contextmenu.list(0)
table.insert(list, 1, "commands")       -- the palette first
btv.contextmenu.set(0, { "clipboard" }) -- or replace the list
btv.contextmenu.set(0, {})              -- no menu in this buffer

A buffer gets its list the first time it’s asked for: a copy of btv.contextmenu.default for a 'modifiable' buffer, or of btv.contextmenu.default_readonly otherwise. Edit the defaults in your config to change what every buffer starts with:

table.remove(btv.contextmenu.default, 1) -- no Undo / Redo in new buffers

A group is a table you can change. Add a row, remove one, or hide the whole group:

table.insert(btv.contextmenu.groups.commands.items, {
  label = "Save",
  action = function() btv.cmd("write") end,
})
btv.contextmenu.groups.undo.when = false

Adding a group

A group has items, the rows, and an optional when. items is a list, or a function that returns one, or returns a promise of one:

btv.contextmenu.groups.file = {
  when = function(ctx) return btv.buf.name(ctx.buf) ~= "" end,
  items = function(ctx)
    local path = btv.buf.name(ctx.buf)
    return btv.fs.stat(path):next(function(st)  -- answers later
      return {
        { label = "Copy Path", detail = st.size .. " bytes",
          action = function() return btv.clipboard.write(path) end },
      }
    end)
  end,
}
table.insert(btv.contextmenu.default, "file")

A row has:

Field
labelThe row’s text. Required.
actionfunction(ctx), run when the row is chosen. It may return a promise. Required.
detailThe right-hand hint: a string, or function(ctx) returning one or nil.
whenHide the row unless this is true: a boolean, or function(ctx).
enabledGrey the row out unless this is true: a boolean, or function(ctx).

A group’s when hides the whole group the same way. A field a group or a row doesn’t know about is an error that names the group, so a typo doesn’t silently drop a row. So is a name in a list with no registered group. Lists hold names, so a group registered after a buffer’s list was made shows as soon as it exists.

The menu opens once every group has answered. Rows keep list order, whichever group answers first.

ctx is built once per right-click and passed to every items, when, enabled, detail and action:

{
  buf = 3, win = 1000,        -- the clicked buffer and window
  mouse = btv.getmousepos(),  -- the click
  mode = "n",                 -- the mode once the click has moved the cursor
  selection = false,          -- Visual or Select is active
  modifiable = true,          -- the buffer can be edited
  modeless = false,           -- a modeless session
}

For a modeless verb’s key hint in your own row, use btv.modeless.key_hint:

{ label = "Save", action = btv.modeless.actions.save,
  detail = function(ctx) return ctx.modeless and btv.modeless.key_hint("save") or nil end }

Opening it yourself

btv.contextmenu.open() opens the current buffer’s menu at the pointer, which is what a right-click does. From a key with no click behind it, the menu opens at the text cursor. It returns a promise that settles once the chosen row’s action has run, or once the menu closed. btv.contextmenu.entries(buf) returns a promise of the rows a buffer’s menu would show right now, without showing it.

A menu of your own

Groups are the one way to put rows in the right-click menu. For a buffer that should show only your rows, set its list: btv.contextmenu.set(buf, { "mine" }). To take the right-click over entirely, bind a buffer-local <RightMouse>: a map replaces a key’s default, so no menu opens.

Underneath, a right-click fires the MenuPopup event (see autocmd events), as vim does just before its popup menu shows, and this menu is what opens. Don’t open a second menu from a MenuPopup handler: only one menu shows at a time, so it would replace this one or be replaced by it.

Undo steps

bemtvi keeps vim’s undo tree but not vim’s undo steps. In vim, everything you type between entering Insert and pressing <Esc> is one step, so u after a paragraph takes the whole paragraph. bemtvi breaks typing into the steps a GUI editor uses, roughly a word at a time. This is the same in every mode, Helix included. There is no option to get vim’s grouping back.

ifoo bar baz<Esc>   then  u  →  foo bar
                          u  →  foo
                          u  →  (empty)

Where a step ends

You start a new step when you:

You…Exampleu takes back
type a separator after a wordfoo bar bar (the space goes with the word after it)
type the first character of a new linefoo<CR>barbar, then foo and the line break
switch between typing and deletingfoo<BS><BS>xx, then both <BS>s, then foo
pause for more than a secondfoo, wait, barbar
move the cursor, then editfoo<Left>XX

A run of separators stays together, so foo bar undoes bar in one step. Punctuation counts as a separator: foo.bar(x) is four steps.

Moving the cursor counts however it happens: an arrow, <Home>/<End>, a mouse click, a jump, or a plugin calling btv.win.set_cursor. An i_CTRL-O command is a step of its own, even one that doesn’t move the cursor.

What stays one step

  • A Normal-mode command, including the text a c command types. cwnew<Esc>u restores the old word in one u. With more than one word typed, the last ones come off first.
  • A paste, a register insert (<C-r>{reg}, <C-r><C-w>), and an <C-r>= result. The typing after one of these is a new step.
  • A mapping’s output. One key pressed is one step, however much text its right-hand side types, for a string RHS, an <expr> RHS, or a Lua function that feeds keys. A mapping that changes nothing leaves the word you were typing in one piece.
  • A snippet expansion. Typing over a placeholder, or into an empty tabstop, is a separate step, so u puts the placeholder back before it removes the snippet.
  • An edit made with several cursors. Each step spans every cursor.
  • A replay: ., a macro (<F3>{reg}), :normal, :g. u after . undoes the whole repeat.

The tree is unchanged

Each step is a node in the same undo tree, so g-/g+, :earlier/:later, :undo {N}, :undolist and btv.undotree all work on word-sized states. 'undolevels' still counts nodes, and with smaller steps the default of 1000 covers less editing than it did.

Tests and fed keys

Keys a plugin feeds with btv._feedkeys are one step per run, like a mapping. Keys fed as typed are the keyboard: the btv.test runner’s t:feed uses them, so a spec that types ifoo bar<Esc>u sees foo, the same as you would.

Expressions

There are places where a fixed rule is not enough and a whole plugin is too much: how a substitution builds each replacement, what a collapsed fold says, which filetype a .h file really is. For those, bemtvi takes a small Lua expression and evaluates it where the answer is needed.

-- double every number on every line
:%s/\d+/\=tonumber(m[0]) * 2/g

-- a collapsed fold shows its first line and how much it hides
btv.fold.text([[ first:gsub("%s+$", "") .. "  (" .. lines .. " lines)" ]])

-- a .h file with templates in it is C++, not C
btv.filetype.detect([[ ext == "h" and head:find("template", 1, true) and "cpp" or nil ]])

Each one is an expression, not a function body: it evaluates to a value, and that value is the answer. There is no return, no statements, no end. (Two are blocks — the frame-time paint and the quickfix parser — because a per-line paint loops over the line’s matches and a parser builds a record, so each ends in its own return.)

The eleven surfaces

WhereIn scopeReturns
:s/…/\=…/m, lnumthe replacement text
"= / <C-r>=line, lnum, coltext to insert or paste
'foldexpr'line, lnuma fold level (see Fold levels)
btv.fold.textfirst, lines, lnuma closed fold’s collapsed row
btv.indent.exprprev, line, lnum, sw, previndentindent columns, or nil
btv.filetype.detectname, ext, heada filetype, or nil
btv.picker.scorerlabel, query, scorea sort key, higher first
btv.complete.scorerlabel, query, score, kinda sort key, higher first
btv.decor.exprline, lnumspans to draw (a block)
btv.qf.textitem, idxone quickfix row’s text
btv.qf.parseline, lnumone entry, or nil (a block)

Everything an expression needs is handed to it. That is the whole design, and the reason for most of what follows.

Substitutions

A replacement that starts with \= is an expression, evaluated once per match. m holds the submatches — m[0] is the whole match, m[1], m[2], … the capture groups — and lnum is the line the match sits on.

:%s/(\w+)_(\w+)/\=m[2] .. "_" .. m[1]/     -- one_two      -> two_one
:%s/\w+/\=m[0]:upper()/g                   -- alpha beta   -> ALPHA BETA
:%s/^/\=lnum .. ": "/                      -- number every line

A group that did not participate in the match is nil, not "", so type(m[2]) distinguishes “matched nothing” from “did not match”.

The expression is compiled once per command and called per match, and it works the same in bulk :s, in the live inccommand preview, and in the :s///c confirm walk. The delimiter still ends the replacement, so an expression containing / needs it escaped — or a different delimiter:

:%s#(\w+)/(\w+)#\=m[2] .. "/" .. m[1]#

A number is accepted and rendered as Lua prints it, so \=tonumber(m[0]) * 2 gives 14, not 14.0.

Fold levels

'foldexpr' decides a line’s fold level under foldmethod=expr. It sees the line’s own text as line and its 1-based number as lnum.

Set it through btv.bo rather than :set, since an expression contains spaces:

btv.bo.foldmethod = "expr"
btv.bo.foldexpr = "line:find('{%s*$') and '>1' or line:find('^%s*}') and '<1' or '='"

The value is vim’s fold-level grammar, and it is worth knowing because it is how you express nesting without the expression having to remember anything:

ValueMeaning
0, 1, 2, …this line is at that level
>Na fold of level N starts here
<Na fold of level N ends here
aNN deeper than the previous line
sNN shallower than the previous line
=same level as the previous line
-1undefined — take the shallower of the levels either side

The engine carries the running level from line to line, so >1 / <1 bracket a block however deeply it is nested, and your expression only ever looks at one line.

Two ready-made values need no expression at all: btv.treesitter.foldexpr folds by the syntax tree and btv.lsp.foldexpr by the language server’s foldingRange. Both are native — they never enter the sandbox.

btv.bo.foldexpr = "v:lua.btv.treesitter.foldexpr()"

Fold text

btv.fold.text replaces the +-- 12 lines: … placeholder a closed fold shows.

btv.fold.text([[ first:gsub("%s+$", "") .. "  (" .. lines .. " lines)" ]])
btv.fold.text(nil)   -- back to the built-in

first is the fold’s first line verbatim, lines how many it covers, lnum where it starts. The result is memoized on the first line, so a screen that is not changing costs nothing.

Indentation

bemtvi indents from tree-sitter’s indents.scm wherever a grammar has one. btv.indent.expr is the escape hatch for a filetype that has no grammar, or whose indent query gets a construct wrong.

btv.indent.expr([[
  line:match("^%s*end") and previndent - sw
    or prev:match("then%s*$") and previndent + sw
    or nil
]])

Return the target indent in columns, or nil to decline. Declining matters: it hands the line to smartindent/autoindent exactly as if no expression were installed, so you only have to describe the cases you care about.

The precedence is tree-sitter → your expression → smartindent → autoindent. Structure beats a hand-written rule, so an expression never overrides a grammar that has an opinion.

Filetype from content

bemtvi resolves a filetype from a file’s name, a path pattern, or its extension. Extensions that can only be settled by looking inside the file — .h for C-vs-C++, and .r, .v, .m, .pl — are deliberately left alone rather than guessed. btv.filetype.detect is where you answer them.

btv.filetype.detect([[
  ext == "h" and (head:find("template", 1, true) or head:find("::", 1, true))
    and "cpp" or nil
]])

name is the basename, ext the extension without its dot, and head the first few lines (bounded, about 2 KB). Return a filetype to decide it, or nil to decline and leave the built-in tables to answer — a returned filetype wins over them, which is what makes the .h case work.

It runs once per buffer, and its verdict becomes that buffer’s filetype exactly as :setf would set it.

Picker and completion ranking

btv.picker.scorer reorders a picker’s results. It is handed the native fuzzy score alongside the row’s label and the active query, so the natural shape is to nudge the existing order rather than reinvent matching:

-- push test files down; leave everything else where the matcher put it
btv.picker.scorer([[ score - (label:find("/test") and 50 or 0) ]])

It re-ranks only rows that already matched, and only the top 1000 of them. Matching stays native, which is what keeps a picker responsive while 100 000 candidates stream in.

btv.complete.scorer is the same thing for the completion popup, with the row’s kind also in scope ("Snippet", an LSP kind name, or "" for a buffer word):

-- keep snippets available, but below real code completions
btv.complete.scorer([[ score - (kind == "Snippet" and 20 or 0) ]])

Its score is the blended native key — the fuzzy score plus the source’s own bias (lsp 8 > snippets 5 > buffer 0) — so nudging it composes with source order rather than replacing it. Both re-rankers run at most once per repaint, never once per streamed batch, and the completion caret follows the row it was standing on: a popup that reordered under the caret would accept a candidate nobody chose.

The expression register

"= is the register whose contents are computed. It is reachable from three places, and each prompts with = for one expression:

i<C-r>=6*7<CR>          in Insert — inserts `42` at the cursor
"=lnum*10<CR>p          in Normal — the following `p` pastes the result
:e log-<C-r>=lnum<CR>   in the command line — splices the result into the line

In scope are line (the cursor’s line text), lnum and col (1-based) — vim’s expression register can reach the whole Vimscript environment, so the pure equivalent is handed what a computed insert actually wants.

A result ending in a newline pastes linewise, anything else charwise — so "="a\nb\n"<CR>p opens two new lines below.

Two consequences of it being an ordinary register once evaluated:

  • It is evaluated when you submit the prompt, not each time it is read. The text is then stored, so getreg("=") shows it and p pastes it — but "=lnum<CR>p followed by j. pastes the first line number again. (vim re-evaluates on read; bemtvi cannot, because the paste path resolves registers through a read-only borrow while the sandbox needs a mutable one.)
  • A computed insert or paste is not .-repeatable, for the same reason :s and d/foo are not: it transits the command line. . replays the last repeatable change instead.

<Esc> at the prompt computes nothing. From a nested prompt it abandons only the expression — the command line you were typing comes back untouched, as it does when the expression fails.

Painting a line

btv.decor.expr runs over every visible line during the frame and returns the spans to highlight on it:

btv.decor.expr([[
  local out, i = {}, 1
  while true do
    local s, e = line:find("TODO", i, true)
    if not s then break end
    out[#out + 1] = { s, e, "Todo" }
    i = e + 1
  end
  return out
]])

Each span is { first, last, group } with 1-based inclusive columns — what string.find hands back, so a match drops straight in. An empty list declines the line. This is the surface that takes a block rather than an expression, for the loop above.

A span can draw more than a colour. Beside the three positional slots it takes named keys, each the scalar form of the extmark key it lowers into:

virt_text          text drawn beside the line
virt_hl            its highlight group
virt_pos           where it draws: "eol" (the default), "inline", "overlay", "right_align"
hl_mode            how it merges with the cell under it: "replace" (default), "combine", "blend"
virt_lines         a whole extra screen row, or a list of them
virt_lines_hl      their highlight group
virt_lines_above   draw them above the line instead of below
sign_text          a 1-2 cell glyph in the gutter
sign_hl            its highlight group
line_hl            a highlight group backing the whole line
line_fill          a glyph repeated from the end of the line to the right edge
line_fill_hl       its highlight group
priority           where the span sits in the paint stack (default 4096)
btv.decor.expr([[
  local s, e = line:find("FIXME", 1, true)
  if not s then return {} end
  return { { s, e, "Todo", virt_text = "  <- fix me", sign_text = ">>" } }
]])

-- a heading above every `## ` line, and a rule out to the right edge
btv.decor.expr([[
  if not line:find("^## ") then return {} end
  return { { virt_lines = line:gsub("^## ", ""), virt_lines_hl = "Title",
             virt_lines_above = true, line_fill = "-", line_fill_hl = "NonText" } }
]])

The columns and the group are each optional when the span carries a decoration — a sign anchors on the line, not on a stretch of it, so { sign_text = ">>" } is a whole span. A span that draws nothing at all, or a qualifier with nothing to qualify (virt_hl without virt_text, sign_hl without sign_text), is refused rather than ignored.

priority is the span’s own, so a highlight takes one too: draw an indent guide below the treesitter layer (priority = 90) and it colours only what the syntax left plain.

It is the pure sibling of btv.decor.provider, and the choice between them is what each can see:

btv.decor.providerbtv.decor.expr
powerfull Lua, async, any editor stateline and lnum, nothing else
whenoff the frame, one frame laterduring the frame it belongs to
drawsthe whole extmark vocabulary, styled per chunkthe same decorations, one run each

Reach for the block when the paint is a pure function of the text — indent guides, colour swatches, trailing whitespace, a keyword badge — and for the provider when it needs git, an LSP reply, or anything the line alone cannot tell it.

The paint is evaluated over each window’s visible rows and memoized on the viewport, so a steady screen costs nothing and only scrolling, resizing or editing re-evaluates.

Quickfix rows

btv.qf.text renders the rows of the quickfix window — vim’s 'quickfixtextfunc'. It is the one surface handed a record: item is the entry, keyed exactly as btv.qf.getqflist() returns it, and idx is its 1-based position.

-- the message first, the location trailing
btv.qf.text([[ item.text .. "  (" .. item.filename .. ":" .. item.lnum .. ")" ]])

btv.qf.text(nil)   -- back to vim's `file|lnum col N| message`

item carries filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid.

One row per entry is a hard rule — the row index is the entry index for <CR>, :cc and the severity colouring — so a newline inside a rendered row is flattened to a space, exactly as the default flattens a multi-line message.

It applies to the quickfix list, every named list and every window’s location list, and installing or clearing it re-renders the ones that are open.

Parsing build output

btv.qf.parse turns one line of build output into a quickfix entry — the job 'errorformat' does, in Lua rather than in a mini-language inherited from a 1990s C compiler:

btv.qf.parse([[
  local file, ln, col, msg = line:match("^(%S+)%((%d+),(%d+)%): (.*)$")
  if not file then return nil end
  return { filename = file, lnum = tonumber(ln), col = tonumber(col),
           text = msg, type = msg:find("^error") and "E" or "W" }
]])

The entry may set filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid. Any other key is an error rather than an ignored one — a parser that writes line where it meant lnum would otherwise quietly produce entries that cannot be jumped to.

Returning nil declines the line, which is not the same as dropping it: like 'errorformat', a line no pattern matched is kept as an invalid entry carrying its raw text, which is what makes :copen show a build’s prose alongside its errors.

It applies wherever 'errorformat' does — :cbuffer, :cfile, :cexpr, setqflist{ lines = … }, :make, :grep — and replaces that pass while it is installed, because two parsers disagreeing about one line has no sensible answer.

What it cannot do. The block sees one line and carries nothing between calls, because the sandbox is stateless. That rules out errorformat’s multi-line entries (%A/%C/%Z) and its directory stack (%D/%X) — exactly the features that need an accumulator. 'errorformat' stays for those, and stays the default.

What an expression can do

The expression runs in a second, deliberately tiny Lua VM. It has the value-level standard library — string, table, math, utf8, plus tostring, tonumber, type, pairs, ipairs, next, select, assert, error — and nothing else.

There is no io, os, package, require, load, dofile or debug, no coroutines, and no btv.*. An expression cannot read the buffer, touch the filesystem, or reach the editor at all. Everything it is allowed to know is in its arguments.

Why source text, not a function

You pass a string of Lua rather than a function:

btv.fold.text([[ first .. " (" .. lines .. ")" ]])   -- yes
btv.fold.text(function(first, lines) end)            -- fails loudly

The expression runs in a different Lua VM from your config, and a closure cannot cross between VMs — it would carry references to everything it captured. The source crosses instead, and is compiled on the other side.

Expressions are stateless

Nothing carries from one call to the next, and assigning a global raises an error. That is deliberate, and the reason is that none of these surfaces calls your expression once per item in order:

SurfaceWhy a counter would lie
:sthe live preview re-runs it on every keystroke you type
'foldexpr'only the rows an edit touched are re-evaluated
btv.picker.scoreronly the top survivors, re-run on each repaint
btv.complete.scorerthe same, over the popup’s rows
btv.decor.expronly the rows on screen, and only when the viewport moved
btv.fold.textmemoized, so calls are skipped outright
btv.filetype.detectonce per buffer
"= / <C-r>=evaluated once per prompt, then stored

A counter over :%s/x/…/g on a three-x line would read 16 17 18, not 1 2 3, because the preview got there first. Statelessness turns that class of bug into an error you see immediately.

Fold nesting, the one case that genuinely wants carried state, is served by the relative fold values above — the engine does the accumulating.

When an expression fails

Every failure is reported, and none of them silently produces an empty value.

A compile error is caught when you configure the expression, before it can touch anything. A runtime error, a wrong return type (a table where a string was wanted), or an expression that runs longer than its 50 ms budget aborts that use.

Surfaces that run continuously — fold text, indentation, filetype, picker ranking — report the failure once and then uninstall the expression, falling back to the built-in behaviour. An expression evaluated on every repaint cannot be allowed to print the same error on every frame.

:s and the expression register are the exceptions, because they are one-shot. A failing "= echoes, computes nothing, and leaves whatever was stored alone. A failing :s expression aborts the substitution and says so: a compile error leaves the buffer untouched; a failure partway through leaves the substitutions already made, which one u undoes, since the whole run is a single undo step.

An expression that loops forever is stopped at its deadline rather than hanging the editor, and the VM has a hard memory ceiling, so string.rep("x", 1e12) fails the call instead of the process.

Smooth scrolling

Viewport scrolls slide instead of teleporting — neoscroll.nvim’s behavior, built into the core. It is on by default and needs no plugin.

When the viewport moves more than a step — <C-d>/<C-u>, <C-f>/<C-b>, the mouse wheel, or an off-screen jump like G/gg/a search — the editor emits a scroll descriptor (the from/to lines and a duration) and the client interpolates the slide locally over its own wall clock with an ease-out curve. Because the animation runs client-side, it stays smooth even over a slow remote daemon link, and any new keystroke interrupts the in-flight slide and snaps straight to the destination.

Sideways scrolls slide too. With 'wrap' off, a pan of more than a column — the horizontal wheel (or Shift with the wheel in a modeless session), $ or 0 on a long line, a jump that lands off the side of the screen — pans smoothly, and a jump that moves the view both ways slides diagonally. The GUI pans sub-pixel; the terminal and the browser step a column at a time.

Single-line motions at the window edge, a one-column pan, typing in insert/command mode, and edits that reflow the buffer are kept crisp — only genuine viewport jumps animate.

Options

OptionDefaultMeaning
'scrollanim'trueAnimate viewport scrolls. :set noscrollanim snaps every scroll.
'scrollanimduration'160The longest a slide may last, in milliseconds. The per-scroll duration scales with the travel distance and is clamped to this ceiling; 0 disables animation entirely.
btv.o.scrollanim = true
btv.o.scrollanimduration = 160   -- ms ceiling; raise for a slower, more visible slide

'scrollanim' is also a window-local option (btv.wo.scrollanim): nil inherits the global value, false forces this window’s scrolls to snap, and true forces the slide even when the global is off. A synced side-by-side diff sets it false on its panes so a mirrored scroll doesn’t desync.

See examples/smooth-scroll/ for a runnable demo.

Indent detection

A file’s own indentation decides how you indent it. Open a tab-indented file in a spaces-by-default config and >> inserts a tab; open a 2-space file and <Tab> inserts two spaces. This is what the vim-sleuth plugin does for vim — in bemtvi it is built into the core, on by default, with nothing to install.

Every time bemtvi reads a file it looks at the leading whitespace of that file’s own lines and sets the buffer’s 'expandtab' and 'shiftwidth' from what it finds:

  • Indented with tabs → 'noexpandtab', and shiftwidth=0 (bemtvi’s “follow 'tabstop'” sentinel), because one indent level in such a file is one tab. 'tabstop' itself is left alone: how wide a tab draws is a preference the bytes cannot reveal.
  • Indented with spaces → 'expandtab', and 'tabstop' set to the step the file’s own lines show (2, 4, …), with 'shiftwidth' left at its 0 sentinel.
  • No usable evidence — an empty or unindented file, a single indented line, or tabs and spaces exactly as common as each other → nothing changes, and the style your config chose stands.

The width lands on 'tabstop', not on 'shiftwidth', and that is load-bearing. 'tabstop' is the single knob that sets bemtvi’s whole indent width — 'shiftwidth' follows it through the 0 sentinel and 'softtabstop' follows 'shiftwidth' through -1 — so a FileType autocmd doing the documented btv.bo[buf].tabstop = 2 still changes how the buffer indents. Writing the detected width straight into 'shiftwidth' would sever that chain silently: the later write would land on a knob nothing reads any more, and the file’s width would win forever.

The verdict is per buffer, not a mode: a tab-indented file and a 2-space file open side by side each keep their own.

Precedence

The file beats your config; anything set after the read beats the file.

built-in defaults  <  your config  <  the file's own indentation  <  :set / an autocmd / .editorconfig

Detection runs when the bytes become the buffer’s text — before BufReadPost — so an autocmd, an .editorconfig plugin, or you typing :setlocal noexpandtab all have the last word. Re-reading the file (:e!, an autoread reload) runs the detection again, so a file reindented on disk is picked up.

It works identically on a local file, a file fetched over a daemon link, and a file read from OPFS in the browser: the detection hangs off the point where a read’s bytes become the buffer’s text, which is core-side on every one of those legs.

Options

OptionDefaultMeaning
'indentdetect' ('idt')trueLet an opened file’s own indentation set its 'expandtab' and 'shiftwidth'.
btv.o.indentdetect = false   -- or :set noindentdetect

With it off, every buffer simply uses the 'expandtab' / 'shiftwidth' your config set, as it did before.

How the width is decided

The step, not the raw indent width, is what is counted: a 2-space file’s indent widths are 2, 4, 6, 8 — which alone look as much like a 4-space file with skipped levels — while every step between one line and the next is 2. The most common step wins, ties going to the narrower one.

Three deliberate abstentions keep a stray line from setting the style for a whole file:

  • A one-column step is never adopted as a width. It is far more often a wrapped line or prose than a convention, and a silent shiftwidth=1 is a much worse outcome than leaving the configured width alone.
  • A file must show at least two indented lines before its width is taken — one indented line is as likely a misindent as a convention. (The tabs-versus-spaces direction is still read from a single line: that much a lone indented line does say.)
  • Block-comment bodies (* continuation lines) are skipped, so the space that aligns a star under its /* neither votes for a 1-column indent nor makes a tab-indented C file look space-indented.

The scan is bounded — it stops after enough indented lines to be sure, and never reads more than the first 8192 lines — so opening a huge file costs nothing measurable.

See examples/indent-detect/ for a runnable demo.

Image previews

Open an image file and bemtvi renders the picture inline instead of its raw bytes — image.nvim’s behavior, built in, with no terminal-specific plugin to wire up. Each client draws it the native way: ratatui-image in the terminal (Kitty/Sixel/iTerm2 graphics, with a block-cell fallback), a textured quad in the GPU GUI, and an out-of-band <img> in the browser build.

The feature is opt-in through one option.

Enabling it

'imagepreview' (bemtvi-native, off by default) turns it on:

btv.o.imagepreview = true        -- or :set imagepreview

With it on, opening a file with an image extension — png, jpg/jpeg, gif, bmp, webp, tiff/tif, ico, tga, qoi, ppm/pgm/pbm/pnm (case-insensitive) — loads it as an inert preview buffer: the bytes are never decoded as text, the rope stays empty, and the window projects an image the client paints. The path is retained, so the buffer still has a name. With the option off, an image file opens as ordinary binary text, exactly as before.

There are no special commands or keymaps — opening is the usual :e <path> or a file argument on the command line.

UI primitives

bemtvi gives plugins a small, layered set of primitives for building rich interfaces — a reactive component model, plugin-owned content surfaces, ready-made async widgets, and the floating windows underneath them. They share two properties that make plugin UIs short to write and consistent to use:

  • The server owns every surface. You never write a render loop, an input grab, or frame-timing code — you hand the server data and a description of what to show, and react to results. (PUC Lua can’t do frame-time work anyway; ADR 0002.)
  • One geometry vocabulary. Floats, views, pickers, and the bottom panel all place themselves with the same size / align / margin words (see Placing things).

A file tree, a dashboard, a modal dialog, a which-key popup, a fuzzy finder — each is a few lines on top of these. Reach for the highest-level one that fits:

You want to…Reach forWhat it is
A stateful UI that redraws itself (file tree, dashboard, dialog)btv.view.component / btv.componentA Vue-shaped reactive component: state + a pure render.
Plugin-owned content you update by hand (a list, a report)btv.viewAn inert buffer you set lines on and mount in a dock / split / float.
To ask the user something (text, choice, yes/no)btv.ui.input / select / confirmAsync prompt widgets — return a promise.
To pop up read-only content (a hint, a tooltip)btv.ui.floatA bordered content overlay, dismissed by the next key.
A real, editable window placed over the layoutnvim_open_win (float form, RPC)The low-level escape hatch — a true floating window (see Floating windows directly).

Components — reactive UIs

btv.component is a Vue-shaped component model: you write reactive state and a pure render, and the framework re-runs the render automatically whenever the state changes (coalesced to one redraw per tick). It owns the whole lifecycle — waiting for the surface to be ready, batching state writes, tearing down on close — so there’s no tick-dance, no manual re-render, no buffer-number juggling.

A component is a { setup, render } table:

  • setup(ctx, props) runs once on mount and owns every side effect — reactive state, derived state, event subscriptions, key binds, data fetches — and returns the state value handed to render. It runs only after the surface is ready, so everything on ctx is valid immediately.
  • render(state) is pure: it maps state to what’s on screen and returns it. The framework re-runs it on every state change.

Both may be async — call btv.await(...) straight inside them (each runs in its own btv.async coroutine), so a setup that loads from disk, or a render that fetches to display, reads top-to-bottom.

local Counter = btv.view.component({
  setup = function(ctx)
    local s = ctx.reactive({ n = 0 })            -- writing s.n re-renders
    ctx.keymap_set("n", "+", function() s.n = s.n + 1 end)
    ctx.keymap_set("n", "q", ctx.close)
    return s
  end,
  render = function(s)
    return { lines = { "count: " .. s.n, "", "+ to increment · q to quit" } }
  end,
})

Counter.mount({ float = { width = 30, height = 4, grab = true } })

ctx carries the reactivity and lifecycle:

ctx fieldPurpose
ctx.reactive(tbl)A deep reactive proxy — writing any key schedules a re-render. (Iterate with ipairs / #, not pairs — PUC 5.4 has no __pairs.)
ctx.computed(getter)A cached derived value (read c() / c.value); re-evaluates only when a reactive input it read has changed.
ctx.refresh()Force a re-render.
ctx.propsThe opts.props passed to mount.
ctx.on_close(fn) / ctx.close()Register a teardown hook / close the instance.

Two surfaces, one reactive core

Where a component renders is a pluggable backend, so the same reactive core drives two very different surfaces:

  • "view" (the default, and what btv.view.component selects) — a focus-taking, navigable btv.view buffer, mounted in a dock, split, or grabbing float. render returns { lines, decor }. This is the file-tree / list / modal-dialog case; ctx gains keymap_set / line / set_cursor / bufnr / winid / bo / wo.
  • "float" — a non-focus popup-content float (the which-key surface). It never steals focus and binds no keys; render returns { lines, title?, relative?, border? } (rows may be styled chunks), and an empty render hides the float — so a component shows and hides purely by what it returns. Reach it with btv.component{ surface = "float", … }.
-- A self-dismissing toast on the non-focus float surface.
local Toast = btv.component({
  surface = "float",
  setup = function(ctx)
    local s = ctx.reactive({ text = ctx.props.text })
    btv.timer(function() s.text = nil end, 1500)   -- clear -> empty render -> hides
    return s
  end,
  render = function(s)
    if not s.text then return { lines = {} } end  -- an empty render hides the float
    return { lines = { { { s.text, "Comment" } } }, relative = "bottom" }
  end,
})
Toast.mount({ props = { text = "saved ✓" } })

mount(opts) instantiates the component (returning the instance, with :close()); opts.props is passed to setup, and the rest configures the surface. Render and setup errors are caught and surfaced through btv.notify rather than crashing the editor.

Views — the content surface

btv.view is the surface components are built on, and is useful on its own. A view is a plugin-owned, read-only buffer: its lines a plugin sets wholesale, the editing grammar treats it as inert (navigation works, text-mutating keys don’t), and <CR> dispatches to an on_select callback. It’s the generalization of the bottom panel — the surface a file tree, a symbol list, or any line-oriented widget mounts in a dock or a split.

local v = btv.view.create({ name = "files", filetype = "btvfiles" })
v:set_lines({ "  init.lua", "  README.md" })
v:set_userdata({ { path = "init.lua" }, { path = "README.md" } })  -- parallel to lines
v:on_select(function(line, data) btv.open(data.path, { where = "main" }) end)
v:mount({ dock = "left", size = 30 })
-- later: v:unmount()  (keeps it alive)  /  v:close()  (drops it)
MethodDoes
:set_lines(lines)Replace the content wholesale.
:set_userdata(list)Opaque per-line data (1-based, parallel to the lines); the selected line’s entry is handed to on_select.
:on_select(fn)fn(line, userdata) on <CR> / confirm.
:set_decor(ns, marks)Replace namespace ns’s extmark decoration — each mark { line, col, <extmark opts> } (0-based), so hl_group / virt_text / sign_text / … all apply.
:mount(opts)Show it — { dock = … } / { split = "vsplit"|"split" } / { float = … } / { tab = true }.
:bufnr() / :winid()The backing buffer / showing window (live, from the mirror).
:line() / :set_cursor(n)Read / move the 1-based cursor line.
:place_in(win)Adopt a reserved restore slot (used by btv.view.on_restore, below).

Persisting a view across sessions

A view opts into the workspace session by passing persist — a stable, plugin-chosen string id (instance-unique within your plugin) — to create. The editor records only the (namespace, id) pair and the view’s slot in the layout, never its content: the plugin owns what’s worth saving and stores it in its own btv.shada.plugin() store, keyed by the same id.

On restart the editor reopens the layout with each persisted view’s slot held by an empty placeholder window, then — once your plugin has loaded — calls the restorer you registered with btv.view.on_restore so you can rebuild the view and drop it into the reserved slot:

-- Save side: create with a persist id, and stash whatever you need to rebuild it.
local function open_tree(state)
  local v = btv.view.create({ name = "Files", filetype = "btvfiles", persist = "main" })
  v:set_lines(render(state))
  v:on_select(...)
  -- The plugin owns the content; persist just enough to rebuild it.
  btv.shada.plugin():set("view:main", state)
  -- Clean up the stored state if the user closes the view for good.
  v:on_close(function() btv.shada.plugin():delete("view:main") end)
  v:mount({ dock = "left", size = 30 })
  return v
end

-- Restore side: register once at load. `id` is the persist string; `place(view)` drops a
-- freshly-built view into the reserved slot instead of opening a new window.
btv.view.on_restore(function(id, place)
  local state = btv.shada.plugin():get("view:" .. id)
  local v = btv.view.create({ name = "Files", filetype = "btvfiles", persist = id })
  v:set_lines(render(state))
  v:on_select(...)
  place(v)
end)

The owning namespace is derived from your plugin’s location, exactly like btv.shada.plugin() — so two plugins can both use persist = "main" without colliding, and a persisted view whose plugin is no longer installed has its slot quietly collapsed on restore. A lazy plugin does not have to be awake for this. A reserved slot names its owner, so a restore that finds one belonging to a cmd/keys/event/ft-lazy plugin loads that plugin — the slot is itself a trigger, on equal footing with the ones you declared. You do not arm anything for it: keep bemtvi-tree behind cmd = "BtvTree" and the sidebar you quit with still comes back, its config running (and its on_restore registering) before the slot is given up on. A plugin that is enabled = false stays unloaded and its slot collapses, exactly as if it had been removed. From a context that attributes to no plugin (a bare :lua, an RPC, a test), pass an explicit namespace = "…" to both create and on_restore, the same escape hatch btv.shada.plugin(namespace) takes. GC: the editor never deletes your stored state — delete it yourself when the view is closed for good (the on_close line above). A view created without persist is ephemeral: it does not ride the session. Session persistence is a native-build feature (the web build does not restore layouts yet).

The high-level way: a persistent component. The create + on_restore + fresh-mount dance above is what btv.view.component (see Components above) automates. Pass persist = "<id>" to mount and the framework resolves the namespace once, threads it through the backing view + a per-component ctx.store, and on restart adopts the reserved slot or mounts fresh for you — no on_restore handler, no VimEnter fallback:

local Files = btv.view.component({
  setup = function(ctx)
    local s = ctx.reactive({ tree = ctx.store:get("tree") or load_tree() })
    ctx.on_close(function() ctx.store:delete("tree") end)  -- GC is still yours
    return s
  end,
  render = function(s) return { lines = render_tree(s.tree) } end,
})
Files.mount({ name = "Files", filetype = "btvfiles", persist = "main", dock = "left", size = 30 })

ctx.store is this component’s btv.shada.plugin() slice; mutate it on every change and the sidebar comes back intact. A full runnable example is examples/view-persist/. Reach for the raw create / on_restore pair only when you need a surface the component doesn’t model.

Widgets — ready-made prompts

The btv.ui widgets are the common interactions, prebuilt. The three input ones are non-blocking and promise-only (ADR 0002): the call returns at once and you react with :next(fn), or await it inside btv.async. (The vim.ui.* muscle-memory aliases keep neovim’s callback shape.)

-- input: a one-line prompt. Resolves to the text, or nil on <Esc>.
btv.ui.input({ prompt = "Rename to: ", default = vim.fn.expand("%:t") })
  :next(function(name) if name and name ~= "" then btv.notify(name) end end)

-- select: a floating chooser. Resolves to the chosen item, or nil on cancel.
btv.ui.select({ "apple", "banana", "cherry" }, { prompt = "Pick:" })
  :next(function(item) if item then btv.notify("picked " .. item) end end)

-- confirm: a yes/no dialog. Resolves to a boolean.
btv.ui.confirm("Quit without saving?", { default = false })
  :next(function(ok) if ok then btv.cmd("qa!") end end)
  • btv.ui.input(opts) — prompt label + default prefill, over the command line. "" on an empty <CR>, nil on <Esc>.

  • btv.ui.select(items, opts) — a floating list. format_item maps an item to its label (default tostring); the original item round-trips back, so an arbitrary table survives even though only strings cross the bridge. Its keys are rebindable select-mode maps (j/k/<C-n>/<C-p>/arrows nav, gg/G first/last, <CR> confirm, <Esc>/q cancel):

    btv.keymap.set("select", "<C-j>", btv.ui.select_actions.next)   -- actions: next,
    btv.keymap.set("select", "q", function() end)                  -- prev, first, last,
    ```                                                            -- confirm, cancel
    
    
  • btv.ui.menu(entries, opts) — a context menu on the same list. Each entry is { label, detail?, enabled?, separator? }. detail (a shortcut, say) sits in an aligned column on the right, a row with enabled = false greys out, and { separator = true } draws a rule. Keys and clicks skip the rows that can’t be chosen. The promise resolves to the chosen entry itself, so extra fields (an action) come back with it. anchor = "mouse" opens it at the pointer (at the text cursor when a key came after the last click), where it acts like a GUI menu: one click chooses, hovering highlights, a click or right-click off it dismisses it. The right-click menu itself is built on it: to add rows there, register a group with btv.contextmenu rather than opening a second menu.

    btv.ui.menu({
      { label = "Format", detail = "Shift+Alt+F", action = btv.lsp.format },
      { separator = true },
      { label = "Close", action = function() btv.cmd("bdelete") end },
    }, { anchor = "mouse" }):next(function(e) if e then e.action() end end)
    
  • btv.ui.confirm(message, opts) — yes/no. default = true (the default) makes <CR> Yes and shows [Y/n]; false makes it decline and shows [y/N]. For a multi-choice menu use select.

  • btv.ui.open(uri) — hand a path/URL to the OS opener (open / explorer / xdg-open), off-tick; resolves to the run result { code, stdout, stderr } (like btv.run, it resolves rather than rejects — a missing opener is code = -1).

btv.ui.float(contents, opts) is the popup-content surface — a box of read-only content (a rounded border by default; border = "none" for borderless) with no list, no selection, no input grab. By default it is transient: the next key dismisses it.

btv.ui.float("A hint.\nPress any key to dismiss.", { title = " info ", relative = "cursor" })

contents is a string (split on newlines), a list of line strings, or — for a styled popup — a list where a row is a chunk list { {text, hl_group?}, … } (neovim’s virt_text shape), so a row can colour its key one group and its description another. With persist = true it survives keystrokes and returns a handle (:update(contents, opts) / :close() / :is_open()) — the surface a key-observer plugin like which-key refreshes as keys arrive. Options: border ("none"/"single"/"rounded"/"double"/"solid", default "rounded"), title, relative ("cursor" / "editor" / "bottom").

The float surfaces

When something floats, it’s one of four distinct surfaces — worth knowing which, because they behave differently. The two axes: is it a real window? (a real window holds a buffer, so it can scroll and be focused) and does it carry a selectable list?

SurfaceWhat it isReal window?Grabs input?Scrolls?
Floating windowA free, editable window over the layout — you manage its lifecycle✅optional (grab)✅
Popup contentA lightweight display overlay (btv.ui.float) — not a window❌never❌
Popup windowA transient, read-only real window over a scratch buffer — auto-dismissed but scrollable✅never✅
List widgetThe floating selectable list — picker, btv.ui.select, btv.ui.menu, completionserver widget✅✅ (preview)

The naming is a two-axis system: “popup” marks the transient, read-only family (popup content, popup window); “window” marks a real backing window (floating window, popup window). The popup window sits in the intersection — it auto-dismisses like popup content, but because it’s a real window it scrolls (a long hover scrolls past its box) and gets syntax highlighting for free. That’s why LSP hover and signature help use it, not popup content; it has no direct Lua constructor — btv.lsp.hover / signature_help open it internally.

Placing things

Every windowed surface — floating windows, btv.view, components, pickers, the bottom panel — shares one placement vocabulary, so you learn it once:

FieldMeaning
width / heightCells (40) or a viewport fraction string — "50vw" (50% of editor width), "30vh", "50%". A fraction re-resolves on every layout, so it reflows when the terminal resizes.
alignA 9-grid word — "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right".
marginInset from the aligned corner. A number (the vertical gap; sides get ~2× to look even), or {vertical, horizontal} / {top, right, bottom, left}.
relative"editor" (the whole screen — dock bands included, so a centered float centers on the screen, not on whichever region has focus), "cursor" (anchored at the cursor, flipping for room), or "win".
border"none" / "single" / "rounded" / "double" / "solid" (plus "shadow" for btv.view floats).
titleA string drawn on the top border.
grabtrue (default for a view/component float) locks focus to it — the modal-dialog shape; false is a non-modal panel focus can leave.

For low-level parity there’s also anchor ("NW"/"NE"/"SW"/"SE") + row/col offsets, the explicit form align + margin sugar over.

Floating windows directly

When you need a real, editable window over the layout — not a view or a widget — the float form of nvim_open_win is the low-level primitive. It lives on the RPC surface (the nvim_* methods a client, test, or remote tool drives), not in config Lua: bemtvi’s Lua API deliberately omits the mutating vim.api.nvim_* entity surface (ADR 0002), so a plugin opens its float through btv.view:mount{ float = … } or a component instead — the same window with the lifecycle managed for you (pass grab = false for a non-modal panel focus can leave).

-- over RPC (a client or test):
-- win = nvim_open_win(buf, true, { relative = "editor", width = 40, height = 3,
--                                  align = "center", border = "rounded", title = " scratch " })
-- nvim_win_set_config(win, { height = 6 })   -- move / resize live
-- nvim_win_close(win, true)                  -- dismiss

From Lua the float reads are first-class: nvim_win_get_config(win) (alias of btv.win.config) reads a window’s float config back ({ relative = "" } for a tiled window), and btv.win.gettype(win) returns "popup" for a float. A float is a real window — it holds an editable buffer, splits are disallowed, and :q / focus / :only treat it as an overlay rather than part of the tiled tree.

Try it

Runnable playgrounds ship under examples/:

ExampleShows
btvchecklistA modal checkbox dialog written with btv.view.component (reactive state + pure render).
btvviewA dockable btv.view content surface with <CR> → open-in-main.
which-keyA real which-key as a surface = "float" component over the pending-key oracle.
ui-promptbtv.ui.input and btv.ui.confirm prompts.
ui-selectThe floating chooser, including items that carry data.
ui-floatPopup content (\f / \F), and LSP hover (K) through the popup window.
window-geometryThe shared size / align / margin vocabulary across every surface.
BEMTVI_CONFIG=examples/btvchecklist cargo run -p bemtvi

How it works (in brief)

A floating window is a real window carried by the same WindowOp queue and View projection as tiled windows — redraw() projects each with its rect, floating flag, border, and zindex, and every client paints the tiled windows first, then the floats in z-order. The two placement modes map onto existing FloatRelative::{Cursor, Editor} variants with no new positioning code.

The widgets sit on one server-side float-list component — completion, the picker, and btv.ui.select are thin engines over the same widget, differing only in whether they carry a prompt and a preview; for each, only a display label and an integer key cross the Lua↔Rust bridge. The component model is pure Lua: a Vue-3 reactivity core (dependency-tracked reactive / computed) over the btv.view (and popup-content) surfaces, with the render coalesced to one redraw per tick and async renders generation-gated so a slow one can’t clobber a newer one.

For the full design, see the floating-windows plan, the float-list widget spec, and the native plugin API spec.

Permanent docks

A dock is a permanent, editable window region pinned to a screen edge — like VSCode’s side bars and bottom panel. It holds normal buffer windows (you can split inside it), but unlike an ordinary split it is global — it shows on every tab — and the main editing area can never disturb it: splits, window switches, and tab changes in the main area leave it untouched.

There are four docks, one per edge: left, right, top, bottom. The top dock sits above the tabline, owning the very top rows of the screen.

Layers: main ↔ docks

The screen is split into two layers: the main editing area and the docks. <C-w> window commands act within the focused layer; the doubled <C-w><C-w> prefix is a layer switch that crosses between them.

KeysEffect
<C-w><C-w>h / j / k / lCross focus from the main area into the left / bottom / top / right dock
<C-w><C-w>l (from a dock)Cross back to the main area
<C-w>v / <C-w>s (in a dock)Split within the focused dock — a single <C-w>, as usual
<C-w>l (in a dock)Move between windows inside the dock, without leaving it
<C-w><C-w>v (from main)Cross to the last-used dock and split it

So once focus is inside a dock, every plain <C-w>{cmd} operates inside that dock; <C-w><C-w> returns you to the main area. Each dock starts on an empty scratch buffer — cross into one and start typing.

Opening and closing

Drive docks from Lua or the ex-command wrappers:

-- side is "left" / "right" / "top" / "bottom"; size is columns (left/right) or
-- rows (top/bottom); buf is an optional existing buffer (default: a scratch).
btv.dock.open({ side = "left", size = 28 })
btv.dock.open({ side = "bottom", size = 6 })

btv.dock.focus("left")   -- move focus to a dock
btv.dock.close("left")   -- drop the dock and its content
Ex-commandDoes
:DockOpen {side} [size]Open (or resize/refocus) a dock
:DockFocus {side}Move focus to a dock
:DockClose {side}Close a dock, discarding its content
:DockToggle {side}Hide if shown, show if hidden — keeping content
:DockHide {side} / :DockShow {side}The two halves of toggle, addressed individually

The ops are queued and applied after the current Lua chunk runs (the editor’s “Lua queues, core mutates” flow), so docks opened in init.lua appear on the first frame.

Toggle vs. close

These are different on purpose:

  • :DockClose (and btv.dock.close) drops the dock and its content.
  • :DockToggle / :DockHide / :DockShow (and btv.dock.toggle/hide/show) collapse a dock from view while keeping everything — its splits, tabs, cursor, and text all come back exactly as they were when you show it again.

A collapsed dock isn’t gone: it leaves a ▸LABEL chip on the command-line row (bottom-left, when idle). Click the chip to bring that dock back.

Toggling from a keymap uses the same path as the ex-command:

vim.keymap.set("n", "<leader>e", function()
  btv.dock.toggle("left")
end, { desc = "toggle the left explorer dock" })

Per-dock options

Docks have their own option scope, alongside btv.bo / btv.wo / btv.o. Set options inline in btv.dock.open{...} or after the fact through btv.dock.opt(side); reads return the cached value (or its default).

btv.dock.opt("left").title = "EXPLORER"   -- a fixed strip label
btv.dock.opt("left").showtabline = 2      -- always show the dock's own tabline
btv.dock.opt("bottom").autohide = true    -- collapse when focus leaves
btv.dock.opt("left").size = 40            -- resize live
OptionMeaning
sizeWidth (left/right) or height (top/bottom); settable live to grow/shrink
titleA fixed strip label, shown ahead of the dock’s tab cells
showtablinePer-dock override of the global option (0 never / 1 if >1 tab / 2 always)
laststatusPer-dock statusline override (0/1/2/3)
autohideCollapse the dock the moment focus leaves it; it pops back when you cross in
winhighlightPer-window highlight remap ("Normal:NormalSB,EndOfBuffer:Hidden") so a dock paints like a sidebar — see examples/dock-winhighlight

Setting an unknown option warns loudly rather than silently ignoring it.

autohide is great for a panel you want out of the way until you need it — a terminal tray, say. It collapses as soon as focus leaves and re-appears when you cross back in (<C-w><C-w>j) or run :DockShow {side}.

Per-dock tabs and tablines

Each dock — and the main area — has its own independent tab stack and tabline. Focus a dock and :tabnew opens a tab inside that dock; its strip lights up on its own, driven by that dock’s showtabline. Clicking a dock’s tabline switches tabs within that dock. (Design: the per-region tablines spec.)

Try it

A runnable playground ships in examples/dock:

BEMTVI_CONFIG=examples/dock cargo run -p bemtvi -- examples/dock/sample.txt

It opens a titled left side bar and an autohide bottom tray, maps <leader>e to toggle the explorer, and walks through the layer-switch keys interactively.

How it works (in brief)

A dock is a parked WindowTree swapped onto the live self.windows whenever it gains focus — the same trick tab pages use to keep one tree active. Because every split / close / focus / edit / redraw reads from self.windows, they “just work” inside the focused dock with no retargeting. Geometry reserves the edge bands before laying out the main area, and each client maps a window’s region (Main / DockLeft / …) to its absolute screen origin. One region is not a band: an editor-relative float is laid out against the whole windows area and tagged Screen, so a centered dialog centers on the screen and paints over the dock bands rather than being boxed into whichever region opened it.

For the full design — the layer-swap model, geometry, <C-w><C-w> parsing, and the cross-client rendering — see the permanent docks plan and its per-region tablines follow-up.

Fuzzy picker

btv.picker is bemtvi’s native fuzzy finder — a centered float with a prompt that grabs every key, a Rust fuzzy matcher that re-ranks as you type, and an optional preview pane. The server owns the widget: the prompt, the matcher, navigation, and a generation token that drops a stale response for a query you’ve already typed past. No input loop runs in Lua — a source is just a thin driver that streams candidates in and handles confirm.

It ships with a set of built-in sources — files, files_git, live_grep, live_grep_git, buffers, curbuf, diagnostics, keymaps, marks, jumplist, pickers — and registering your own is a few lines.

File finding and grepping come in pairs:

  • files_git / live_grep_git respect .gitignore (and .ignore / .rgignore) — rg --hidden, so a dotfile like .github/workflows/ci.yml is still found and only ignored paths drop out. Without rg they fall back to git ls-files / git grep inside a work tree, to plain find / grep outside every repository (no .git at or above the directory, so, as for rg, no .gitignore applies), and otherwise (git missing or refusing the repository, or the pure web client) to a btv.fs walk that reads the ignore files itself (btv.fs.walk{ gitignore = true }) — so the ignore rules hold everywhere. The git grep leg needs git 2.19 or later (--column); with an older git, live_grep_git says so instead of searching. A basic grep (busybox, stock Alpine: no --null, -I or --exclude-dir) still works, but a file name holding :<digits>: is cut short there. These are the everyday <leader>ff / <leader>fg.
  • files / live_grep search unrestricted — the equivalent of rg -uu (--no-ignore --hidden), so a .gitignored build artifact is still findable. The one exclusion is .git itself. These are <leader>fF / <leader>fG.

Either way, narrowing a search is the filter boxes’ job (<C-g>) — per-search, so hiding target/ this time never makes a file unfindable the next.

Using a picker

The built-in sources are bound out of the box, plus a resume map:

MapSource
<leader>fffiles_git — fuzzy file finder, .gitignore respected
<leader>fFfiles — fuzzy file finder over every file, ignored ones too
<leader>fglive_grep_git — live grep, .gitignore respected
<leader>fGlive_grep — live grep over every file, ignored ones too
<leader>fbbuffers — open buffers (scoped to the focused layer, like :ls)
<leader>f/curbuf — fuzzy find in the current buffer
<leader>fddiagnostics — diagnostics
<leader>fkkeymaps — keymaps
<leader>fmmarks — marks
<leader>fjjumplist — the jumplist
<leader>fipickers — the registered pickers themselves
<leader>frresume — reopen the last picker where you left off

These are overridable defaults — your own map for the same key wins, and you can disable one by binding it to an empty function. To open any registered source from your own keymap, call btv.picker.open:

btv.keymap.set("n", "<leader>o", function() btv.picker.open("files") end)

Resume — <leader>fr

<leader>fr (telescope’s resume) reopens the most-recently-closed picker restored to exactly where you left off — the same displayed rows, prompt text, highlighted row, and multi-select marks. The server replays a frozen snapshot it captured at close, so a live_grep picker comes back with its actual previous results rather than a fresh, differently-ordered search; editing the query from there re-runs the source as usual. It’s a no-op (with a gentle notice) before any picker has closed. Call it from your own map with btv.picker.resume().

Transient internal pickers (the command-line completion overlay, for instance) opt out by setting resumable = false on their source, so resume always points at the last real picker.

In the open picker (all of these are rebindable — see Keys):

KeyAction
(printable)Edit the query — the document is never touched
<C-n> / <Down>Next item
<C-p> / <Up>Previous item
<CR>Confirm — run the source’s action on the highlighted item
<C-t>Confirm in a new tab — open the highlighted item in a fresh tab
<C-x>Confirm in a horizontal split
<C-v>Confirm in a vertical split
<Esc>Cancel
<Tab> / <S-Tab>Multi-select — mark/unmark this row and advance (see Sending results to a list)
<C-q>Send the results (marked, else all filtered) to a named list <picker>:<query>
<C-d> / <C-u>Scroll the preview pane half-page down / up
<C-f> / <C-b>Scroll the preview pane a page down / up

Sending results to a list

<C-q> sends the picker’s current results to a named list keyed <picker>:<query> — bemtvi’s take on telescope’s send-to-loclist, and a fast way to turn a search into a working set you step through with <CR> in the list. Each distinct search is its own persistent dock tab (re-running the same search updates it in place); a named list never collides with the quickfix and survives closing the window you sent it from. See named lists.

  • Filtered, not everything. It sends the rows matching your live query — what you see — not every candidate the source streamed.
  • Multi-select. Mark individual rows with <Tab> (it marks and advances; <S-Tab> too). Marks are kept by item, so they survive further typing / re-ranking. When any rows are marked, <C-q> sends only the marked ones (in mark order); with none marked it sends the whole filtered list.

Where the list opens is governed by the 'qfdock' option (on by default, the bemtvi way): each send opens as a tab in the bottom dock, so several searches sit side by side, and <CR> on an entry jumps into the main editing area. Set :set noqfdock for a bottom split instead. See Quickfix & named dock lists for the full model and the btv.qf.list / show API the action builds on.

Writing a source

btv.picker.source{...} registers a source. The driver, items(ctx), streams candidates by calling ctx.push(item) per result and signals completion by returning. An item is a table with a text display field plus whatever data confirm (or the preview) needs — e.g. path / row / col.

A static source pushes a fixed set, fuzzy-matched in Rust as you type:

btv.picker.source({
  name = "colours",
  items = function(ctx)
    for _, c in ipairs({ "red", "green", "blue", "amber" }) do
      ctx.push({ text = c })
    end
  end,
  confirm = function(item) btv.notify("picked " .. item.text) end,
})

A source can be asynchronous — wrap items in btv.async and stream from a subprocess. bemtvi is promise-only, so an async source returns its promise and the engine awaits it; there is no done callback. Reap any spawned job on close via ctx.on_cancel. This is essentially how the built-in files source works (the shipped one adds a fallback chain — find, then an btv.fs walk — for when rg is missing):

btv.picker.source({
  name = "files",
  preview = "file",
  items = btv.async(function(ctx)
    local stream = btv.run_stream({ cmd = "rg", args = { "--files" }, cwd = ctx.cwd })
    ctx.on_cancel(function() stream:kill() end)
    for batch in btv.await_each(stream) do
      for _, l in ipairs(batch) do
        if l ~= "" then ctx.push({ text = l, path = l }) end
      end
    end
  end),
  confirm = function(item) btv.picker.edit(item) end,
})

Row shape: heads, tags and colors

A row can be more than one string. A source that knows its row has structure — a location, a classification, a matched fragment — declares it, and the widget fits the parts separately instead of truncating one long label:

ctx.push({
  tag = "E",                       -- pinned classification, never elided
  head = "src/main.rs:12:5 ",      -- the location column, aligned down the list
  text = "unused variable `x`",    -- the body
  match = { 17, 17 },              -- 1-based INCLUSIVE char range of a hit in `text`
  hl = "DiagnosticError",          -- the group the row is painted with
})
  • head makes the row two-column. The head keeps at least 40% of the row (a long body can never squeeze the file name off), and the body is windowed around match so a hit 200 columns into a line still shows. A head that doesn’t fit elides tail-first, keeping file:line — the part you scan for.
  • tag is a short classification prepended to the head and pinned: the elision happens after it, so E survives however narrow the column gets. Use it whenever the head’s leading characters say what the row is.
  • hl is an ordinary highlight group, resolved against the live colorscheme each frame (an undefined group simply doesn’t paint). It colors the head of a two-column row — leaving the body’s match highlight readable — and the whole label of a plain one.

The shipped diagnostics source is exactly this shape: E src/main.rs:12:5 in the severity’s color, then source: message folded onto one line.

Declaring filter = true gives the source the include/exclude boxes. Every candidate carrying a path is then tested against them at the single point they all cross, so a source gets the filter for free however it enumerates. A source that shells out to a ripgrep-compatible tool should also splice ctx.rg_globs (ready-made -g argument pairs) into its argv, so the tool prunes the tree instead of streaming paths that are about to be dropped — on a node_modules-sized directory that is the difference between the results arriving and the cap filling with noise. The patterns themselves reach the source as ctx.include / ctx.exclude.

items = btv.async(function(ctx)
  local args = { "--files", "--color=never" }
  for _, a in ipairs(ctx.rg_globs) do args[#args + 1] = a end
  local stream = btv.run_stream({ cmd = "rg", args = args, cwd = ctx.cwd })
  …
end),

btv.picker.edit(item, mode) is the common confirm action: it opens item.path and, if the item carries a 1-based row (and optional col), jumps the cursor there. The mode is the confirm gesture (the picker passes it to confirm(item, mode)): "current" opens in the focused window honoring 'switchbuf'; "tab" / "split" / "vsplit" (the defaults <C-t> / <C-x> / <C-v>) open in a new tab / horizontal split / vertical split. Forward it from a custom source’s confirm to support those keys: confirm = function(item, mode) btv.picker.edit(item, mode) end.

Switching to an open tab

Where a confirmed pick (and every jump — LSP go-to, quickfix, marks) lands is governed by 'switchbuf'. bemtvi defaults it to usetab: opening a buffer already shown in another tab focuses that tab instead of re-opening it in the current window. Set it like vim — btv.o.switchbuf = "useopen" (reuse a window in the current tab only) or btv.o.switchbuf = "" (classic: always open in the current window). <C-t> always makes a new tab regardless (an explicit tab gesture).

The widget windows its rendering and matches incrementally, so a source can stream 100k+ candidates and stay fast; max_results (default 100000) is only a runaway-source safety bound.

Dynamic (live) sources

Set dynamic = true and the source re-runs on every prompt edit with the local fuzzy matcher bypassed — the source itself does the filtering. It reads the live prompt from ctx.query and the working directory from ctx.cwd. This is essentially how live grep works (re-spawning rg per query; the shipped source falls back to grep, then an btv.fs match, when rg is missing):

btv.picker.source({
  name = "live_grep",
  dynamic = true,
  preview = "location",
  items = btv.async(function(ctx)
    if ctx.query == "" then return end
    local stream = btv.run_stream({
      cmd = "rg", args = { "--vimgrep", "--", ctx.query }, cwd = ctx.cwd,
    })
    ctx.on_cancel(function() stream:kill() end)
    for batch in btv.await_each(stream) do
      for _, l in ipairs(batch) do
        local file, lnum, col = l:match("^(.-):(%d+):(%d+):")
        if file then
          ctx.push({ text = l, path = file, row = tonumber(lnum), col = tonumber(col) })
        end
      end
    end
  end),
  confirm = function(item) btv.picker.edit(item) end,
})

A dynamic source is debounced: a query edit cancels the in-flight job and schedules the search debounce ms later, so a fast typist spawns one process per pause, not one per keystroke. While the new search runs the previous results stay on screen — the list never flashes empty; they swap out only when the first new result arrives (or clear if nothing matched). The prompt row’s spinner is what tells the two states apart. The delay defaults to btv.picker.debounce (250 ms), overridable per source (debounce = N) or per open; 0 disables it.

Preview pane

Add preview to show a side pane for the highlighted item:

  • "file" — shows the head of item.path.
  • "location" — shows item.path scrolled to item.row / item.col (1-based) with the match range highlighted.

Omitted means no preview pane. Preview content is tree-sitter-highlighted by the server, and works across the terminal, GUI, and web clients. Scroll it with the <C-d> / <C-u> / <C-f> / <C-b> keys above.

Open-time options

btv.picker.open(name, opts) — each opts field overrides the matching field on the source, which in turn overrides the picker default:

OptionMeaning
width / heightA fixed box size: a cell count (100) or a viewport fraction ("80vw" / "60vh" / "50%"). The picker is never content-sized.
align + marginPlacement, like a float ("top-left" … "center" … "bottom-right", default centered).
preview"file" / "location" / nil (no pane).
prompt_pos"top" (default) or "bottom" (telescope-style, input under the results).
queryInitial prompt text — the picker opens already filtered against it, caret at its end. Default "".
titleA title centered on the box’s top border (the shipped sources set their own); nil for none.
multiselectWhether <Tab> marks rows for a batch action (default true); false is a single-choice picker.
layerWhere a confirmed item opens: "main" crosses back to the main editor area first; "active" (the default) opens in the focused layer. The shipped files / live_grep set "main".
debounceMilliseconds before a dynamic source re-runs; 0 off.
include / excludePre-fill the filter boxes — a comma-separated string or a list of globs. A seed, not a lock: the boxes stay editable.
filters"open" reveals the filter rows immediately; "collapsed" (the default) keeps the picker’s single-line shape with a badge.
-- a snappier live grep, just for this map:
btv.keymap.set("n", "<leader>fG", function()
  btv.picker.open("live_grep", { debounce = 100 })
end)

Keys

Every picker key is an ordinary picker-mode keymap, not a hardcoded grab: while a picker owns input the server selects the picker bucket, so navigation, confirm, cancel, preview-scroll, and query-editing are all rebindable like any other mode:

btv.keymap.set("picker", "<C-j>", btv.picker.actions.next)
btv.keymap.set("picker", "<C-k>", btv.picker.actions.prev)
btv.keymap.set("picker", "<Tab>", btv.picker.actions.confirm)
-- disable a default binding by mapping it to an empty function:
btv.keymap.set("picker", "<C-n>", function() end)

The actions are next, prev, confirm, confirm_tab, confirm_split, confirm_vsplit, cancel, send_to_list, toggle_select, clear_select, preview_half_down, preview_half_up, preview_page_down, preview_page_up, backspace, delete, left, right, to_start, to_end, next_field, toggle_filters. The one key that is not a map is an arbitrary printable char — there is no way to enumerate every char, so an unmapped printable just inserts into whichever line has focus.

Progress — the count and the spinner

The right end of the prompt row is the picker’s own status line:

┌─ Live Grep ──────────────────────┐      ┌─ Live Grep ──────────────────────┐
│ > handler                  ⠹ 128 │      │ > handler                    247 │
├──────────────────────────────────┤ ───► ├──────────────────────────────────┤
│ src/net/handler.rs:12:5: …       │      │ src/net/handler.rs:12:5: …       │
└──────────────────────────────────┘      └──────────────────────────────────┘
    still searching (128 hits so far)          done — 247 hits
  • A spinner turns while the source is still working. It covers the whole in-flight window — the debounce, the process spawn, the scan — so a search over a big tree never looks like a picker that has stopped responding. It matters most for a dynamic source, where the rows on screen belong to the previous query until the new run’s first result lands (see Dynamic sources): with no spinner, “searching” and “broken” look identical.
  • The count is the number of rows the list is showing. Once the local matcher has narrowed the candidates it reads matched/total (12/3480 — twelve rows answer what you typed, out of 3480 the source found), and a bare number when everything found is on show — which is always the case for a dynamic source, since it does its own filtering. A finished search that found nothing says 0.

Results also appear as they stream: a partial batch crosses to the widget at the end of the tick it was pushed on, so a query with three hits shows them the moment rg prints them rather than when the scan of the whole tree finishes.

The readout is composed server-side, so the terminal, GUI and web clients all show the same thing, and a remote (daemon) or browser session animates exactly like a local one.

Include / exclude filters

files, files_git, live_grep and live_grep_git carry two glob boxes, the way VSCode’s search panel does — files to include and files to exclude. <C-g> reveals them and steps through the three editable lines (query → include → exclude); typing goes to whichever has focus.

┌─ Find Files ─────────────────────┐      ┌─ Find Files ─────────────────────┐
│ > handler        12/3480 [+1 -2] │      │ > handler                12/3480 │
├──────────────────────────────────┤ <C-g>│ include  src/**                  │
│ src/net/handler.rs               │ ───► │ exclude  target/, *.lock         │ ← focus
│ src/ui/handler.rs                │      ├──────────────────────────────────┤
└──────────────────────────────────┘      │ src/net/handler.rs               │
        collapsed, filters active         └──────────────────────────────────┘

Each box holds a comma-separated list of globs. A comma inside {…} belongs to the pattern, so **/{node_modules,target}/** is one glob, not two. Collapsing the boxes does not turn the filter off — the badge ([+1 -2]: one include, two exclude patterns) keeps an active filter visible, so a search that is quietly hiding files never looks like one that isn’t.

Defaults

btv.picker.setup sets the line every filterable picker opens with — the “stop showing me build output” knob:

btv.picker.setup({
  exclude = { "target/", "node_modules/", "*.min.js" },
  history = 20,  -- past lines kept per box for <C-Up>/<C-Down>; 0 disables
})

History — <C-Up> / <C-Down>

Each box remembers the lines you have used, most recent first, and persists them across restarts (via btv.shada.plugin, on the ordinary shada cadence). With a box focused, <C-Up> walks back into older lines and <C-Down> forward again, returning to the line you were composing — cmdline history. The two boxes keep separate lists, so an include pattern never surfaces in the exclude box where it would mean the opposite.

A filterable picker opens pre-filled with the most recent line for each box, so the filter you worked out yesterday is already applied rather than retyped.

btv.picker.history("exclude")   -- the stored lines, most recent first
btv.picker.forget_history()     -- drop them all

Precedence, lowest to highest: the source’s own default → btv.picker.setup → the most recent line used → an explicit btv.picker.open{ include = … }. History outranks the configured default because a line you actually typed is a stronger statement than one configured months ago; an explicit open option outranks everything, so a picker asked for a scope gets exactly that scope.

Two conveniences make a typed pattern mean what you meant:

You typeIt matches
*.lockany .lock file, at any depth
targetthe target entry and everything under it, at any depth
vendor/same — a trailing / just says “the directory”
*.egg-info/every *.egg-info directory and everything under it — the trailing / makes even a glob a directory
src/**taken as written; a pattern with a / is anchored at the search root

A picker can also open already scoped, which is what a dedicated map usually wants:

-- "find in sources"
btv.keymap.set("n", "<leader>fs", function()
  btv.picker.open("files", { include = { "src/**", "crates/**" } })
end)

-- grep everything except the vendored trees, with the boxes already showing
btv.picker.open("live_grep", { exclude = "vendor/, **/*.min.js", filters = "open" })

Any source whose items carry a path can have the boxes by declaring filter = true — see Writing a source. On a source without it, <C-g> says so rather than presenting boxes that would filter nothing.

Vendored paths last — deprioritize

A search over a real project hits node_modules/, build output, lockfiles and source maps by the thousand, and the hits you wanted — the project’s own sources — end up under them. So every filterable picker lists those paths after the rest. Nothing is hidden (that is the exclude box’s job): a yarn.lock hit is still there, below src/. Each group keeps its own order — the order found with no query, match order under one — so a better-matching vendored file never jumps a project file.

┌─ Live Grep ──────────────────────────────────┐
│ > useAuth                               412  │
├──────────────────────────────────────────────┤
│ src/composables/auth.ts:3:17: export func…   │  ← the project's own hits first
│ src/pages/login.vue:12:9: const { user } …   │
│ node_modules/@nuxt/auth/dist/index.mjs:88…   │  ← then the vendored ones
│ .output/server/chunks/auth.mjs.map:1:4410…   │
└──────────────────────────────────────────────┘

The shipped list is btv.picker.defaults.deprioritize: the JS/TS, Python and build-output trees (node_modules/, .nuxt/, .output/, dist/, build/, target/, .venv/, __pycache__/, vendor/, …), editor and toolchain directories, every common lockfile, and *.min.js / *.min.css / *.map. Test files are not on it — plenty of people grep their tests on purpose. btv.picker.setup replaces the list, in the same glob syntax as the exclude box; extend the shipped one rather than retyping it:

btv.picker.setup({
  deprioritize = btv.list.extend(
    btv.tbl.deepcopy(btv.picker.defaults.deprioritize),
    { "*test*", "*_spec.lua" }
  ),
})

btv.picker.setup({ deprioritize = {} })   -- turn it off: pure stream / match order

Ordering alone would not stop a node_modules-heavy tree from filling the result cap before the project’s hits were even found, so the shipped files / live_grep pickers search in two passes: rg first with the deprioritized globs excluded — it never enters node_modules/ — then a second run over just those paths. The btv.fs walk fallback (the web client) pushes its list the same way, normal paths first. Only the fallbacks on a machine without rg — find / grep, and git ls-files / git grep for the _git pickers inside a work tree — search in one pass; their rows are still ordered.

A source can use the same machinery. A row pushed with low = true lists after the rest; a source with filter = true gets its paths tested against the deprioritize list automatically, and can search in the same two passes through ctx.passes:

items = btv.async(function(ctx)
  for _, pass in ipairs(ctx.passes) do   -- normal paths first, then the low ones
    ctx.begin_pass(pass)                 -- the sink keeps only this pass's tier
    run_my_tool(pass.rg_globs)           -- the box globs plus this pass's own
  end
end),

A tool that honours .gitignore takes pass.rg_excludes instead of rg_globs: rg lets a positive -g override the ignore files, so the low pass’s includes would re-include an ignored node_modules/. The negations alone are safe, and the sink applies the include box and the tier; pass.keeps(path) says whether a path belongs to the pass, for a source that lists first and searches only its own.

btv.picker.scorer re-ranks within each group and never lifts a deprioritized row above the rest.

Try it

A runnable playground ships in examples/ui-picker:

BEMTVI_CONFIG=examples/ui-picker cargo run -p bemtvi -- examples/ui-picker/sample.txt

It maps the three built-in sources, registers a custom static source, and shows the box-size, preview, and debounce overrides.

For the filter boxes there is a second playground, examples/picker-filters:

BEMTVI_CONFIG=examples/picker-filters \
  cargo run -p bemtvi -- examples/picker-filters/sample.txt

It ships the mess a real project has — a target/ artifact, a vendor/ lock file, a dotfile — and walks through defaults, editing a box, what each pattern shape means, the <C-Up> history, pre-scoped pickers, and a custom filterable source.

Deprioritizing and the .gitignore pickers have a third, examples/picker-priority:

BEMTVI_CONFIG=examples/picker-priority \
  cargo run -p bemtvi -- examples/picker-priority/sample.txt

It shows the default ordering, extending the list with test files, and <leader>fg versus <leader>fG on an ignored file.

How it works (in brief)

The full item tables stay Lua-side; only a display label and an integer key cross the bridge per result (exactly like btv.ui.select), so an item’s arbitrary fields never need to serialize. Candidates are batched rather than crossing one at a time, which is what makes streaming 100k results fast: a batch crosses every ~1000 items, or at the end of the tick it was pushed on — whichever comes first, so a handful of hits is never held back waiting for a threshold it will not reach. A generation token stamps every run, so a push from a query you’ve typed past — or from a picker that has since closed — is dropped.

For the full design — the unified float-list widget, the Rust matcher, dynamic forwarding, and the preview cache — see the fuzzy-finder plan, the preview-pane plan, and the float-list widget spec.

Quickfix & named-list dock tabs

bemtvi shows the quickfix list and named lists as tabs in the bottom dock by default, not as a split window — so you can keep several searches open side by side and flip between them, while activating an entry opens the file in the main editing area. The classic vim behavior (a bottom split, one list, replaced in place) is one option away. Location lists are the exception: they always keep vim’s behavior — a bottom split owned by their window — and never dock.

This is the surface the fuzzy picker’s <C-q> builds on, but it is a general list facility — :copen, :make, :vimgrep, diagnostics, and the btv.qf.* API all flow through it.

The 'qfdock' option

'qfdock' governs only the dock-oriented lists — the global quickfix list and named lists. It does not touch location lists.

'qfdock'Behavior
on (default — the bemtvi way)The quickfix and named lists open as tabs in the bottom dock. The single global quickfix list is one reused tab; each named list gets its own tab (so searches stack up). <CR> / :cc / :cnext jump into the main layer, leaving the dock in place.
off (:set noqfdock)The classic vim/telescope behavior: a full-width bottom split of the current window, the single global quickfix list, replaced in place.
btv.o.qfdock = false      -- prefer the classic split behavior
-- or, transiently:  :set noqfdock  /  :set qfdock

The option governs :copen, named lists, and the quickfix btv.qf.*_qflist actions. Location lists (:lopen, btv.qf.{send,add}_to_loclist) always open as a bottom split owned by their window, regardless of this option.

Closing a list

:cclose closes the quickfix list — its dock tab (and the bottom dock itself when it was the last tab) under 'qfdock', or its split otherwise. :lclose closes the location list’s split. Same commands as vim, either way.

Dock tabs are ordinary dock tabs: cycle between saved searches with gt / gT while the dock is focused, cross in and out of the dock with <C-w><C-w>. (See Permanent docks.)

Sending results to a list

The btv.qf.* family populates a list and shows it. Each takes an array of entry dicts ({ filename =, lnum =, col =, text = }, the setloclist shape) and an optional { title = }:

FunctionEffect
btv.qf.send_to_loclist(list, opts)Replace the current window’s location list and open it in a bottom split (vim behavior — a loclist is window-scoped and never docks).
btv.qf.add_to_loclist(list, opts)Append to the current window’s location list.
btv.qf.send_to_qflist(list, opts)Replace the global quickfix list and show it (one reused dock tab under 'qfdock', else a split).
btv.qf.add_to_qflist(list, opts)Append to the global quickfix list and show it.

To save several searches as side-by-side dock tabs, use a named list (btv.qf.list / show, below) — that, not the loclist, is the dock-tab surface.

(Bare btv.send_to_loclist etc. aliases exist too.) Example — send the current buffer’s TODO lines to a saved location list:

btv.keymap.set("n", "<leader>lt", function()
  local items = {}
  for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
    if line:match("TODO") then
      items[#items + 1] = { filename = btv.buf.name(), lnum = i, text = line }
    end
  end
  btv.qf.send_to_loclist(items, { title = "TODOs" })
end)

From the picker

In any picker, <Tab> marks rows (multi-select) and <C-q> sends the results to a named list keyed <picker>:<query> — the marked rows if any are marked, else the whole filtered set. Each distinct search is its own persistent dock tab (re-running the same search updates it in place), independent of the quickfix and of any window.

Named lists (window-independent, addressed by name)

A named list is like the global quickfix list — structured entries, its own bottom-dock tab, <CR> jumps to the entry in the main editing layer — but there can be many, each addressed by a stable name. Storage lives on the editor (not a window), so a named list survives closing any window and never collides with the single quickfix list (or with :grep / :make). That makes it the right home for a persistent plugin panel (e.g. a debugger’s “All Breakpoints”).

You push items with btv.qf.list(name, items) whenever your data changes — no datasource/refresh indirection — then btv.qf.show(name) opens or focuses the tab.

local function todo_items()           -- re-scan the live buffer on demand
  local items = {}
  for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
    if line:find("TODO") then
      items[#items + 1] = { filename = btv.buf.name(), lnum = i, col = 1, text = line }
    end
  end
  return items
end

btv.keymap.set("n", "<leader>tl", function()
  btv.qf.list("todos", todo_items(), { title = "TODO / FIXME" })
  btv.qf.show("todos")                 -- repaints the tab if already open
end)
CallPurpose
btv.qf.list(name, items[, opts]) -> namecreate / replace the list name; repaints its tab if open
btv.qf.show(name) -> nameopen or focus the list’s dock tab
btv.qf.drop(name) -> nameclose its tab and forget the list

opts.title sets the dock-tab label (defaults to name); opts.action is "r" (default — replace in place), " " (push a new list onto the stack), or "a" (append). btv.qf.list only writes the list — it never opens or focuses the tab, so call btv.qf.show to surface it. Showing a name with no items yet opens an empty tab.

Each name is an independent list shown as its own dock tab, so several sit side by side and a :grep later clobbers none of them. Closing the tab — or any window — never destroys a named list: re-show it by name and it re-renders.

Try it

A runnable playground ships in examples/picker-to-named-list:

BEMTVI_CONFIG=examples/picker-to-named-list \
  cargo run -p bemtvi -- examples/picker-to-named-list/sample.txt

Named lists have their own playground in examples/named-lists (\tl collects a TODO/FIXME list, \ll a long-lines list beside it, \td drops the first):

BEMTVI_CONFIG=examples/named-lists \
  cargo run -p bemtvi -- examples/named-lists/sample.txt

How it works (in brief)

Three list flavors share one rendering / navigation engine, differing only in where they’re stored and shown:

  • The quickfix list is global. Under 'qfdock' (default) it shows as the single bottom-dock tab; otherwise a bottom split.
  • A location list is owned by a window (vim’s model). :lopen / send_to_loclist open it in a bottom split of that window; closing the owner closes the list. It never docks — 'qfdock' does not apply to it.
  • A named list is the dock-tab “save searches” surface. Its list lives in an editor-side registry keyed by name (not on a window), so it is addressed and re-shown by name, sits beside other named lists as its own dock tab, and outlives any window close.

All three jump the same way: a jump excludes the display window as its target, so it falls back to the main layer (always enumerated first), landing the file in the editing area rather than inside the dock. The picker’s <C-q> captures the matched item keys and the live query server-side, then (in Lua) builds a named list keyed <picker>:<query> via btv.qf.list + btv.qf.show.

Gutter text

A plugin can give each line a column of its own text, between the line numbers and the text: a diff’s old and new line numbers, a blame hash and author, a side-by-side view’s own numbering. It is the gutter_text extmark key, so it uses extmarks’ namespaces, gravity and view:set_decor, and it moves with its line as the text is edited.

local ns = btv.ns.create("my-diff")
btv.buf.set_extmark(0, ns, 4, 0, {
  gutter_text = { { "  12 ", "DiffDelete" }, { "     " } },
  line_hl_group = "DiffDelete",
})

A window reads, left to right: fold column, signs, line numbers, gutter text, text.

What it does

  • The column is not part of the line. The text keeps its own columns, so a sideways scroll under 'nowrap' leaves the column where it is, a click lands on the character under the pointer, $ scrolls by the text area that is left, and 'wrap' wraps into it. (Inline virtual text at column 0, the usual way to fake this, gets all four wrong.)
  • One width for the whole buffer. The column is as wide as the widest gutter_text in the buffer, measured in screen cells, so a wide character in a blame column still lines up. A shorter one is padded on the right. The width comes from the whole buffer rather than the visible rows, so the text doesn’t shift as you scroll past a long entry. A plugin that wants numbers right-aligned pads them itself. The column is never wider than what the window has left beside its fold, sign and number columns: text past that is cut off.
  • First row only. Like a line number, it shows on a wrapped line’s first display row; continuation rows, virtual lines and ~ filler rows leave the column blank. A closed fold shows its first line’s.
  • One mark per line wins. Several marks on one line don’t concatenate, so the column can’t fall out of line: the highest priority wins, then the higher mark id. Within one namespace that is the mark placed later, unless you reuse an explicit id; across namespaces, give the marks different priorities.
  • Colours. Each chunk’s group is honoured as given, through the window’s 'winhighlight'. A chunk without one paints like the line numbers: LineNr, or CursorLineNr on the cursor line. A line_hl_group tint runs under the column too, so a diff’s tint reaches its numbers.

The rules

  • gutter_text is a non-empty list of { text, group? } chunks. Anything else raises, naming the key and the chunk: an empty list, a hole in the list, a named key beside it, or a third slot in a chunk. So does a newline, a tab or any other control character in the text.
  • It works from btv.buf.set_extmark, view:set_decor and a btv.decor provider’s publish alike, since the three share one option vocabulary.
  • A btv.decor.expr block can’t paint it: that frame-time surface has its own key list, and gutter_text isn’t on it.
  • One column per window. Two plugins wanting a column each (blame beside a diff) is not supported yet.
  • The column is not clickable on its own: a click there behaves like a click on a line number, and the pointer stays the arrow.

Testing it

In a btv.test spec, t:decor(row).gutter is the row’s text as sent (the chunks joined, before padding) and t:gutter().gutter_text_width is the column’s width. t:screen() doesn’t show it, since it isn’t buffer text.

Example

examples/gutter-text/ paints old and new numbers over a unified diff and a blame column with a CJK author:

BEMTVI_CONFIG=examples/gutter-text cargo run -p bemtvi -- examples/gutter-text/sample.txt
bemtvi --test-plugin examples/gutter-text

Cost

Reading the column’s width costs nothing per frame: the extmark store keeps a count of marks per width as marks are set, deleted and cleared. A buffer with no gutter_text skips the column’s projection entirely. With some, the visible marks come from the extmark store’s position index, as every other layer’s do, so marks off screen cost a frame nothing.

Workspaces

A workspace is the VSCode “open a folder” model brought to a modal editor: open a directory as a workspace — bemtvi --workspace <dir> in the terminal, or the :workspace [dir] command in the GUI — and it becomes a persistent project session. The window/split/dock layout, the open files (with cursor and scroll), and any modified scratch buffers are saved on exit and restored on the next launch — and the directory becomes the working directory, so relative paths, :find, and project plugins resolve against the project root.

It is opt-in: an ordinary bemtvi <dir> does not start a workspace — you ask for one with the flag or command. vim/neovim have :mksession for something adjacent, but it is manual on both ends (you save and load the session yourself) and global. Once a workspace is open, the save-on-exit / restore-on-launch is automatic, per-directory, and carries its own option overrides.

Opening a workspace

--workspace takes the directory as its value (a bare --workspace uses the current directory). The positional TARGET is a separate optional file to open:

bemtvi --workspace ~/code/myproject          # open a directory as a workspace
bemtvi --workspace ~/code/myproject README.md  # …and open a file in it
bemtvi --workspace                           # the current directory

--workspace does three things:

  • Derives a per-directory shada namespace from the directory, so each project keeps its own session and history, isolated from every other workspace and from the global store. (An explicit --shada-namespace overrides the derived one.)
  • Enables session capture and restore with no plugin opt-in — the layout is saved on exit and replayed on the next launch.
  • Changes the working directory to the workspace root (by default; pass --workspace-no-cwd to keep the launch directory). A relative TARGET file then resolves against the workspace root.

The native GUI also exposes a :workspace [dir] command: a bare :workspace reopens the current directory as a workspace, and :workspace <dir> switches the running window to that directory’s session (a relative dir is taken against the editor’s working directory). The GUI registers the command when it starts a session; running it hands the GUI the directory, which builds the new session, as with :connect. An embedding app gets it only with session_switching(true).

What gets restored

A workspace session round-trips, across restarts:

  • Tabs — count and order.
  • The split layout — the full nesting and proportional sizes of every window.
  • One file per window leaf — its path, cursor line/column, and scroll position; the focused window is re-selected.
  • Docks — left/right/top/bottom docks, their size and shown/hidden state, and any file-backed content.
  • Modified unnamed buffers — a [No Name] buffer with unsaved edits is saved with its contents (under 'workspacepersistunnamed', on by default).

The quickfix and location lists are deliberately not part of the session (they are transient search results, not layout); marks and registers ride the ordinary shada store within the workspace’s namespace.

Keeping the launch directory — --workspace-no-cwd

By default a --workspace launch makes the workspace directory the process working directory: the editor cds into the root at boot, before it opens any file or restores the session, so relative paths and the session’s (relative, portable) buffer paths resolve against the root. Pass --workspace-no-cwd to keep the directory you launched from instead:

bemtvi --workspace ~/code/myproject --workspace-no-cwd

The cd is a launch-time decision (a CLI flag), not a config option — there is no init.lua knob, so the working directory is settled from the first instruction. --workspace-no-cwd has no effect without --workspace.

Per-workspace option overrides — btv.wso

btv.wso is a per-workspace overlay over the global option tier: a value set here takes precedence over the global value for this workspace only, and is persisted with the session. It is the place to pin project-specific settings — a tab width, a colorscheme toggle, search case sensitivity — without touching your global config.

btv.wso.tabstop = 2             -- this project indents with 2
btv.wso.ignorecase = true       -- case-insensitive search here
btv.wso.tabstop = nil           -- clear the override; revert to the global value

Only global options can be workspace-overridden (not window- or buffer-local ones), and the name and value type are validated on write. The resolution order is simply: the btv.wso overlay when present, otherwise the global value (btv.o / :set).

Workspaces and the remote daemon

A workspace pairs with the edit-host split: launch --workspace together with --connect-daemon and the workspace root is resolved from the daemon’s working directory after connect, so you edit a remote project as a first-class workspace with the layout restored locally. (--workspace is a client-side concept and does not combine with running as a daemon.)

Browser editor

bemtvi runs the real editor — entirely in a browser tab, with no server. Not a cut-down demo or a syntax-highlighted textarea: bemtvi-core plus the PUC Lua 5.4 VM plus the production server tick (autocmds, mirrors, the redraw projection — the same keystroke path the native server drives) compile to WebAssembly and run client-side. Your init.lua sources, your keymaps and autocmds fire, files open and save — all in the tab.

Because the editing engine is local, typing has zero round-trips: motions, operators, undo, and Lua all run in the page. Only the filesystem — and processes, in daemon mode — ever crosses a wire, and even the filesystem can be the browser’s own storage. It’s the edit-host split taken to its limit: the local half is a browser tab; the fs/process half is the browser’s storage or a remote daemon.

What runs

  • The whole editor — core + the full Lua VM + the server tick, in a Web Worker.
  • Your config — init.lua is read from the browser’s storage at boot and sourced through the real path, so options, keymaps, autocmds, user commands, and highlights all apply.
  • Files persist — :e / :w open and save real files (see Files), surviving a reload.
  • Syntax highlighting — done JS-side via web-tree-sitter. A small set of grammars is bundled for offline use (rust, lua, json, javascript, typescript, python, markdown); :TSInstall <lang> fetches any other prebuilt grammar on demand and caches it. Embedded content highlights too, through each grammar’s injections.scm: markdown’s prose is parsed by the markdown_inline grammar the block grammar injects, and the code inside a ```lang fence by that language’s own.

What’s different from native

In the browser
Pluginsinit.lua is one self-contained file — a require through package.path doesn’t resolve (the runtimepath is empty and storage reads are async). Multi-file plugins ship by amalgamation: web/amalgamate-plugins.mjs bundles a plugin tree into one chunk registering each module in package.preload, so require resolves from memory.
LSPWorks both ways. A language server compiled to JS/wasm runs serverlessly in a Worker (the python demo ships basedpyright); a real native server runs over the daemon (WebTransport). Both ride the same off-tick LSP seam as native. With neither a wasm server nor a daemon, a configured server fails loud, not silently.
Native tree-sitterThe in-process parser is gated off the build; highlighting uses the JS-side web-tree-sitter path instead.
ProcessesServerless there is no process host: a spawn (btv.run / btv.run_stream) completes loud with a spawn-failure exit (code = -1) — real processes need daemon mode (see Files).
HostingRequires cross-origin isolation (COOP/COEP) for the SharedArrayBuffer. Without it, input still works but timers (btv.timer / vim.defer_fn) don’t fire.

Anything unavailable fails loud with a named error rather than faking a result — the same no-silent-stubs rule the rest of bemtvi follows.

Files

The browser is the filesystem, three ways — all riding the same off-tick fs seam the native edit-host split uses, so only the transport differs:

  • OPFS (default, serverless). :e / :w persist to the browser’s Origin Private File System, and :e <dir> lists it. Your init.lua and shada live there too, so edits and config survive a reload.
  • Real local files. The File System Access API backs :eo / :wo (and a bare :w on a bound path) — pick a real file or directory on disk through the browser’s permission picker.
  • A real daemon. Open the page with ?daemon=bemtvi://HOST:PORT/TOKEN?cert=HASH (the string bemtvi --daemon --listen prints), or dial it at runtime with :connect bemtvi://…, and :e / :w operate on the daemon’s filesystem over WebTransport (HTTP/3 / QUIC). Editing still happens entirely in the tab — only fs crosses the wire. Having no local disk, the browser is always remote-config: it runs the daemon’s config + plugins (fetched over the wire), where a native client would default to local. Because a link can name any daemon, a ?daemon= from the page URL connects only after you approve it in a prompt that shows HOST:PORT and the cert hash; the page remembers the approval for that address and cert, and declining keeps the tab local. A daemon session’s shada (registers, history, marks, plugin state) is its own OPFS blob, one per daemon address, so a daemon’s config never sees the local session’s state or another daemon’s. Daemon mode also brings async processes (btv.run / btv.process), :terminal, and LSP over the wire — the daemon’s real language servers, driven from the tab. A dropped WebTransport link auto-reconnects (the tab’s editor is local, so your buffers survive): the Worker re-dials underneath the seams and re-syncs them — re-opening LSP, re-arming watches, and re-statting open files (a change made while disconnected is caught) — exactly like the native clients. btv.daemon.status() reports the link state.

Run it locally

cd crates/bemtvi-edithost
./build.sh                  # cargo → em++ link → dist/eh.{mjs,wasm} + tree-sitter assets
cd web && npm install       # once: Playwright + chromium
node serve.mjs              # a cross-origin-isolated (COOP/COEP) dev server
# open http://localhost:8088/web/

node harness.mjs is a headless Node smoke test (feeds ihello<Esc>, asserts the lines and a real redraw); the verify-*.mjs scripts drive the real wasm editor in a headless browser (renderer, OPFS, the local-file picker, daemon mode, …).

How it works (in brief)

  • A Web Worker owns the editor. web/worker.mjs is the single !Send thread holding core + Lua; it loads the wasm module (dist/eh.mjs) and runs the production tick. The UI thread (web/index.html) paints the server redraw frame as HTML/CSS — a per-cell-span DOM renderer (windows, gutter, status/tabline, pmenu, cursor shapes, smooth scroll) — and translates KeyboardEvents to vim key-notation. The two talk over postMessage and a shared ring.
  • One wait drives input and timers. When cross-origin isolated, the Worker parks on Atomics.wait over a SharedArrayBuffer input ring, waking on a keystroke or the next timer deadline — so btv.timer / vim.defer_fn fire without Asyncify, one mechanism. (Without isolation it falls back to a postMessage loop where timers don’t fire.)
  • Interop is emscripten ccall/cwrap, not wasm-bindgen — it links the C lua54 backend + vim-regex, so the final link is emcc. src/lib.rs exports eh_input / eh_exec_lua / eh_redraw_json / the fs legs / … and the redraw returns as JSON.
  • It’s agent-drivable. The page exposes a window.__bemtvi hook (feed / mouse / execLua / lines / frame) so a headless browser (Playwright) can feed keys and assert on state — the same black-box style as the native test harness.
  • Built outside the workspace. It targets wasm32-unknown-emscripten and links C via emcc, so it sits in the root Cargo.toml’s [workspace] exclude (a host cargo build --workspace never touches it) and pins its own deps. Deployed as static files (netlify.toml); the one hard requirement is cross-origin isolation.

See also

The edit-host split

Editing on another machine the usual way — running the whole editor over there and a thin client here — means every keystroke round-trips the network before the cursor moves. That lag is structural, not tunable: the editing state machine lives on the far side of the wire.

The edit-host split moves the network boundary below the editor instead. The editor and Lua run locally; only an bemtvi --daemon serving the filesystem, processes, and file-watching runs on the remote. So typing, motions, operators, and undo are all local — zero round-trips — and only fs / process / watch / LSP traffic crosses the wire, the work that was always going to feel like a spinner anyway. It’s the direction VS Code Remote takes: the text model is local, the heavy I/O is remote.

The same bemtvi --daemon serves the Browser editor too — one remote, reached natively here or from a browser tab over WebTransport.

The two halves

editor + Lua  (LOCAL, your machine)  ──network──▶  bemtvi --daemon = fs + processes + watch  (REMOTE)
  • The local half is the same embedded editor as a normal bemtvi, with its host seams — HostFs, HostProc, the async HostFsAsync, the LSP transport, and the Lua-facing LuaFs — pointed at the daemon instead of the local disk.
  • The remote half is bemtvi --daemon: it reads and writes the remote machine’s files, spawns its processes, and watches its files. Nothing about editing lives there.

Running it

Two transports reach a daemon — ssh (simple) and QUIC (multiplexed, and the same transport the browser uses).

Over ssh (stdio)

Point a local editor at a daemon spawned over ssh:

BEMTVI_DAEMON_CMD="ssh user@host bemtvi --daemon" bemtvi --connect-daemon path/to/file

BEMTVI_DAEMON_CMD is run through sh -c, so any command line ending in bemtvi --daemon over stdio works. Left unset, --connect-daemon spawns this same binary locally (bemtvi --daemon) — a two-process local split, handy for testing.

In the GUI, do it at runtime with :connect [user@]host[:port][/file] — it spawns ssh … bemtvi --daemon and routes any password / passphrase prompt to a native dialog via SSH_ASKPASS, so it works from a windowed launch with no tty.

Over QUIC / WebTransport

Run a standalone listener on the remote — the same wtransport-on-quinn stack the browser dials:

bemtvi --daemon --listen                 # binds 127.0.0.1:8765, prints a connect URI
bemtvi --daemon --listen 0.0.0.0:9000    # accept off-host connections

It prints a connect URI — bemtvi://HOST:PORT/TOKEN?cert=HASH. Dial it from a local editor (the bemtvi://… scheme selects the QUIC path; --connect-daemon is optional):

bemtvi bemtvi://HOST:PORT/TOKEN?cert=HASH path/to/file

In the GUI, :connect bemtvi://… does the same at runtime.

What crosses the wire (and what doesn’t)

Stays local (zero round-trips)Goes remote (over the wire)
Every keystroke, motion, operator, undoOpening / saving files and the file explorer
The Lua VM and the redrawProcesses — btv.run / btv.run_stream / btv.process / :terminal
Your config + shada (default; see below)File-watching — :checktime / 'autoread' / FileChangedShell
LSP requests

Only the things that were always going to feel like a spinner cross the wire.

Local or remote config (and shada)

By default a daemon session runs your local config and keeps shada (marks / registers / history) local — only I/O crosses the wire. Pass --remote-config to run the daemon’s config + plugins instead: the daemon’s config is fetched over the wire (one config_bundle request, materialized into a per-process cache and run locally, since Lua’s synchronous require can’t await the network), and shada follows the same choice — a remote-config session keeps its shada on the daemon, so a remote workspace’s editor state travels with it. The browser client has no local disk, so it is always remote-config. See examples/remote-config.

The split-brain filesystem (for Lua)

One subtlety the split forces: which filesystem does Lua see? bemtvi splits it on purpose. Project-facing fs APIs route to the daemon — the async btv.fs surface (btv.fs.read / btv.fs.readdir / btv.fs.stat / btv.fs.watch / …), root detection, a picker’s previewer, a VCS-status provider — so they see the project on the remote. Config and shada stay local by default, or move to the daemon with --remote-config (see above). (This is the LuaFs seam.)

Plugin management is always local. The btv.plugins manager clones, discovers, and sources plugins on the local disk in every session — even a daemon / --remote-config one — because a plugin loads into the local Lua VM (its dir is added to the local runtimepath and required). So :PluginSync downloads into the local stdpath('data')/plugins, and a dir= plugin is read locally; a git clone runs the local git, never the daemon’s. (A loaded plugin’s own runtime btv.fs / btv.run still route to the daemon — it edits the remote’s files.) This is separate from the pack/*/start plugins a --remote-config session fetches in the config_bundle: those come from the daemon and are materialized into the local cache alongside the config. Either way, plugin code ends up on the local disk where the VM can load it.

Staying connected (auto-reconnect)

Because the editor runs local and only the seams cross the wire, a dropped connection — a laptop sleeping past the QUIC idle timeout, an ssh hop dropping, a network blip — does not tear the session down. The link re-dials underneath the seam handles the editor already holds: your buffers, undo, cursor, windows and Lua state are untouched, and the transport beneath them reconnects. While the link is down, remote ops (save, read, LSP, watch, terminal) fail loud rather than hanging.

  • Automatic. A drop is retried a few times with backoff (≈0.5 → 8 s). On success the seams rebind and editing resumes with no action from you. If the budget is spent the link parks disconnected and tells you to run :reconnect.
  • :reconnect re-dials now (and resets the retry budget); :disconnect drops the link on demand. Both are server-side ex-commands, so they work on the TUI too.
  • Status is a first-class API: btv.daemon.status() returns "connected" / "reconnecting" / "disconnected" (or nil for a local session), and a User DaemonStatusChanged autocmd fires on every change — so a statusline component can render it (green / yellow / red). See examples/daemon-status.
  • On reconnect the editor re-syncs what a fresh daemon doesn’t know about: it re-opens LSP servers, re-arms file watches, and re-stats open files — a file changed while the link was down is detected (an unmodified buffer autoreloads; an edited one is flagged as a conflict). Remote terminals/jobs do not survive a drop (the PTYs die with the link) and are surfaced as exited; reopen them with :terminal.

All three transports reconnect — ssh/stdio, QUIC, and the browser’s WebTransport. (Daemon- side session survival — keeping terminals/jobs alive across a drop — is out of scope.)

Auth & identity (QUIC)

A daemon executes arbitrary processes, so an open listener is remote code execution by design — the TLS cert buys encryption, not authorization. Two mechanisms, both minted at --daemon --listen launch and presented by the local half on connect:

  • Bearer token — 32 random bytes, carried on the connect URI’s path. The daemon compares it constant-time and drops the session without accepting on a mismatch, so a bad token is a failed connect, never a half-open session.
  • Server identity — a self-signed cert whose SHA-256 hash the daemon prints; the client pins that hash on first use (the known-hosts / TOFU model, no CA). The browser passes the same hash to its WebTransport constructor.

The default loopback bind (127.0.0.1) is defense-in-depth — the token is the real gate. Bind 0.0.0.0 only when the token (and, ideally, a trusted network) is doing its job.

How it works (in brief)

  • One code path. The local half is the same embedded editor as the default role; the boundary is the same RPC every client speaks. Only its host seams (HostFs / HostProc / HostFsAsync / the LSP transport / LuaFs) are repointed at the daemon — the editing logic is untouched.
  • ssh vs. QUIC. ssh stdio carries every leg over one ordered stream — simple, but a process flood (a fuzzy-finder’s rg, an npm install) can head-of-line-block a file save queued behind it. QUIC gives each traffic class its own stream, removing that coupling at the protocol level: the legs ride four independently flow-controlled bidi streams by latency class — Control (fs / config / btv.fs), Proc, Lsp, Term — each prefixed with a one-byte group tag the daemon dispatches on. Because it’s the same wtransport/quinn stack the browser’s WebTransport uses, native and browser unify on one daemon (the browser opens the same four streams). See the multi-stream plan.
  • :connect swaps the backend, not the window. In the GUI, :connect builds a fresh local server whose seams point at the daemon and re-attaches the same window. The editor transport is always the in-process duplex, so the window never notices.

See also

Embedding bemtvi in an app

A mitsuami app can put a bemtvi editor anywhere in its own layout: a pane beside a sidebar, a tab, a dialog. It’s the whole editor, with its config, its plugins and LSP. The app drives it through a handle, and hears from it through events. bemtvi-gui is itself one of these editors, filling a window.

cargo run -p bemtvi-mitsuami --example embed

runs a small notes app built this way (crates/bemtvi-mitsuami/examples/embed.rs), with examples/embed/init.lua as its editor’s config.

Depending on it

The crates aren’t on crates.io yet: an app takes bemtvi-mitsuami from git, and the mitsuami from crates.io at a version compatible with bemtvi’s (the editor’s view is a mitsuami view, so both must resolve to one mitsuami):

[dependencies]
bemtvi-mitsuami = { git = "https://github.com/bemtvi/bemtvi" }
mitsuami = "2.0.0"

bemtvi-mitsuami is a thin layer: the editor’s client itself, with no toolkit, is bemtvi-gpu (renderer, sessions, the Client a host drives through effects and deadlines), so a host on another toolkit builds on that instead.

The editor in a layout

#![allow(unused)]
fn main() {
use bemtvi_mitsuami::{Config, Editor, EditorEvent, EditorHandle, SessionOptions, Shada};

let editor = EditorHandle::new();
let session = SessionOptions::new()
    .file("notes.md")
    .config(Config::Dir(app_config_dir))      // or Config::User, Config::None
    .shada(Shada::Namespace("myapp".into()))  // or Shada::Global, Shada::Off
    .host("myapp")
    .spawn()?;

Row::new().children((
    sidebar(),
    Editor::new(session, move || tabs.close_current())   // on_exit
        .handle(editor.clone())
        .on_event(move |event| match event {
            EditorEvent::Host { name, payload } => status.set(format!("{name}: {payload}")),
        }),
))
}
  • The session comes first. SessionOptions::spawn starts the editor’s server on a thread of its own and fails then, before anything is mounted: a daemon that refused the connection (.connect(target), the edit-host split), or a shada namespace that isn’t a safe name.
  • on_exit is required. It’s what the app does when the editor quits (:q of the last window, or its server went away), so a quit editor never sits there showing its last frame.
  • It takes the space left over. The editor’s surface has no size of its own: it grows into what its row or column has left, stretched across the other way.

Its config and shada

Config::User runs the user’s own config, as bemtvi does: right for a tool whose users already run bemtvi. Config::Dir runs the app’s own directory (its init.lua, and plugins under its pack/*/start/). Config::None runs no config at all. The directory and no-config cases leave out the user’s system-plugin tier too.

Shada::Global shares marks, registers and history with the user’s other bemtvi instances. Shada::Namespace keeps a store of its own under the shada directory. Shada::Off persists nothing.

By default the editor stays on the session the app gave it: :workspace isn’t a command, and :connect or a plugin’s btv.session.reconnect is refused with a message saying why. SessionOptions::session_switching(true) allows them, as bemtvi-gui does.

Taking things away

An app can take from the editor what makes no sense for how it’s used.

  • Commands. SessionOptions::disable_commands(["lua", "terminal"]) disables commands by any spelling (e is :edit). A disabled command fails with E5800: :lua is disabled in this editor wherever it’s run from: typed, from a mapping, from a plugin’s btv.cmd, an autocmd, under :silent or :tab. As an error it also stops the rest of a | chain. Completion leaves it out, and Lua can ask btv.command_disabled(name). A user command can be disabled by its name too.
  • User commands from elsewhere. user_commands_from_startup_only(true) takes user commands only from the editor’s config and the plugins loaded with it. Once startup ends (PluginsLoaded), a command defined by any other code fails with E5802, naming where that code is: a plugin loaded later, typed :lua, a file :sourced from elsewhere. What counts is the file the defining code is in, not when it runs, so a startup plugin that defines a command later, from an autocmd say, still may. So may the app’s own exec_lua. :command defines none after startup: it runs deferred, and the code that asked for it is gone. This is a policy, not a sandbox: Lua can name its code what it likes, and plugins are trusted code.
  • Other files. lock_to_file(true) locks the editor to its file. Opening, switching to or writing any other file fails with E5801: this editor only edits <file>, and so does a new file buffer. That holds whatever asks: :e, :sp, :tabedit, :enew, :b, <C-^>, btv.open, the picker, an LSP or quickfix jump, :w other, :bd of the file itself. Views, panels, floats, quickfix lists and terminals work as ever, and so do :e!, :w and :sp of the file itself.
  • The plugin manager. disable_plugin_manager() makes every btv.plugins call fail with E5805, so the config declares, loads, installs and updates nothing through it, and its :Plugin* commands are disabled. Plugins under the config’s pack/*/start/ still load. A config can ask btv.plugins.disabled().
  • LSP. disable_lsp() means no language server starts. btv.lsp.enable, start and restart fail with E5804, and the :Lsp* commands are disabled. A config meant to run both ways checks btv.lsp.disabled() first.
  • Pickers. disable_pickers(["files", "live_grep"]) takes pickers away by source name. btv.picker.open fails with E5803 for one, and the commands and pickers lists leave it out. A config can ask btv.picker.disabled(name).

An editor for one file

SessionOptions::single_file(path) is all of it, for an app that embeds the editor to edit one given file:

  • it locks to the file and takes user commands only from startup;
  • it turns the plugin manager and LSP off;
  • it takes away the pickers in SINGLE_FILE_DISABLED_PICKERS (buffers, diagnostics, files, live_grep);
  • it disables SINGLE_FILE_DISABLED_COMMANDS. That is what runs code or processes (:lua, :=, :source, :command, the autocmd family :autocmd / :augroup / :doautocmd, :terminal, :make, :grep, :TSInstall), changes directory or session (:cd, :connect, :workspace, :rshada), or reads other files into a list (:vimgrep, :cfile).

.enable_commands([...]) gives commands back.

SessionOptions::views_only() is all of that with no file, for an app whose editor only shows what its plugins put in views, a diff say. Opening or writing any file fails with E5801: this editor edits no files. A plugin mounts its view with view:mount({ here = true }), so it’s the editor’s one window and :q quits.

cargo run -p bemtvi-mitsuami --example single_file [file]

runs one, with examples/single-file/init.lua as its config.

Talking to the app

The app names itself with SessionOptions::host. Inside the editor that’s btv.host (its page is under the API reference):

if btv.host.name() then                 -- nil when no app embeds the editor
  btv.host.forward({ "changed", "wrote" })
  btv.keymap.set("n", "<leader>p", function()
    btv.host.send("preview", { file = btv.buf.name(0) })
  end)
end

btv.host.send(name, payload) reaches the app as EditorEvent::Host { name, payload }. btv.host.forward sends changed (a buffer’s name and modified state, once typing pauses) and wrote messages. Neither sends the buffer’s text: the app asks for what it needs. The app’s on_event handler runs on the UI thread with the editor free, so it can drive the editor straight back.

The other way, EditorHandle has command (an ex command, whatever the mode), exec_lua (a future of what the chunk returns), exec_lua_with (the same, with arguments as the chunk’s ..., so the app’s values needn’t be quoted into its source), open(path), focus(), request_quit() (:qa, which refuses while a buffer is unsaved, as a window’s close button wants) and quit_now() (:qall!).

An app can build the Editor anywhere on the UI thread, not only in a view: a C ABI’s call has no current mitsuami Ui, and it uses the one it’s built in.

Its lifetime

The editor’s threads start as it’s mounted and are told to stop as it’s unmounted: a Show switching it out, a closed tab. Mounting it again starts a new one. The handle goes on whichever is mounted, one at a time.

EditorHandle::close() waits for the threads to end and says whether a server panicked. An app calls it before its process ends, so the editor’s exit sequence (its shada, plugins’ VimLeave) lands.

Every editor in the process shares one GPU device, which is never dropped (wgpu’s EGL teardown crashes on Wayland with Mesa otherwise); each editor’s own surface and glyph caches go with it.

Keys and focus

The window’s shortcuts get a key first: a menu item’s shortcut is the app’s, and the editor never sees that chord. A mapping on it in the editor never fires. Everything else goes to the editor while it has focus, Tab included. Control+Tab moves focus on, so a <C-Tab> mapping never fires in any mitsuami app, bemtvi-gui included.

A modeless caret shows and blinks only while the editor has the focus, as a text field’s does, so an app with several editors shows one caret. Vim’s and Helix’s block cursors are an outline without it, with the glyph in its own colour; Insert’s bar and Replace’s underline stay as they are. It’s the same while the editor’s window isn’t the active one, though the editor keeps its focus.

The editor’s surface is a native child surface on GTK, Kirigami and Windows, drawn above the window’s own content. Nothing the app draws inside the window can overlap it, so popovers and menus over it have to be the platform’s own popup windows, as GTK’s popovers and Kirigami’s windowed lists are.

Versions

mitsuami’s types are part of the API (Editor is a mitsuami View), so the app has to build against the same mitsuami bemtvi does. For now that’s a GitHub revision, pinned in bemtvi’s root Cargo.toml.

Plugin Development

A plugin is pure Lua over the btv.* API — a lua/<name>/init.lua module that exposes setup(opts) and wires keymaps, commands, autocmds, and UI through btv.*. The server owns the UI surfaces; the plugin supplies data and behavior.

Install one by declaring it with the built-in manager and running :PluginSync:

btv.plugins({
  { "bemtvi/bemtvi-keys-helper",
    config = function() require("bemtvi-keys-helper").setup({}) end },
})

Where to go next

  • Writing plugins — the full authoring guide: anatomy, loading, the btv.* surfaces you’ll use, a worked example, and testing.
  • btv.* API Reference — every public function in the btv.* namespace, generated from the prelude source.
  • bemtvi-plugin-skills — agent skills that teach a coding agent the btv.* model, one skill per surface. The model is not in any agent’s training data, so without them an agent writes neovim plugins that do not run here.

First-party plugins

The surest way to learn the btv.* API is to read code that already ships on it. bemtvi is its own plugin API’s first and most demanding consumer: its first-party plugins use the same public btv.* surface a third party would, with no privileged access. That makes them honest reference implementations — if a behavior can be built in one of these, it can be built in yours.

They live in their own repositories under github.com/bemtvi and are each installable with the built-in manager:

btv.plugins({
  { "bemtvi/bemtvi-lspconfig",
    config = function() require("bemtvi-lspconfig").setup({}) end },
})

The catalogue

Each is paired below with the neovim plugin it echoes and the btv.* surfaces worth studying it for.

PluginWhat it isRead it to learn
bemtvi-lspconfigReady-made language-server configs (port of nvim-lspconfig)The smallest, most data-driven plugin: curated config tables driven onto btv.lsp.config / btv.lsp.enable, inlay hints, and the LSP buffer verbs — no neovim compat layer. Start here.
bemtvi-treeDockable file explorer — the official tree (sibling of nvim-tree)A read-only btv.view surface, docking via btv.layer.main / btv.open, async filesystem walks with a per-directory watch (btv.fs + btv.async / btv.await), and glyphs / guides / git signs painted as extmarks.
bemtvi-lineLualine-style statusline (sections a–z, themes, powerline separators)Composing a rich statusline from btv.statusline components, recolour-by-mode themes (btv.mode + btv.hl.define), git/diff data shelled out via btv.run, buffer options through btv.bo, and refresh on a btv.timer.
bemtvi-keys-helperLive popup of the keys that can follow what you’ve typed (a which-key)Subscribing to the pending-key oracle (btv.on_key_pending) instead of intercepting input, rendering a popup with btv.component, debounce (btv.utils.debounce), and width-aware layout (btv.str.displaywidth). No blocking key reads.
bemtvi-dapDebug Adapter Protocol client (sibling of nvim-dap)The richest example: a Content-Length-framed JSON protocol over the duplex btv.process primitive, breakpoint / stopped signs as extmarks, a scopes-and-stack sidebar plus REPL on read-only btv.view docks, and cross-tick scheduling (btv.on_next_tick).
bemtvi-diffMeld-style side-by-side diff viewerRead-only btv.view panes locked in lockstep through WinScrolled + btv.win.set_topline / set_leftcol / set_cursor, with every line tint and intra-line span an extmark. A renderer you feed a diff to.
bemtvi-helpVim-style :helpA navigable read-only btv.view split, a tag index merged across the runtimepath (btv.runtime_file), files read with the promise btv.fs API, and fuzzy topic search through the picker.
bemtvi-editorconfig.editorconfig support (port of neovim’s built-in editorconfig.lua)The smallest behavioral plugin: an async upward directory walk on btv.fs inside btv.async / btv.await (btv.utils.ancestors + btv.fname.modify), option application through btv.bo[bufnr], and a whole feature driven by four btv.on autocmds — including live reload off BufWritePost.

Every one ships its own integration-test suite (most run a real server over the black-box harness, exactly as Testing plugins describes) — those tests double as worked, runnable usage examples for the surfaces above.

Bundled in the editor

A plugin that ships inside bemtvi and loads by default — also pure btv.*, and browsable in this repository under crates/bemtvi-lua/src/prelude/:

  • plugins.lua + plugins_ui.lua — the btv.plugins package manager and its dashboard: declarative specs, async git over btv.process, lazy-loading on command/event/filetype/keys, and a full floating UI.

See also

  • Recommended plugins — the same plugins from the user’s side: what bemtvi offers on first run, what each gives you, and how to pick a subset.
  • Writing plugins — the authoring guide these implement.
  • Testing plugins — how their test suites are structured.
  • The btv.* model — the five rules they all obey; the btv.* API Reference chapter lists every surface named above.

The btv.* model

bemtvi has its own, purpose-built plugin system. Every editor API lives in the btv.* namespace (config and plugins alike), with a small set of familiar vim.* names aliased to btv.* for convenience. This page explains the model that shapes the whole API; Writing plugins is the hands-on how-to, and the btv.* API Reference chapter lists every function.

The model in one sentence:

The server owns every UI surface and the frame; plugins are async, declarative providers of data and behavior.

Where neovim hands you buffer primitives and redraw hooks and says “draw your own completion menu,” bemtvi hands you a completion engine and says “give it items.”

Compared to neovim’s plugin model

neovim’s plugin model fits neovim’s architecture. A plugin there is an imperative program written against the editor’s runtime — synchronous, re-entrant editor access; blocking reads (getcharstr, vim.wait pumping the loop); libuv as a public API (vim.uv timers / processes); frame-time render hooks (decoration providers running Lua inside redraw); and the open-ended vim.fn inventory. The plugins that define that ecosystem’s UX — completion menus, fuzzy pickers, statuslines, popups, tree sidebars — own frame time and input loops, and neovim’s single-process, synchronous core is built to let them.

bemtvi’s architecture is different (see Architecture), so its plugin model is too. bemtvi-core is pure and synchronous, Lua influences the editor only through snapshot reads + queued effects drained at a settle point, and a client-server boundary puts the server in charge of the frame and every UI surface. Those properties — a pure core, a frame no script can stall, identical behavior across every front end including the serverless WebAssembly build — are the point of the design, and they call for plugins that are async, declarative providers rather than imperative programs. Same goal (rich, scriptable UX); a model shaped by a different runtime.

The five rules

The model is five rules — each one a property the architecture already enforces internally; the API just makes it the documented contract:

  1. Reads are snapshots. btv.buf.lines(b) and friends read the state pushed at Lua entry. Documented, not disguised as live access.
  2. Writes are queued effects. Applied at the settle point, not instantly. The buffer mutations (btv.buf.set_lines / btv.buf.set_text) validate loud up front, queue the edit, and return a promise that fulfils once it has landed.
  3. Nothing blocks, ever. No wait-pumps, no blocking reads, no uv handles. Anything that waits returns a promise you btv.await inside btv.async, or — for streaming — an async-iterator (btv.run_stream + btv.await_each). See Async & promises.
  4. No frame-time Lua. Plugins publish decorations / segments / items whenever they like; the server folds them into the next frame. A plugin cannot make redraw slow.
  5. Registrations are data. A provider registers with a name + schema and is called with a context carrying a generation token; it emits through the context’s sink (ctx.push) and signals completion by returning. Stale async responses are dropped by the engine.

Because Lua influences the editor through the same queues RPC clients use, every btv.* registration has an RPC twin in principle — out-of-process providers, in any language, are the same surface (later). The in-process Lua host is v1.

Providers, not programs

The inversion rule 5 implies: the server owns the engine, and a plugin is a thin source / segment / provider plugged into it. This is what keeps plugins small and the frame safe — the hot path (rendering, navigation, matching, input grab) lives in Rust, written once, and the plugin only supplies data.

The neovim “shape”In bemtvi
Completion menu (the nvim-cmp shape)btv.complete engine; plugins are sources
Statusline (the lualine shape)btv.statusline; plugins register segments
Fuzzy finder (the telescope shape)btv.picker engine; plugins are sources
Snippets (the LuaSnip shape)btv.snippet engine (LSP grammar, tabstops)
File tree / sidebar / dashboard — a bespoke plugin UI in neovimFirst-class content surfaces: btv.view in an btv.dock, btv.component for reactive ones
Decoration providerbtv.decor — viewport-scoped, recomputed off the frame

The same applies to the UI itself. In neovim, plugin UIs are bespoke — a file tree, a popup, a dashboard is each hand-built from buffer and window primitives, so every plugin reinvents rendering, navigation, and input handling. bemtvi ships those as first-class APIs: btv.view (dockable content surfaces), floating windows, the btv.ui widgets, and the reactive btv.component — so a plugin describes a UI instead of drawing one.

vim.* aliases

The editor API is btv.*. For convenience, a small set of familiar vim.* names are provided as thin aliases over their btv.* equivalents, so common config reads in muscle-memory spellings — vim.g.mapleader, vim.o.number = true, a vim.keymap.set block, an nvim_create_autocmd block, vim.cmd.colorscheme — without learning a new vocabulary first. They’re aliases, not a second API: the same objects, with btv semantics (snapshot reads, queued effects, settle-point callbacks).

The aliased names (ADR 0002 has the canonical list):

  • Variables / options / env — vim.g / vim.b / vim.w, vim.o / vim.opt / vim.opt_local / vim.bo / vim.wo, vim.env.
  • Dispatch & keymaps — vim.cmd, vim.keymap.set / del.
  • Pure helpers — vim.tbl_*, vim.split, vim.trim, vim.startswith / endswith, vim.list_extend, vim.deepcopy, vim.inspect, vim.json.
  • Declarative registrations — a partial vim.api of nvim_create_autocmd / augroup / del / clear (→ btv.on), nvim_create_user_command (→ btv.command), and nvim_set_hl (→ btv.hl.define).
  • Callback-shaped async — vim.notify, vim.schedule, vim.defer_fn, and vim.ui.input / select (process spawning is btv.run / btv.run_stream — there is no vim.system).
  • Treesitter foldexpr — vim.treesitter.foldexpr, neovim’s spelling of the native 'foldexpr' marker (btv.treesitter.foldexpr). Highlighting itself is declarative buffer state — the btv.bo.filetype / btv.bo.ts_highlight nouns — not a function call.

The list is intentionally small — these convenience spellings, and nothing more; everything else (LSP, treesitter, processes, the filesystem, …) is btv.*.

Colorschemes are data, not plugins

A colorscheme is pure data — a table of highlight-group definitions, registered through the btv highlight API (the nvim_set_hl alias above). It never touches the runtime model, so it crosses the snapshot/effect boundary intact: sourcing one is just running Lua that fills the highlight registry. It uses the same vim.* aliases as any other config — there’s no plugin host and no special case.

Dogfooding the btv.* API

The split: the core provides primitives — the engines and UI surfaces, in Rust: completion, the picker (a float-list widget), statusline, snippets, btv.view / btv.dock / floats, btv.decor, plus the tree-sitter / LSP / regex engines — and the more complex UI behavior is implemented as plugins in Lua, composing those primitives. A file explorer, a which-key popup, statusline extras, a completion or picker source: each is a plugin, not bespoke Rust.

bemtvi is the plugin API’s first and most demanding consumer — its first-party plugins use the same public btv.* API a third party would, with no privileged access. That keeps the API honest: a feature that can’t be expressed against btv.* is a gap to close in it.

See also

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 with btv.keymap.get. Always pass a desc — it surfaces in completion and which-key.
  • User commands — btv.command(name, fn, { desc = …, usage = …, complete = … }); fn receives { args, fargs, bang, … }. usage is 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" / a fn(args)) drives <Tab> completion of the argument.
  • Autocmds / events — btv.on(event, { pattern = … }, fn) for editor lifecycle events (BufReadPost, FileType, …), or btv.on(event, fn) with no options.
  • Options & vars — read/write btv.o (global), btv.bo (buffer), btv.wo (window), and btv.g (globals). Edge docks have their own scope too: btv.dock.opt(side) (e.g. btv.dock.opt("left").size = 32), alongside btv.bo/btv.wo — per-dock showtabline, laststatus, size, title, winhighlight, and autohide.
  • Highlights — btv.hl.define(ns, name, spec), btv.hl.get, btv.hl.exists. Define your groups as fallbacks (btv.hl.exists guard) 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 primitives btv.schedule (end of the current tick) / btv.on_next_tick / btv.wait_for(pred, opts) (across ticks). Reach for on_next_tick/wait_for — never a btv.schedule re-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 for float), and btv.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), and btv.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

Async & promises

The editor is single-threaded and tick-based: all Lua runs on the editor’s own loop, and nothing may block it — a blocking read would freeze every keystroke, every repaint, every client. So bemtvi has no blocking I/O at all. Instead it gives plugins a browser-shaped async runtime: Promises/A+ (btv.promise) plus async/await sugar (btv.async / btv.await), layered over the editor’s event loop. If you’ve written fetch(...).then(...) or await fetch(...) in JavaScript, you already know this API.

One rule shapes the whole surface (ADR 0002):

btv async is promise-only. Every “do a thing, get a result later” API returns a promise — never takes a callback. Streaming is an async-iterator over promises. (The vim.* muscle-memory aliases keep neovim’s callback shapes; new btv code uses promises.)

So btv.run, btv.fs.read, btv.ui.input, btv.lsp.hover — anything that waits — hands you a promise you :next() / await.

Promises

A promise is a value that arrives later. btv.promise.new(executor) is the browser constructor — executor(resolve, reject) runs now, and you call resolve (or reject) when the result is ready:

local function after(ms)
  return btv.promise.new(function(resolve)
    btv.timer(function() resolve("done") end, ms)   -- settle later, off the tick
  end)
end

after(500)
  :next(function(v) btv.notify(v) end)     -- "done", ~500ms later
  :catch(function(e) btv.notify("failed: " .. tostring(e), "warn") end)

The three chaining methods are the browser’s, with one spelling change:

MethodDoes
:next(on_fulfilled[, on_rejected])The spine. Returns a new promise resolved with the handler’s return (adopting it if it’s itself a promise), or rejected if the handler throws. Spelled :next, not :then, because then is a Lua keyword.
:catch(on_rejected)Sugar for :next(nil, on_rejected). A bare :catch at the end of a chain catches a rejection from anywhere earlier in it.
:finally(on_finally)Runs whichever way the promise settles, then passes the original value/reason through untouched.

Ready-made promises: btv.promise.resolve(v) (already fulfilled), btv.promise.reject(e) (already rejected), and btv.promise.try(fn, ...) — run fn inside a promise so a synchronous throw becomes a rejection and a returned promise is adopted, folding “might fail either way” into one chain.

Two semantics worth knowing:

  • Reactions are always asynchronous. A :next callback runs as a microtask (deferred to the end of the current tick via btv.schedule), never inline — even on an already-settled promise. Same as the browser running .then off the stack.
  • Unhandled rejections are reported, not swallowed. A promise that rejects with nothing subscribed surfaces a loud bemtvi: unhandled promise rejection: … (CLAUDE.md: no silent failures). Attach a :catch — or handle it via await.

async / await

Chains are fine for one step, but nest badly. btv.async + btv.await flatten them into straight-line code — the same trade the JS async/await keywords make.

btv.async(fn) returns a function that, when called, runs fn as a coroutine and returns a promise for its result. Inside, btv.await(p) suspends until p settles and evaluates to its value (or re-raises its rejection as a Lua error):

local load_config = btv.async(function(path)
  local exists = btv.await(btv.fs.exists(path))
  if not exists then return {} end
  local text = btv.await(btv.fs.read_text(path))
  return parse(text)                       -- becomes the promise's fulfilment
end)

load_config("init.lua")
  :next(use_config)
  :catch(function(e) btv.notify("config load failed: " .. tostring(e), "warn") end)

Error handling works either way: a rejected await raises inside the coroutine, so you can pcall it to handle locally (PUC 5.4 yields across a pcall), or let it propagate to the coroutine edge — the returned promise rejects, caught by :catch on the result.

btv.await must be called inside an btv.async function (there’s nothing to suspend otherwise — it errors loudly). It’s also what setup / render run inside on an btv.component, so you can btv.await(...) straight inside those too.

The async APIs you get

Everything that waits is one of these promise-shaped surfaces — all awaitable, all non-blocking:

SurfaceReturnsFor
btv.run{ cmd, args, cwd, env, stdin }promise of { code, stdout, stderr }Run a subprocess to completion.
btv.run_stream{ … } + btv.await_eacha Stream (async-iterator)Stream a subprocess’s stdout as it arrives.
btv.fs.read / read_text / stat / readdir / exists / …promise of the resultFilesystem, never blocking.
btv.ui.input / select / confirm / openpromise of the user’s answerPrompts and choosers.
btv.lsp.hover / references / …promiseLanguage-server requests.
btv.promise.delay(ms[, v])promise that fulfils after msAn await-able sleep.
btv.wait_for(pred[, opts])promise of the truthy valuePoll a cross-tick condition (below).

btv.run resolves rather than rejects on a non-zero exit — the exit code is part of the value, so you decide what counts as failure:

btv.async(function()
  local r = btv.await(btv.run({ cmd = "git", args = { "rev-parse", "HEAD" } }))
  if r.code == 0 then btv.notify("HEAD is " .. r.stdout:gsub("%s+$", "")) end
end)()                                       -- note the trailing () — call it to run

Streaming

For output you want as it arrives (not after the process exits), btv.run_stream returns a Stream you iterate with btv.await_each. Each step awaits the next batch of lines; the loop ends when the stream is exhausted. :kill() reaps the child early. This is how the picker’s files / live_grep sources stream rg:

btv.async(function()
  local stream = btv.run_stream({ cmd = "rg", args = { "--files" } })
  for batch in btv.await_each(stream) do
    for _, line in ipairs(batch) do
      -- … push each result as it streams in …
    end
  end
end)()

The contract is sequential: at most one outstanding :next() at a time — which is exactly what the for loop does. Batches arriving between steps buffer internally.

Combinators

The browser’s, on btv.promise — for fanning out concurrent work:

CombinatorSettles whenWith
all(list)all fulfil (or any rejects)the array of values, in input order
all_settled(list)every one settlesan array of { status, value } / { status, reason } (never rejects)
race(list)the first settlesthat outcome (fulfil or reject)
any(list)the first fulfils (or all reject)the first value (else an aggregate error)
btv.async(function()
  -- read three files concurrently, wait for all
  local texts = btv.await(btv.promise.all({
    btv.fs.read_text("a.lua"),
    btv.fs.read_text("b.lua"),
    btv.fs.read_text("c.lua"),
  }))
  btv.notify(("read %d files"):format(#texts))
end)()

Scheduling: schedule, on_next_tick, timer

These run a function later but aren’t one-shot results, so they stay callback-shaped (a promise models one eventual value — the wrong shape for a microtask or a repeating timer). Reach for the right one:

PrimitiveRuns fn…Use for
btv.schedule(fn)at the end of the current tick (a microtask)defer off the current call stack, same convergence
btv.on_next_tick(fn)on the next event-loop tickobserve state the server only refreshes between ticks
btv.timer(fn, ms)after ms wall-clock ms (returns a :stop() handle)delays, polling, self-rescheduling timers
btv.wait_for(pred, opts)polls pred between ticks, fulfils a promise with its truthy valueawait a cross-tick condition

The cross-tick gotcha. btv.schedule runs within the same convergence, so it cannot observe anything that only refreshes between server ticks — a freshly-mounted window’s id, a server-repopulated mirror. Re-arming btv.schedule(self) to “wait” for such a value spins forever. For anything cross-tick use btv.on_next_tick or btv.wait_for — the latter is the await-able form:

btv.async(function()
  local v = btv.view.create({}); v:mount({ float = { width = 40, height = 8 } })
  local win = btv.await(btv.wait_for(function() return v:winid() end))  -- settles when the winid exists
  vim.wo[win].number = false
end)()

btv.wait_for(pred, { tries = 200, interval = nil, message = … }) checks once immediately, then once per tick, and rejects (so an await fails loud) if the condition never holds within tries.

Helpers

  • btv.utils.debounce(fn, ms) — coalesce a burst of calls into one trailing invocation ms after the last (which-key’s show-delay, on-change/resize/scroll reactions). Callback-shaped, not a promise — a different job — but they compose: pass an btv.async function as fn to kick await-able work after the quiet period.
  • btv.promise.wrap(fn) — lift a single-callback async function into a promise-returning one (it appends a resolver as the last argument). The bridge for a vim.*-style callback API into the promise world.
  • btv.schedule_wrap(fn) — a function that, when called, defers fn to the end of the tick — the btv.schedule analogue of a wrapped callback.

Try it

ExampleShows
async-runtimeThe bare loop — btv.schedule vs btv.timer, and a self-rescheduling timer firing on wall-clock time while the editor stays responsive.
ui-pickerbtv.run_stream + btv.await_each streaming rg results into a source.
BEMTVI_CONFIG=examples/async-runtime cargo run -p bemtvi -- examples/async-runtime/sample.txt

How it works (in brief)

btv.promise is pure Lua layered on the editor’s existing async runtime: a promise’s reactions run as microtasks via btv.schedule, exactly the way the browser runs .then callbacks off the current stack, so :next is always asynchronous and the ordering matches Promises/A+. btv.async drives a coroutine one resume at a time — each btv.await yields the awaited promise, and the runner re-enters the coroutine with the value (or re-raises the reason) once it settles.

Under the hood a background actor owns timers and process I/O and wakes the editor loop when something completes, so deferred work and subprocesses run off the input tick without ever stalling the editor. The transports stay native (btv._system_async / btv._spawn_stream for process, the btv._fs_* bridge for files, the daemon on wasm); only the shape is promises.

See also

Testing plugins

bemtvi plugins are pure Lua over the btv.* API (ADR 0002), so their tests are too. A plugin repo carries a test/*_spec.lua suite that drives a real editor — feeds vim keys, then asserts on the resulting buffer, cursor, or UI — run headlessly by bemtvi --test-plugin. No mocks, no stubs: the same end-to-end philosophy as bemtvi’s own Rust black-box harness (crates/bemtvi-test-harness), reachable from your plugin’s own repo and CI.

The framework is btv.test — describe / it / expect with a small async context — shaped like a familiar BDD test runner (busted / Jest), so a spec reads the way you’d expect.

Quick start

Put specs under test/ in your plugin repo (each file must end _spec.lua):

-- test/my_plugin_spec.lua
btv.test.describe("my-plugin", function()
  btv.test.before_each(function()
    require("my-plugin").setup({})
  end)

  btv.test.it("inserts text", function(t)
    t:feed("itext<Esc>")                          -- type in insert mode, then escape
    btv.test.expect(t:lines()).to_equal({ "text" })
    btv.test.expect(t:mode()).to_be("n")
  end)
end)

Run it — defaults to the current directory:

bemtvi --test-plugin                 # runs ./test/**/*_spec.lua
bemtvi --test-plugin path/to/plugin  # or an explicit plugin dir

The runner boots an embedded editor with your plugin on the runtimepath (so require("<your-plugin>") resolves), runs every spec, prints a report, and exits 0 (all pass) / 1 (any fail) — drop it straight into CI.

The hermetic slate

Each plugin runs in isolation: no user init.lua, an in-memory clipboard, no persistence (shada), and your plugin as the sole runtimepath entry. A test exercises your plugin against a clean editor and nothing else.

Isolation also holds between tests. One editor serves the whole run, so the runner takes a baseline snapshot of the world once — after every spec file has been sourced — and restores it before each test. That timing is the contract:

  • A file’s load-time setup is the baseline. A require("my-plugin").setup{} or a dofile at the top of a spec file runs before the snapshot, so it is the state every test in the run starts from, and no test can undo it.
  • What a test changes is put back. Restored: global and window-local options, btv.g, the named registers, every sandbox expression surface (btv.fold.text, btv.indent.expr, btv.filetype.detect, btv.picker.scorer, btv.complete.scorer, btv.decor.expr, btv.qf.text, btv.qf.parse), the quickfix list, and any keymap, user command or btv.decor provider a test added. The buffer goes back to a fresh empty one in normal mode.

Some things are not restored, because a snapshot has no way to rebuild them: a keymap, user command or decor provider a test deleted, autocmds, the window layout, and buffers beyond the one the reset replaces. For those, clear down yourself — a before_each that puts the state you depend on into a known shape is more reliable than an after_each that tidies up, because a test that fails part-way never reaches its after_each:

btv.test.describe("my plugin", function()
  btv.test.before_each(function()
    btv.cmd("silent! only")        -- one window, whatever the last test opened
    btv.augroup.create("MyPluginTest", { clear = true })  -- and no stale autocmds
  end)
end)

The one thing restore cannot reach: your module’s own state

Restore puts the editor back. It cannot see the locals inside your plugin, and that asymmetry has a sharp edge — a module that memoizes “I already registered my command” will go stale:

-- WRONG. Test 1 registers the command and flips the memo. Restore then removes
-- the command (a test added it), but `state.commands` is still true, so every
-- later `setup` early-returns and `:MyPlugin` no longer exists.
local function register_commands()
  if state.commands then return end
  state.commands = true
  btv.command("MyPlugin", run, opts)
end

btv.user_command.create and btv.keymap.set key their registries by name, so re-registering is already idempotent — the memo buys nothing and only encodes an assumption about global state your module does not own. Drop it and register unconditionally. The same applies to any “did I do this once” flag guarding something the editor holds; keep such flags for state your module genuinely owns (a spawned process, a cache).

The tick model — why the context is async

The editor is tick-based: fed keys settle at the end of a tick, and the Lua state mirrors refresh before each Lua entry. So a single synchronous chunk that feeds then reads would see stale state (the Rust harness uses a fresh RPC round-trip per assertion for exactly this reason).

btv.test handles it for you: every it body runs inside an btv.async coroutine, and the context’s driving methods await internally. t:feed(...) queues the keys and awaits one tick, so by the next line the keys have drained and the reads are current. You write straight-line code; the awaits are under the hood.

Deterministic (synchronous) input settles in one tick. Asynchronous effects — a debounced popup, a timer, a file watch — won’t be ready on the next line; await them with t:wait_for(predicate):

btv.test.it("shows a debounced popup", function(t)
  t:feed("<Space>")
  local float = t:wait_for(function() return t:float() end)
  btv.test.expect(float.text).to_contain("write")
end)

API

Structure

CallMeaning
btv.test.describe(name, fn)A group; nestable.
btv.test.it(name, fn)A test; fn receives the context t.
btv.test.before_each(fn) / after_each(fn)Hooks, resolved per test along the describe chain (order-independent, busted-style — a hook declared after an it in the same block still applies to it).

Assertions — btv.test.expect(value)

Matchers are called with a dot; prefix any with .never to invert (btv.test.expect(x).never.to_equal(y)):

MatcherPasses when
.to_equal(x)value deep-equals x.
.to_be(x)value == x (identity).
.to_contain(x)value is a string containing substring x, or a list containing element x.
.to_match(pat)value is a string matching the Lua pattern pat.
.to_be_truthy() / .to_be_falsy() / .to_be_nil()The obvious.
.to_error([substr])value is a function that raises when called (optionally with a message containing substr).

The context t

Driving methods are async — they settle before returning:

MethodDoes
t:feed(keys[, opts])Type vim key-notation. opts.remap (default true), opts.insert, opts.settle (extra ticks).
t:cmd(excmd)Run an ex-command.
t:wait_for(pred[, opts])Await until pred is truthy (returns it). opts = { tries, interval, message }.
t:sleep(ms)Await a wall-clock delay.
t:exec(fn)Run fn now (it may itself await) and return its value.

Read methods are plain (correct after an await):

MethodReturns
t:lines([first, last]) / t:line(n)Buffer lines.
t:cursor(){ row, col }.
t:mode() / t:mode_info()The mode code ("n", …) / the full table.
t:current_line()The cursor’s line.
t:keymaps([mode])The defined maps (maparg shape).
t:float()The content float — { text, lines, title } — or nil.
t:message() / t:statusline()The message / status line text.
t:screen()The focused window’s painted rows, as a list of strings.
t:highlights([row])The highlight spans over those rows — { first, last, group }.
t:decor([row])The virtual text, virtual lines and gutter sign beside those rows.

t:highlights() and t:decor() are the views that can see a decoration — a btv.decor provider’s mark, a btv.decor.expr paint, a treesitter capture, an LSP inlay hint — which changes neither the buffer text nor the glyphs drawn. The split between them is what each layer is: t:highlights() colours the buffer’s own cells and names the group it painted; t:decor() reads the glyphs that are not in the buffer at all ({ virt_text, virt_pos, virt_col, virt_lines, sign }). Groups are absent from t:decor() on purpose — the wire carries a per-frame palette id for those layers rather than a name.

t:screen() is the sibling of t:lines(), and the difference decides which one a test should assert on. t:lines() is buffer text; t:screen() is what the client would actually draw. Anything the editor renders instead of a buffer line shows up only in the latter — a closed fold’s 'foldtext' placeholder, a ~ filler past the end of the buffer. Assert on t:screen() for those and on t:lines() for an edit. A virtual line is the exception in the other direction: it takes a screen row but its text rides its own layer, so the row reads blank in t:screen() and t:decor(row).virt_lines is what sees it.

Hermetic seams

For plugins that touch the clipboard or the filesystem:

  • btv.test.clipboard.seed(text[, linewise]) — put text on "+ / "* as if an external app set it. btv.test.clipboard.peek() → text, linewise (what a plugin wrote). btv.test.clipboard.clear().
  • btv.test.tempdir() — a fresh, already-created unique directory; pair with btv.fs to exercise a plugin’s file I/O without collisions.

A real example

bemtvi-keys-helper (the first-party which-key) ships a real suite, test/popup_spec.lua: it feeds a leader prefix, t:wait_fors the debounced popup, and asserts on t:float().text — group names, leaf descriptions, the built-in z grammar, and close-on-abort. It is a compact model of a UI plugin tested entirely through its observable surface.

Gating

The whole surface is off in a normal editor session: btv.test is nil and the UI mirror it reads (btv._ui) is unpopulated. It is turned on only by the --test-plugin runner (via the btv_enable_test_mode RPC), so a config or plugin can’t accidentally depend on the test API, and a normal session pays none of the per-redraw mirror cost.

Note. There is no virtual clock yet — tests use real wall-clock time plus t:wait_for / t:sleep, which covers debounce and timeout behavior. Faking the timer wheel is tracked as a follow-up.

See also

Autocommand events

These are the events the bemtvi editor currently emits. Register a handler for one with btv.autocmd.create (or vim.api.nvim_create_autocmd). The event name is matched exactly, and a handler’s callback receives the event table { id, event, match, buf, file, data }.

The list is the emitted set, not all of vim’s events. bemtvi fires events as features come to need them. A handler registered for an event that isn’t emitted yet (e.g. OptionSet) is accepted but simply never fires — it does not error. If you need one that’s missing, it’s a feature gap, not a config mistake.

Patterns support shell globs: a pattern such as "*.rs" (no /) matches a file event by the path tail, one containing a / matches the whole path, and "*" (or an omitted pattern) matches everything. A pattern with no glob metacharacter is an exact compare — so a FileType rust autocmd never glob-matches a path.

Hot-path events are synchronous

Events fall into two classes, and it decides whether your handler may be async.

Hot-path events fire while the editor converges a single input tick — that is, on nearly every keypress:

CursorMoved   CursorMovedI   TextChanged   TextChangedI   ModeChanged
InsertEnter   InsertLeave    BufEnter      BufLeave
WinEnter      WinLeave       WinScrolled   WinResized

Their handlers must be synchronous. Returning a promise from one raises, naming the event and the file:line you registered it at. The editor will never wait for it, so returning one means expecting an ordering that cannot happen.

This is not a ban on async work — only on handing back the promise. Start it and return nothing:

-- WRONG: raises. Nothing awaits this.
btv.autocmd.create("CursorMoved", { callback = function()
  return btv.lsp.buf.hover()
end })

-- Right: fire-and-forget, or defer with btv.schedule / btv.on_next_tick.
btv.autocmd.create("CursorMoved", { callback = function()
  btv.lsp.buf.hover()
end })

Every other event is async-capable: a callback may return a promise (btv.promise, or the result of an btv.* async call). A returned promise is never dropped — a rejection surfaces like any unhandled btv.promise rejection, named for the event that raised it.

The split exists so the machinery below never touches the per-keypress path.

What happens when a handler is async

Two guarantees, both reproducing for async what vim gets for free by being synchronous.

Late subscribers still get the event. If a handler registers another handler for the same event while the first is still running, the newcomer receives that event too. This is what makes a lazily-loaded plugin work: FileType wakes the plugin, the plugin’s config runs (possibly btv.await-ing), it registers its own FileType handler — and that handler still fires for the buffer that woke it. Nothing fires twice: delivery is filtered by registration order, not replayed wholesale.

Plugins loading late still get the startup file’s events. This is the same guarantee widened to the plugin-load boundary — see Plugins and the startup file.

The read sequence is ordered. BufReadPost → FileType → BufEnter → BufWinEnter advances one stage at a time, each waiting for the previous stage’s async handlers to finish. So a BufReadPost handler that detects the filetype asynchronously is reflected in the FileType that follows — you get one correct FileType, not a wrong one followed by a correction. This is vim’s order, and an async handler does not reorder it: BufEnter and BufWinEnter stay behind the read, however long it takes. BufWritePre (the write waits — see Writing) and QuitPre / ExitPre / VimLeavePre (the exit waits — see Quitting & exit) are awaited the same way.

Handlers have a time budget: 500 ms by default. It bounds how long the editor waits, never whether anything gets delivered — when it expires, the sequence advances and late subscribers are still served, so one slow handler cannot wedge a buffer half-initialised. Blowing it warns, naming the handler’s file:line; finishing late warns again with the elapsed time. Raise it for a handler you know is slow:

btv.autocmd.create("FileType", { timeout = 5000, callback = function() … end })

A handler that never settles at all never reports completion, so it stays listed in btv.autocmd.pending() — inspect with :lua print(vim.inspect(btv.autocmd.pending())). Warnings go to :messages.

Buffer lifecycle

EventWhen it firesNotes
BufAddA buffer is added to the buffer list — before its BufReadPost (a file open into a fresh buffer adds it, then reads it).Fires with the added buffer as <afile> (buf / file), so a :badd that never enters the buffer still carries it. The startup buffer never fires it (it is the baseline, like WinNew/TabNew skip the initial window/tab); only buffers created after startup do. BufCreate is an accepted alias (see Event aliases).
BufReadPostA file-backed buffer is first shown after reading an existing file from disk.Fires once per buffer (gated by the “announced” set). Fires for every buffer that lands in a window, not only the focused one — so a session/workspace restore announces each restored file rather than leaving background splits uninitialised until you focus them. buf / file set. BufRead is an accepted alias (see Event aliases). A handler registered while the plugins were still loading is replayed the reads that happened before it — see Plugins and the startup file.
BufNewFileA buffer is opened for a path with no file on disk — fires instead of BufReadPost.buf / file set.
FileTypeA buffer is first announced and whenever its filetype changes.match is the filetype (e.g. "rust"); file is the path. Where ftplugins and vim.lsp.enable attach. On the first announce it is ordered behind BufReadPost’s handlers, including async ones — see What happens when a handler is async.
BufEnter / BufLeaveA buffer becomes / stops being the current one (including plain switches with no read), and BufEnter again on a re-read of the buffer that is already current (:e!) — a reload re-enters what it re-read.buf / file set. Hot-path, so handlers must be synchronous. BufLeave fires before the incoming buffer is read, BufEnter after: a switch is BufLeave → BufReadPost → FileType → BufEnter, so the outgoing buffer’s teardown always precedes the incoming one’s setup. On a buffer’s first announce BufEnter is ordered last, after BufReadPost and FileType have settled. A reload fires no BufLeave — nothing was left. Restoring a session does not fire either for background windows — nothing became current there.
BufWinEnterA window displays a buffer it wasn’t displaying — a switch (:b, :e), a split onto a file, a window a session/workspace restore fills (including non-current ones the current-buffer events never visit), and a re-read of a displayed buffer (:e!). Fires per window, so opening a buffer in a second window fires again even though it was already on screen, and again each time you switch back and forth. Navigation never fires it: a tab switch, <C-w>w, and a no-arg :split (which inherits the buffer it was split off) display nothing new.buf / file set. Gated on a registered handler. Fires last on a buffer’s first announce — after BufReadPost, FileType and BufEnter have settled, async handlers included. The handler runs with the window that displayed as the current one — btv.win.current(), btv.wo, and the cursor reads all address it, including for a session/workspace restore filling background windows. That context is the mirror one, not a focus change: the editor never moves your cursor to run a handler. So a mutation that binds to “current” only when it drains (btv.cmd, feedkeys) raises while the two differ, naming the fire — run it outside, or use an explicit-handle API (btv.wo[win], btv.bo[buf], btv.win.set_cursor). When the displaying window is the focused one — everything you type — nothing is locked. The window context covers the handler’s synchronous run: past an await, an async handler is back in the ordinary context, so capture local win = btv.win.current() before the first await and write through btv.wo[win]. Every window that displayed the buffer gets its own fire, including ones that displayed it while an async BufReadPost was still settling; a window closed before that read completes fires nothing.
BufReadCmdA deferred open is about to perform its default read — vim’s “replace the default read” hook. A handler that returns true claims the read: it owns filling the buffer (e.g. the file explorer listing a directory) and the default load is skipped.match / file is the path; args.isdir says whether it’s a directory. Gated on a registered handler.
BufDeleteJust before a buffer is deleted (:bdelete), while its state still exists.buf / file set.

Ordering on opening a file is BufAdd → BufReadPost (or BufNewFile for a new path) → FileType → BufEnter → BufWinEnter, with BufLeave for the buffer being left ahead of the whole sequence; an async handler does not reorder it — see What happens when a handler is async. Re-reading the current buffer (:e!) fires the same sequence minus the BufLeave and the BufAdd (the bufnr is neither left nor new).

Plugins and the startup file

Plugins load asynchronously. btv.plugins awaits a spec’s directory before sourcing it, and a spec’s config may btv.await on its own — so a plugin’s config, and every autocmd that config registers, lands several ticks into startup. The file you named on the command line has already been read by then. Painting before the plugins are up is deliberate: it is what makes bemtvi file.txt open instantly.

You do not have to work around it. Every first-announce event fired before the plugins were ready is replayed to the handlers that registered while they were loading, when PluginsLoaded fires:

BufReadPost   -> the handlers that exist now (your init.lua's, the built-in
FileType         treesitter / LSP attach) — so the file colours immediately
BufEnter
VimEnter
  <plugin configs run, registering their own BufReadPost / FileType handlers>
PluginsLoaded -> BufReadPost / FileType replayed to exactly those handlers

So a plugin registers a plain BufReadPost handler and sees the startup file, with the buffer and the match it was read with. There is no separate event to hook and no sweep of btv.buf.list() to write. Restored session windows are covered the same way.

Three things worth knowing:

  • Nothing fires twice. Delivery is filtered by registration order, the same watermark the async replay above uses. A handler registered before the read — one from your init.lua — receives it on the read and is never replayed to.
  • Only BufReadPost, BufNewFile and FileType are replayed. They fire once per read and carry no pairing semantics, so re-delivering one is unambiguous. BufEnter and BufWinEnter are not: they mean “became current” / “a window displayed it”, which may no longer be true by the time the plugins land, and a BufEnter replayed without its BufLeave twin would misreport editor state.
  • The window is a startup one. It closes at PluginsLoaded and never reopens, so a handler registered later — by a lazy plugin, or by you at the : prompt — gets the reads that follow it and nothing from before.

Writing

EventWhen it firesNotes
BufWritePreBefore the buffer is serialized to disk, on :w / :wq / :x / :wall / :wqa. A handler may mutate the buffer (format-on-save, trim trailing whitespace) and the mutation is what gets written — vim’s pre-write contract. A handler may also be async: return a promise (e.g. an async LSP format) and the write waits for every handler to settle before serializing.match / file is the path; glob-matchable (*.rs). The buffer is still modified here (it is clean by BufWritePost). Firing order is Pre → await handlers → write → Post, locally and over the daemon/web wire. For :wall/:wqa each buffer is made current for its own BufWritePre (vim’s aucmd_prepbuf), so a mutating handler targets the right buffer; :wqa quits only once every write has committed. A handler whose promise rejects does not block the write (a failing formatter still saves).
BufWriteSame point as BufWritePre — the bare-name spelling.An alias for BufWritePre (see Event aliases); handlers on both spellings fire once.
BufWritePostAfter a successful write.The hook “reload affected tools” plugins use.

Window & tab

EventWhen it fires
WinNewA new window is created.
WinEnter / WinLeaveFocus moves to / away from a window.
WinClosedA window is closed.
WinResizedA window’s rectangle changes (split/resize/layout change).
WinScrolledA window’s viewport scrolls — topline (vertical) or leftcol (horizontal) changes. match is the scrolled window’s id; fires per-window. Gated on a registered handler (high-frequency), so it costs nothing when nothing listens.
TabNewA new tab page is created.
TabEnter / TabLeaveThe active tab changes.
TabClosedA tab page is closed.

Mode

EventWhen it fires
InsertEnter / InsertLeaveThe editor enters / leaves Insert (or Replace) mode.
ModeChangedThe reported mode() code changes. match is the transition old:new (e.g. "n:i", "v:n"); a handler’s pattern glob-matches it ("*:i", "n:*", "*:*"). A Normal↔MultiCursor swap fires "n:m" / "m:n" (MultiCursor reports its own "m"). Gated on a registered handler.

Editing & cursor

These fire at high frequency, so they are gated on a registered handler — when no autocmd listens for them they cost nothing.

EventWhen it fires
TextChangedThe buffer’s text changes in Normal mode (edit, paste, …).
TextChangedIThe buffer’s text changes in Insert mode (per keystroke).
BufModifiedSetA buffer’s 'modified' flag flips: the first edit to a clean buffer, a write, an undo back to the saved text, :set nomodified. Fires for that buffer (buf / file), current or not. Gated on a registered handler. This is the signal for a [+] indicator: unlike TextChanged, it fires the same way in a session that never leaves Insert ('modeless').
CursorMovedThe cursor moves in Normal mode.
CursorMovedIThe cursor moves in Insert mode.

Mouse

EventWhen it firesNotes
MenuPopupA right-click asks for the context menu: with 'mousemodel' set to popup_setpos (the default) or popup, after the click has done its default. Under popup_setpos that means the caret has moved to the click, unless the click landed inside the selection, which is kept.buf / file are the current buffer. Read the click with btv.getmousepos(). A mapped <RightMouse> replaces the default, so it fires no MenuPopup, and neither does mousemodel=extend. The prelude’s own handler opens the context menu, in every session (see The context menu). Add rows with a group there, not by opening a menu from a handler of your own: only one menu shows at a time.

LSP

EventWhen it firesNotes
LspAttachA language server attaches to a buffer.data = { client_id = … }.
LspDetachA language server detaches from a buffer.data = { client_id = … }.
LspProgressA server reports on long-running work ($/progress) — indexing, loading a workspace.The pattern is the kind (begin / report / end), so pattern = "end" narrows to completions. data = { client_id, token, kind, title, message, percentage, cancellable }, with nil for a field the server didn’t send. Read the settled state with btv.lsp.progress() rather than accumulating updates yourself.

Treesitter

EventWhen it firesNotes
TSInstalledA :TSInstall or :TSUpdate of one language ended, built or failed.The pattern is the language. data = { lang, ok, error }, error being why when ok is false. Buffers repaint by themselves; this is for what a plugin paints from btv.treesitter.highlight. Fires only in the editor that ran the install; btv.treesitter.reload has another look again.

Files & environment

EventWhen it firesNotes
FileChangedShellA loaded file changed on disk (the watch/checktime reconcile).A handler may set vim.v.fcs_choice to "reload" / "edit" / "ask".
FileChangedShellPostAfter the file-change reconcile completes.
DirChangedThe working directory changes (:cd / :lcd / :tcd).file is the new cwd; match is the scope.
EncodingChangedThe current buffer’s 'fileencoding' is changed in place (:set fileencoding=…).match is the new encoding label (e.g. "latin1"); buf / file set. Reading a file (opening it, or an :e ++enc=… reload) only (re)seeds the baseline from the detected encoding — that is not a change, so it fires nothing (neovim, whose global encoding is fixed, likewise fires nothing on read). FileEncoding is an accepted alias (see Event aliases).
ColorSchemeA colorscheme finishes loading.match is the colorscheme name. This is what colorscheme plugins hook.

Startup

EventWhen it firesNotes
VimEnterOnce, after the editor has finished starting (config sourced, first frame imminent).vim.v.vim_did_enter is 1 from this point on. The built-in plugin manager’s first-run prompt hooks it.
UIEnterEach time a client attaches its UI — after VimEnter, since startup completes before any client connects.btv.ui.caps() describes the client that just attached (keyboard_protocol / truecolor / osc52) and is refreshed immediately before this fires. Hook it for setup that depends on what the terminal can do — notably a <C-h> / <C-i> / <C-m> / <C-[> mapping, which is only distinguishable from <BS> / <Tab> / <CR> / <Esc> under the kitty keyboard protocol. Fires again on a daemon re-dial (a second attach), so a handler must be idempotent.

Quitting & exit

Fired when the editor is really leaving — a committed :qa / last-window :q / :wq / :x / :wqa (! skips only the modified-buffer E37 guard, not these events). The three *Pre events are awaited: a handler may return a promise and the exit waits for every handler to settle before advancing, so an async flush/cleanup runs before the process leaves. They fire in order.

EventWhen it firesNotes
QuitPreFirst, once the quit is committed (the E37 guard has passed or ! bypassed it).Awaited. A handler’s returned promise holds the exit until it settles. Unlike neovim, QuitPre does not fire when :q merely closes one of several windows (the editor keeps running) — only on the real editor exit.
ExitPreAfter QuitPre settles — “the editor is really leaving”.Awaited. The natural place for an async flush before quitting.
VimLeavePreAfter ExitPre settles — the last hook before session state (shada) is written.Awaited. Modify anything you want persisted from here.
VimLeaveAfter VimLeavePre settles, immediately before the editor exits.Not awaited (nothing remains to wait for) — a returned promise is tracked but the exit does not block on it. Post-cleanup only.

A handler cannot cancel the quit (these events are advisory, as in neovim), and a handler whose promise rejects does not block the exit — a failing cleanup still lets the editor leave.

Plugins

Fired by the built-in plugin manager (btv.plugins). See Writing bemtvi plugins.

EventWhen it firesNotes
PluginsLoadedOnce, after every eager (non-lazy) plugin declared by your config has fully loaded and settled — its plugin/ scripts sourced and its config run, an async config awaited to completion. Gated on VimEnter, so it never fires before startup finishes.The “all my plugins are ready” hook — run setup that depends on several eager plugins here. Fires once; a plugin a later :PluginSync installs still emits its own PluginLoaded but does not re-fire this. Lazy plugins are not waited for. It is also the point the startup announce window closes — see Plugins and the startup file.
PluginLoadedEach time any one plugin finishes loading — eager at startup, or lazy the moment its cmd/event/ft/keys trigger loads it.match (and data.name) is the plugin name, so btv.on("PluginLoaded", { pattern = "my-plugin" }, …) hooks just that plugin’s load.

To hook one named plugin, prefer btv.plugins.on_loaded(name, fn) over subscribing to PluginLoaded yourself. The event only reports a load that happens later, so the raw subscription silently never runs if that plugin turns out to be eager and already loaded; on_loaded runs fn immediately in that case and waits otherwise, so it fires exactly once either way:

btv.plugins.on_loaded("bemtvi-lspconfig", function()
  require("bemtvi-lspconfig").setup()
end)

User

User is the freeform event namespace: any plugin (or bemtvi itself) fires one with btv.autocmd.exec("User", { pattern = "MyEvent", data = … }), and a handler subscribes by pattern. bemtvi fires User DaemonStatusChanged whenever a daemon session’s connection phase changes (read the phase with btv.daemon.status()).

Event aliases

A handful of neovim event names are aliases for another event. bemtvi honors these by canonicalizing the alias to its real event at registration — so a config that does btv.autocmd.create("BufRead", …) (muscle memory from neovim) behaves exactly as if it had used "BufReadPost", and the callback’s event field reports the canonical name. Aliases are accepted anywhere an event name is (create, exec, get, clear).

AliasCanonical event
BufReadBufReadPost
BufWriteBufWritePre
BufCreateBufAdd
FileEncodingEncodingChanged

Every alias’s target event is one bemtvi actually emits — registering on an alias whose target never fired would be a silent no-op, so we don’t add one until the target exists.

btv.* API Reference

The public btv.* Lua API, extracted directly from the prelude (crates/bemtvi-lua/src/prelude/*.lua) by book/gen/generate.py. Every entry is a public declaration — a function, a handle method (view:mount(opts)), or a value (btv.json.null) — plus its doc-comment; private btv._* internals are excluded. This is the canonical surface per ADR 0002.

584 entries across 82 namespaces.

btv

Top-level btv.* functions (those without a sub-namespace), plus the methods of the handles they hand back (promise:, iter:, stream:, timer:).

btv.mode()

nvim_get_mode(): the editor’s current mode, read from the btv._cur_mode snapshot the server refreshes before each Lua entry. blocking is always false — the in-VM Lua bindings only run when the server is between keys, so it is never blocked on input here. (The dedicated RPC method serves remote clients.)

Defined in api.lua.

btv.current_line()

nvim_get_current_line(): the text of the line the cursor is on in the current window/buffer (no trailing newline). Composed from the cursor row and the buffer’s lines — a completion plugin reads this when it builds a completion context, which runs as soon as its core spins up, so a missing builtin would break completion (and every completion source) at load.

Defined in api.lua.

btv.regex(pattern, opts)

btv.regex(pattern, opts) -> regex: compile pattern into a reusable regex object for matching Lua strings — a more capable string.find/match/gmatch/gsub with a real regex dialect (named groups, alternation, lazy quantifiers, …). The match runs in Rust, so a string you already hold in Lua is matched in place (no copy). For searching buffer text instead, use btv.buf.search. Raises on an invalid pattern.

opts (all optional):

engine     = "pcre" | "vim"  -- regex dialect (default "pcre", the `regex` crate)
plain      = false           -- match the pattern literally (ignores `engine`)
ignorecase = false           -- case-insensitive match

Offsets follow the string library: 1-based and byte-based, with :find’s end inclusive, so s:sub(re:find(s)) is the matched text. The returned object has:

re:find(s, init?)    -> start, end, cap1, … | nil   (like string.find)
re:match(s, init?)   -> the capture(s), or the whole match if the pattern
                        has none, or nil            (like string.match)
re:gmatch(s)         -> iterator over each match's captures or whole match
                                                    (like string.gmatch)
re:find_all(s)       -> { s1, e1, s2, e2, … }: every match's start/end, in
                        one pass (a `:find` loop is quadratic on a long string)
re:gsub(s, repl, n?) -> newstring, count            (like string.gsub)
    repl is a string (`%0` whole match, `%1`-`%9` captures, `%%` literal `%`),
    a function called with the captures (return nil/false to keep the match),
    or a table keyed by the first capture.
re:test(s)           -> boolean: does the pattern match anywhere

init is 1-based and may be negative to count from the end, as in string.find.

local re = btv.regex([[(\w+)@(\w+)]])
local _, _, user, host = re:find("to jo@acme now")  -- "jo", "acme"
for word in btv.regex([[\w+]]):gmatch("one two") do ... end
local masked = btv.regex([[\d]]):gsub("id 42", "*")  -- "id **"

Defined in api.lua.

btv.replace_termcodes(str, _from_part, _do_lt, _special)

nvim_replace_termcodes(str, from_part, do_lt, special): translate key notation (<CR>, <C-w>, <lt>, …) into neovim’s terminal-byte encoding — <C-o> → \15, <CR> → \r, <Esc> → \27, <lt> → < — via the native btv._replace_termcodes. Keys with no single ASCII byte (arrows, function keys) have no such encoding bemtvi can emit (neovim uses K_SPECIAL sequences bemtvi doesn’t model), so they stay as <...> notation, which parse_keys still consumes — so the result round-trips exactly through nvim_feedkeys (build a “feed string”, later feed it). The flags (from_part / do_lt / special) only shape neovim’s byte output and are accepted for call-compatibility; bemtvi always translates the special names and <lt>.

Defined in api.lua.

btv.mode_str(_expanded)

btv.mode_str([expanded]) [alias vim.fn.mode]: the single-letter mode code ("n"/"i"/"v"/"V"/"R"/"c"). (Distinct from btv.mode(), which returns the nvim_get_mode {mode, blocking} table.) INCOMPLETE: expanded is ignored — the core has a flat Mode (no operator-pending / sub-state), so mode(1)’s multi-char forms ("no", "niI", …) don’t exist here; the short code is returned for both.

Defined in api.lua.

btv.err_writeln(msg)

Message writers (aliases nvim_err_writeln / nvim_err_write / nvim_out_write). Error writers route through btv._echo_err, which lands on the message line and in :messages painted red (the core echo_err path); out_write funnels through print like a plain message. nvim_err_writeln/out_write append a newline; the *_write forms don’t (the message line is line-oriented, so both just emit the text).

Defined in api.lua.

btv.err_write(msg)

No documentation comment in the prelude.

Defined in api.lua.

btv.out_write(msg)

No documentation comment in the prelude.

Defined in api.lua.

btv.list_uis()

btv.list_uis() [alias nvim_list_uis]: the attached UIs. bemtvi drives one client at a time, so this reports a single UI sized to the editor screen (vim.o.columns/lines), with the fields a layout calculation reads. The ext_* feature flags are all false (bemtvi’s redraw protocol carries no external-UI widgets).

Defined in api.lua.

btv.exec(src, output)

btv.exec(src, output) [alias nvim_exec]: run the ex-command(s) in src and, when output is truthy, return the text they produced (see exec_capture).

Defined in autocmd.lua.

btv.on(event, opts, fn)

Events — structured autocmd subscriptions. btv.on(event, opts, fn): the canonical verb. fn (when given) is the handler; otherwise opts.callback / opts.command apply, exactly as the underlying registry expects. Returns the subscription id (droppable with btv.off).

btv.on(event, fn) is the same thing with no options — the spelling to reach for when there is nothing to configure:

btv.on("FileType", { pattern = "lua" }, function(ev) … end)  -- with options
btv.on("BufWritePost", function(ev) … end)                   -- without

Defined in btv.lua.

btv.off(id)

Drop a subscription created by btv.on.

Defined in btv.lua.

btv.command_disabled(name)

btv.command_disabled(name) -> whether this editor refuses the command name spells (any spelling: "e" and "edit" alike; a leading : and a trailing ! are ignored). An app embedding the editor can take commands away from it; running one then fails with E5800, on every route. Always false elsewhere.

Defined in btv.lua.

btv.command(name, fn, opts)

User commands — btv.command(name, fn, opts) defines :Name; fn is a function or an ex-command string.

Defined in btv.lua.

btv.uuid()

btv.uuid() -> a fresh random (version-4) UUID as a canonical 8-4-4-4-12 lowercase-hex string, e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479". Bytes come from the OS CSPRNG, so each call is unique; handy for a session id, a temp-file name, or any unique key. Available on every build (native and browser/wasm).

Defined in btv.lua.

btv.echo(msg)

btv.echo(msg) -> nil. Append msg (a string) to the message line — the programmatic echo, the canonical form of vim.api.nvim_echo. For a transient, separately-styled notification prefer btv.notify.

Defined in btv.lua.

btv.argv()

btv.argv() -> the list of positional file arguments this process was launched with (strings; empty when none). A launcher / wrapper reads them to forward to a relaunched editor; carried through the BEMTVI_ARGV environment variable, so the binary stays the single source of truth.

Defined in btv.lua.

btv.now_ms()

btv.now_ms() -> a monotonic timestamp in milliseconds (a number) for timing and scheduling math. Unlike os.clock (CPU time, ≈0 across an awaited tick) it advances with real wall-clock time, so it measures durations that span async work.

Defined in btv.lua.

btv.runtime_file(name, all)

btv.runtime_file(name[, all]) -> full paths of runtimepath files matching name (a runtimepath-relative path whose final component may be globbed with *), as a list. With all falsey it returns just the first match (a one- or zero-element list). Reads the LIVE runtimepath, so a plugin installed mid-session contributes its files immediately. The lsp/<server>.lua config-discovery primitive.

Defined in btv.lua.

btv.open(path, opts)

btv.open(path[, opts]) -> nil. Open a file (or a directory, which opens the file explorer) in the editing area. opts is an optional table:

  • reuse (boolean, default true) — “open or jump”. How to handle the file when it is already up:

    • shown in a window → focus that window (across tabs under the default 'switchbuf' = usetab); the file is NOT reloaded and no split is made.
    • loaded but hidden → show that existing buffer in the current window, preserving its edits and cursor (no re-read, no duplicate buffer).
    • not open at all → read it fresh into the current window.

    This is what a file explorer, a “go to file”, or a jump-to-source wants: click a file that’s already on screen and you land on it rather than getting a second copy. Set reuse = false for plain :edit semantics — always load into the current window even when another window already shows the file, e.g. to deliberately place a buffer into a split you just created.

  • where ("main" | nil) — with where = "main" the open first crosses to the main editor layer, so an open fired from a dock / sidebar keymap lands in the main area instead of inside the dock. Omitted, it opens in the current window.

Note: only reuse (the default) consults 'switchbuf', and 'switchbuf' only ever redirects to a window already DISPLAYING the buffer — a hidden buffer has no such window, hence the “loaded but hidden” case above.

Defined in btv.lua.

btv.component(def)

btv.component(def) -> { mount(opts) } — build a reactive, Vue-shaped UI component for a plugin surface, then :mount() one or more instances of it. The reactive core is surface-agnostic: the same component can drive a focus-taking buffer or a passive float, chosen per def / mount.

def is a table:

  • render(state, inst) — REQUIRED and PURE. Maps the current state to what’s on screen and returns the surface’s output (see Surfaces). The framework re-runs it automatically whenever reactive state changes, coalesced to ONE render per tick. May be async (call btv.await(...) straight inside it).
  • setup(ctx, props) — OPTIONAL; runs ONCE on mount and owns every side effect — it creates reactive state, subscribes to events, binds keys, fetches data — and RETURNS the state value handed to render. Runs only after the surface is ready, so everything on ctx is already valid (no tick-dance). May be async.
  • surface — "view" (default) or "float"; or pass backend (a function(opts) -> adapter) to render to a custom surface.

The ctx handed to setup carries the reactivity and lifecycle:

  • ctx.reactive(tbl) — a deep reactive proxy; writing any key (s.x = 1) schedules a re-render. Iterate with ipairs / # (NOT pairs — PUC 5.4 has no __pairs).
  • ctx.computed(getter) — a cached derived value, read as c() or c.value; it re-evaluates only when a reactive input it read last time has changed.
  • ctx.refresh() — force a re-render. ctx.props — the opts.props from mount.
  • ctx.on_close(fn) / ctx.close() — register a teardown hook / close the instance. On the "view" surface ctx also gains: ctx.view, ctx.bufnr(), ctx.winid(), ctx.line(), ctx.set_cursor(n), ctx.bo / ctx.wo (the view’s buffer/window-local option tables), and ctx.keymap_set(mode, lhs, rhs, opts) (buffer-scoped + nowait by default). A view is read-only, so in a modeless session ('modeless') its keys reach it only through the modeless layer: pass { "n", "modeless" } as mode to serve both editing models.

Surfaces — what render returns, and how the surface behaves:

  • "view" — a focus-taking, navigable btv.view buffer (dock / split / grabbing float): the file-tree / list / modal-dialog case. render returns { lines, decor } (or a bare line list). btv.view.component(def) is the sugar.
  • "float" — a NON-focus btv.ui.float content float (the which-key surface): never steals focus, binds no keys. render returns { lines, title?, relative?, border? }; an EMPTY render HIDES the float (a later non-empty one re-opens it), so a component shows/hides purely by what it returns. Only one float component may display at once (a second fails loud rather than clobbering the single content-float slot).

mount(opts) instantiates and returns the instance (with :close()). opts.props is passed to setup; the rest configures the surface — view: name / filetype / dock / split / float (and eob to keep end-of-buffer tildes); float: title / relative / border. Render errors and setup errors are caught and surfaced via btv.notify rather than crashing the editor.

Persistence (view surface) — mount{ persist = "<id>", … } opts a view component into cross-session restore, the high-level form of btv.view.create{ persist=} + btv.view.on_restore. The framework resolves the owning namespace ONCE (from the mount call site, or opts.namespace for a no-attribution context — the same escape-hatch contract as btv.shada.plugin()), records only (namespace, id) + the view’s slot in the session, and on a restart picks fresh-vs-restore for you: it adopts the reserved slot if the session reopened the view, else mounts fresh — no on_restore handler, no VimEnter fallback. The content is the component’s own: setup reads it from ctx.store and a mutation saves it back. A persistent component’s ctx gains:

  • ctx.store — btv.shada.plugin(ns) for the resolved owner namespace: an isolated, cross-session key/value slice. Read saved state in setup, write it on every change.
  • ctx.namespace — the resolved owner namespace; ctx.persist_id — the stable id. (examples/view-persist/ is a runnable pinned-notes plugin built on exactly this.)

Example — a live-updating counter in a floating view:

local Counter = btv.component({
  setup = function(ctx)
    local s = ctx.reactive({ n = 0 })
    local keys = { "n", "modeless" } -- both editing models
    ctx.keymap_set(keys, "+", function() s.n = s.n + 1 end)  -- write -> re-render
    ctx.keymap_set(keys, "q", ctx.close)
    return s
  end,
  render = function(s)
    return { lines = { "count: " .. s.n, "", "+ to increment · q to quit" } }
  end,
})
Counter.mount({ float = { width = 30, height = 4, grab = true } })

Defined in component.lua.

btv.on_key_pending(fn)

btv.on_key_pending(fn): subscribe to the engine-computed pending-key signal. The server fires it whenever the pending key-context changes — a mapped prefix grows (you type <leader>, then w) or clears (the sequence completed, broke, or the idle flush resolved it) — NOT per keystroke (ADR 0002 rule 4: no per-key Lua). The handler receives ctx = { mode, keys, continuations = { { key, desc, kind = “map”|“group” }, … } } where keys is the withheld prefix in vim notation (“” when the context cleared, which a which-key popup treats as “close”) and each continuation is one key that extends the prefix — kind = "map" completes a mapping (carrying its desc), kind = "group" only leads on to longer mappings. The finite built-in prefixes (g -> gg/gt/.., z -> zt/zz/.., <C-w> -> window commands) enumerate continuations too, merged under one prefix with any maps that share it (g + the LSP gd/gD/gr).

available (boolean) is true for every reachable continuation. It is false only for a continuation kept visible but no longer firable: a mapped g-prefix (gd/gD/gr) surfaced AFTER the leader timeout committed g to the built-in grammar – the maps need a faster sequence to fire, so which-key shows them dimmed / cued rather than dropping them mid-popup. (A future per-line float highlight will let a which-key gray these properly; see docs/plans/2026-06-16-keypending-source-b.md.)

ctx.label (a string, or nil) is the source B channel: the built-in command grammar’s open pending states — f/t/F/T find-char, r replace, i/a text-object, marks, registers, <C-w> — have no finite key list to enumerate, so they arrive with continuations = {} and a human label instead ("Find character"). A which-key renders the label as a hint card when continuations is empty; sources A/C (mapped prefixes) leave label nil and list keys.

This is the render-time oracle a native which-key plugin debounces (btv.utils.debounce) and draws as a persistent btv.ui.float. Continuations come from the mapped-prefix trie (user + native-default maps, sources A/C) AND the built-in command grammar’s finite prefixes (source B); the open built-in states are surfaced via label instead.

Defined in keymap.lua.

btv.run_local(spec)

btv.run_local { cmd, args, cwd, env, stdin } -> promise of { code, stdout, stderr }. The local-always twin of btv.run: runs a one-shot child to completion on THIS machine, even when the session’s processes route to a daemon. Same spec + resolution as btv.run (RESOLVES, never rejects; a spawn failure is code = -1). Reach for it for client-side work — provisioning a remote, an ssh control-master, a docker cp — that must not run on the daemon. In a bare/local session it is exactly btv.run.

Defined in localseam.lua.

btv.run(spec)

btv.run { cmd, args, cwd, env, stdin } -> promise of { code, stdout, stderr }. Runs a child to completion off the input tick, buffering all of its stdout and stderr and resolving once, on exit.

Spec fields:

  • cmd — the program. A string, or an argv list whose first element is the program. Spawned directly, with NO shell: nothing is word-split, quoted, or glob-expanded, so pass each argument as its own element.
  • args — optional list appended after cmd, so { cmd = "git", args = { "log" } } and { cmd = { "git", "log" } } are equivalent.
  • cwd — optional working directory for the child.
  • env — optional { NAME = value } map of environment overrides.
  • stdin — optional string piped to the child; its stdin then closes (EOF).

RESOLVES (never rejects) with the exit result: a non-zero code is the caller’s to act on, and a spawn failure (e.g. binary not found) surfaces as code = -1 with empty output — exactly like vim.system. Await it inside btv.async, or chain with :next / :catch:

btv.async(function()
  local r = btv.await(btv.run({ cmd = "git", args = { "rev-parse", "HEAD" } }))
  if r.code == 0 then btv.print(r.stdout) end
end)()

The one-shot promise twin of btv.run_stream (stream stdout as it arrives). For a duplex child whose stdin stays open for a framed protocol (LSP/DAP) use btv.process instead.

Defined in process.lua.

stream:next()

No documentation comment in the prelude.

Defined in process.lua.

stream:kill()

No documentation comment in the prelude.

Defined in process.lua.

stream:exit()

stream:exit(): how the child ENDED — a table { code, stderr } once it has exited, or nil while it is still running (so it is meaningful only after the btv.await_each loop has finished). code is the child’s real exit status; -1 means the child never ran — the binary wasn’t found, or :kill() reaped it. stdout is empty here: it was already delivered through the stream.

The point of the accessor is to tell “the tool ran and its answer was nothing” apart from “the tool isn’t installed”, which output alone cannot distinguish. A fallback chain must branch on the status, not on emptiness — rg exits 1 on zero matches, and re-searching the tree with the next tool for that is pure waste:

local stream = btv.run_stream({ cmd = "rg", args = { "--vimgrep", "--", q } })
for batch in btv.await_each(stream) do ... end
local exit = stream:exit()
if exit and exit.code == -1 then ... end  -- no rg here; try the next tool

Defined in process.lua.

stream:pid()

stream:pid(): the running child’s OS pid, or nil. The pid is reported asynchronously by the event-loop actor (it can’t be known synchronously on the single-threaded runtime) into the btv._proc_pids registry this reads — so nil before the spawn lands, on a failed spawn, and again after the child exits (the registry entry dies with the child). Handy for signalling the child out-of-band (e.g. kill -USR1) where :kill()’s reap is too blunt.

Defined in process.lua.

btv.run_stream(spec)

btv.run_stream { cmd, args, cwd, env } -> Stream. Spawns a child and streams its stdout as it arrives, in newline-delimited batches — each batch a list of lines with the trailing newline stripped. Takes the same spec as btv.run minus stdin (the child’s stdin is closed at spawn). Only stdout streams; the exit status and stderr land together at the end, on stream:exit().

The streaming twin of btv.run — reach for it when output is large or long-lived and you want to act on lines as they come (the picker / completion sources feed results this way) rather than waiting for the child to exit. Iterate it with btv.await_each inside an btv.async function; call :kill() to reap the child early (e.g. a superseded query):

btv.async(function()
  local stream = btv.run_stream({ cmd = "rg", args = { "TODO" } })
  for batch in btv.await_each(stream) do
    for _, line in ipairs(batch) do btv.print(line) end
  end
end)()

Defined in process.lua.

btv.await_each(stream)

btv.await_each(stream): a for-loop iterator over a Stream’s batches. Each step awaits the next batch; the loop ends when the stream is exhausted (:next() resolves nil). MUST run inside an btv.async function (btv.await suspends the enclosing coroutine).

for batch in btv.await_each(stream) do
  for _, line in ipairs(batch) do ... end
end

Defined in process.lua.

promise:next(on_fulfilled, on_rejected)

:next(on_fulfilled, on_rejected) — the spine. Returns a NEW promise resolved with the handler’s return (adopting it if it’s itself a promise), or rejected if the handler throws. A missing handler passes the value/reason straight through to the returned promise, which is what makes a bare :catch at the end of a chain catch errors from anywhere earlier in it.

Named :next (not :then) because then is a Lua keyword — p:then(...) won’t parse. :next is the idiomatic Lua spelling; :catch/:finally keep their browser names verbatim.

Defined in promise.lua.

promise:catch(on_rejected)

:catch(on_rejected) — sugar for :next(nil, on_rejected), verbatim browser.

Defined in promise.lua.

promise:finally(on_finally)

:finally(on_finally) — run on_finally whichever way the promise settles, then pass the original value/reason through untouched (so finally can’t swallow a result or a rejection). Matches the browser’s pass-through semantics: when on_finally returns a promise (an async cleanup), the chain waits for it, and a cleanup that rejects (or throws) replaces the outcome with its own reason.

Defined in promise.lua.

btv.async(fn)

btv.async(fn) returns a function that, when called, runs fn as a coroutine and returns a promise for its result. Inside, btv.await(p) suspends until p settles and evaluates to its value (or re-raises its rejection as a Lua error), so a sequence of awaits reads top-to-bottom with no nesting:

local load = btv.async(function(path)
  local stat = btv.await(fs.stat(path))
  local data = btv.await(fs.read(path))
  return parse(data, stat)
end)
load("init.lua"):next(use):catch(report)

A rejected await raises inside the coroutine, so you can handle it either way: wrap the await in pcall to catch it locally (PUC 5.2+ yields across a pcall boundary, so this works on bemtvi’s 5.4 backend), or let it propagate to the coroutine edge (the returned promise rejects, caught by :catch on the result) or attach :catch to the awaited promise itself.

Defined in promise.lua.

btv.await(awaitable)

btv.await(awaitable): suspend the enclosing btv.async coroutine until awaitable settles. Returns the fulfilment value, or raises the rejection reason as an error (which, uncaught, rejects the async function’s promise). Errors loudly if called outside an btv.async coroutine — there is nothing to suspend.

Defined in promise.lua.

btv.schedule(fn)

btv.schedule(fn): defer fn to the end of the current convergence — it runs after the work that scheduled it settles, no longer nested in the caller’s stack frame (the strict improvement over the old inline fn()), but still within the same input tick (not a later wall-clock turn; that is defer_fn). This is exactly what the colorscheme’s “defer to avoid reentrancy” wants.

Defined in runtime.lua.

btv.schedule_wrap(fn)

btv.schedule_wrap [alias vim.schedule_wrap] (fn): return a function that, when called, schedules fn with whatever arguments it was given — a common plugin idiom for “run this callback safely on the loop”. The captured args ride into the deferred call via a closure.

Defined in runtime.lua.

timer:stop()

No documentation comment in the prelude.

Defined in runtime.lua.

timer:is_active()

No documentation comment in the prelude.

Defined in runtime.lua.

btv.timer(fn, timeout)

btv.timer(fn, timeout): the canonical timer / defer primitive (aliased by vim.defer_fn) — run fn once, timeout ms from now, on the loop — the off-tick deferral configs use for retry patterns. Returns a handle so the caller can :stop() it before it fires.

Defined in runtime.lua.

btv.on_next_tick(fn)

btv.on_next_tick(fn): run fn on the NEXT event-loop tick — the turn after the current one finishes. The cross-tick sibling of btv.schedule: where btv.schedule fires at the end of THIS convergence (a same-tick microtask, so it cannot observe state that only refreshes between ticks — a freshly-mounted window’s id, a mirror the server repopulates each turn), btv.on_next_tick yields the tick entirely and runs on the next one, when those mirrors have been refreshed. A zero-delay one-shot timer is exactly that. Returns the timer handle, so a caller can :stop() it before it fires. (Poll across several ticks by calling it again from within fn.)

Defined in runtime.lua.

btv.notify(msg, level, _opts)

No documentation comment in the prelude.

Defined in runtime.lua.

btv.notify_once(msg, level, opts)

btv.notify_once(msg[, level[, opts]]) [alias vim.notify_once]: btv.notify, but a given message is shown only the FIRST time it is sent this session (as neovim’s), so a deprecation warning raised from a hot path does not flood the message line. Returns true when the message was shown, false when it was suppressed as a repeat.

Defined in runtime.lua.

btv.inspect(value)

btv.inspect [alias vim.inspect]: pretty-print a value (tables recursively). A table reached again on the current descent renders as "<cycle>" — a plugin inspects arbitrary state (parent-linked trees, self-referencing registries), and unguarded recursion would blow the C stack instead of printing.

Defined in runtime.lua.

btv.exists(expr)

btv.exists(expr) [alias vim.fn.exists]: does the vim entity named by expr exist? (1 / 0). bemtvi answers the forms it can verify and reports 0 for the rest (rather than a fake

  1. so feature-probing stays honest:
  • '&opt' / '&l:opt' / '&g:opt' / '+opt' -> an option bemtvi models. A completion plugin gates every window-option write on exists('+'..key), so an unknown option is skipped instead of erroring the float setup.
  • 'g:'/'b:'/'w:'/'t:'/'v:' prefixed name -> that scoped variable is set.
  • ':Cmd' -> a user command bemtvi can confirm (2, neovim’s exact-match value); a buffer-local command for the current buffer counts, like at dispatch.
  • everything else ('*func', built-in ':write', bare names) -> 0 (can’t confirm).

Defined in state.lua.

btv.log

btv.log [alias vim.log]: the log-level constants plugins compare against.

Defined in state.lua.

btv.set_option(name, value)

btv.set_option / btv.get_option(name[, value]) [aliases nvim_set_option / nvim_get_option]: the global-scope (deprecated) accessors. Route through btv.o, the canonical global-option table that canonicalizes the scope the name implies.

Defined in state.lua.

btv.get_option(name)

No documentation comment in the prelude.

Defined in state.lua.

btv.npcall(fn, ...)

btv.npcall(fn, ...) [alias vim.npcall]: pcall that maps failure to nil — select(2, pcall(...)) on success, nil on error. A neovim helper kept for config/plugin convenience (wrap a call that may raise and treat failure as "no value").

Defined in stdlib.lua.

btv.nonnil(...)

btv.nonnil(...) [alias vim.nonnil]: the first non-nil argument, or nil (verbatim from neovim’s vim/_core/shared.lua; the replacement for the deprecated vim.F.if_nil). A general helper for defaulting an optional value.

Defined in stdlib.lua.

btv.keytrans(s)

btv.keytrans(s) [alias vim.fn.keytrans]: translate the internal terminal-byte form of a key sequence into readable key notation (\15 → <C-o>, \r → <CR>, <Space>, …) — the exact inverse of nvim_replace_termcodes, via the native btv._keytrans (parse_keys + key_to_notation). A string already in notation round-trips through unchanged.

Defined in stdlib.lua.

btv.print(...)

btv.print(...) [alias vim.print]: pretty-print each argument (via btv.inspect) on the message line and return them unchanged, so it can wrap a value inline. Strings print verbatim; tables are inspected.

Defined in stdlib.lua.

btv.iter(src, state, ctrl)

btv.iter(src[, state, ctrl]) [alias vim.iter]: wrap a list-like table OR a Lua iterator triple in a chainable iterator. The triple form is what vim.iter(vim.fs.parents(p)) passes — vim.fs.parents returns (fn, state, start), which Lua spreads as three args here — so the ancestors are drained eagerly into the item list.

Defined in stdlib.lua.

iter:find(pred)

Iter:find(pred): the first item for which pred(item) is truthy (or, when pred is a plain value, the first item equal to it), else nil.

Defined in stdlib.lua.

iter:any(pred)

Iter:any(pred): true iff pred(item) is truthy for some item.

Defined in stdlib.lua.

iter:flatten()

Iter:flatten(): flatten one level of list-valued items into the stream.

Defined in stdlib.lua.

iter:map(f)

No documentation comment in the prelude.

Defined in stdlib.lua.

iter:filter(f)

No documentation comment in the prelude.

Defined in stdlib.lua.

iter:each(f)

No documentation comment in the prelude.

Defined in stdlib.lua.

iter:fold(acc, f)

No documentation comment in the prelude.

Defined in stdlib.lua.

iter:totable()

No documentation comment in the prelude.

Defined in stdlib.lua.

btv.line(expr)

btv.line(expr) [alias vim.fn.line]: a buffer line number (1-based). "." is the cursor line, "$" the last line (the line count), "v" the fixed end of the live Visual selection (the cursor line outside Visual mode), and "'x" the line of mark x (0 when it is not set) — the same expressions btv.pos.get takes. The window-relative forms ("w0"/"w$") are not modelled and raise an error naming the expression.

Defined in vimfn.lua.

btv.col(expr)

btv.col(expr) [alias vim.fn.col]: a byte column (1-based). "." is the cursor column, "$" one past the end of the cursor line (its byte length + 1), matching vim.

Defined in vimfn.lua.

btv.localtime()

btv.localtime() [alias vim.fn.localtime]: the current time in seconds. bemtvi sources this from a MONOTONIC clock (the server’s btv._mono_secs, the same base stamped onto undo nodes), not wall-clock unix epoch, so localtime() - node.time elapsed math (e.g. the undotree visualizer’s "N minutes ago") stays correct and non-negative across NTP steps and manual clock changes. Only differences matter.

Defined in vimfn.lua.

btv.expand(expr)

btv.expand(expr) [alias vim.fn.expand]: the % (current file) and # (alternate file) forms autocmd callbacks and statuslines use to resolve paths, backed by the current-buffer snapshot and the # mirror. % / # are the stored names; a :<mods> suffix routes through fnamemodify (so %:t, %:p, #:h, #:r, %:~:., … all work). Any other expression errors loud. (the override below extends this with cursor keywords / globs, re-binding btv.expand.)

Defined in vimfn.lua.

btv.getmousepos()

btv.getmousepos() [alias vim.fn.getmousepos]: the most recent mouse event’s position as a dict — screenrow/screencol (1-based global screen cell), winid (the window the cell is in, 0 if none), winrow/wincol (1-based, window-relative, gutter included), line/column (1-based buffer line and byte column, 0 off a window’s text), and coladd (always 0 — bemtvi has no 'virtualedit'). Reads the btv._mouse_pos mirror the server pushes from the editor’s last mouse cell, so a mouse mapping (<RightMouse>, <MiddleMouse>, …) can act on the clicked position rather than the cursor.

Defined in vimfn.lua.

btv.cwd()

btv.cwd() [alias vim.fn.getcwd]: the editor’s effective working directory, as an absolute path with no trailing separator.

This is editor state, not a filesystem read: it is answered from the mirror the server republishes on every :cd / :lcd, so it costs nothing and is safe on a hot path. Over a daemon it reports the daemon’s cwd — the directory relative paths actually resolve against — which is the whole reason a plugin should ask here rather than at whatever process it happens to be running in.

The fallback root: a config that walks up looking for a project marker and finds none often wants “wherever the user is working” rather than the file’s own directory.

local root = btv.await(btv.lsp.find_root(bufnr, { ".git" })) or btv.cwd()

Defined in vimfn.lua.

btv.pid()

btv.pid() [alias vim.fn.getpid]: this editor process’s id.

What a language server wants when it offers a --hostPID-style flag: it watches that process and exits when the process does, so a crashed editor doesn’t leave a server holding a project’s worth of memory. In a daemon session this is still the process the server is a child of, which is the one it must watch.

Defined in vimfn.lua.

btv.version()

btv.version() [alias vim.version]: the editor’s version, as "bemtvi <x.y.z>".

For the protocols that carry a client identity — LSP clientInfo, a vendor’s “integration version” telemetry field — and for a plugin reporting what it is running under. It is a display string, not a comparable version object: bemtvi’s surfaces are not version-gated, so there is nothing here to branch on.

Defined in vimfn.lua.

btv.stdpath(what)

btv.stdpath(what) [alias vim.fn.stdpath]: the XDG directory bemtvi keeps what in, as an absolute path with no trailing separator. "config", "data", "cache", "state", "log", "run".

Where a plugin puts the things that are neither the user’s code nor the editor’s: a downloaded language server, a parser cache, a scratch workspace. Ask here rather than composing $HOME by hand — the answer honors $XDG_* and $BEMTVI_CONFIG, so a hand-built path silently diverges from where the editor itself looks.

local workspace = btv.utils.joinpath(btv.stdpath("cache"), "jdtls", "workspace")

The directory is named, not created: btv.fs.mkdir(dir, { recursive = true }) when you are about to write into it. It raises when the process has no home, no $XDG_* base and no platform default to put the directory under — rather than answering a path relative to the working directory.

Defined in vimfn.lua.

btv.align

btv.align.left(line, width)

btv.align.left(line, width): pad line on the RIGHT with spaces so it spans width display cells, keeping the text flush left. A line already at or wider than width is returned unchanged — it only adds spaces, never truncates. Width is measured with btv.str.width, so wide (CJK / emoji) glyphs pad correctly.

Defined in stdlib.lua.

btv.align.right(line, width)

btv.align.right(line, width): like btv.align.left, but pads on the LEFT so the text sits flush right within width display cells. At or over width → returned unchanged; cell-width aware (btv.str.width).

Defined in stdlib.lua.

btv.align.center(line, width)

btv.align.center(line, width): like btv.align.left, but splits the padding so the text is centred within width display cells; an odd leftover cell goes to the right. At or over width → returned unchanged; cell-width aware (btv.str.width).

Defined in stdlib.lua.

btv.augroup

btv.augroup.create(name, opts)

No documentation comment in the prelude.

Defined in autocmd.lua.

btv.autocmd

btv.autocmd.create(event, opts)

btv.autocmd.create(event, opts) -> id [alias nvim_create_autocmd]: run something whenever event fires. Returns the autocmd’s numeric id (pass it to btv.autocmd.del to remove it). event is an event name ("FileType", "BufEnter", …) or a list of names to share one handler — see the autocommand events reference for the events bemtvi emits and what each carries. neovim aliases ("BufRead" -> BufReadPost, "BufWrite" -> BufWritePre, "BufCreate" -> BufAdd, "FileEncoding" -> EncodingChanged) are accepted and canonicalized to the real event.

opts fields:

  • callback — a function run when the event fires; OR command — an ex-command string queued instead. Provide one of the two.
  • pattern — a glob (or list of globs) the event’s match string is tested against (e.g. "*.lua", { "*.c", "*.h" }). Omitted / "*" matches all.
  • group — an augroup, by numeric id or by name (see btv.augroup.create). Ties this autocmd to the group so a later clear of that group drops it.
  • buffer — make it buffer-local: it then fires only for that buffer (and pattern is ignored). 0 resolves to the current buffer at registration time.
  • once — fire once, then auto-remove. desc — a human description.
  • timeout — how many milliseconds the editor waits for this handler’s returned promise before warning and moving on (default 500). It bounds the wait, never the delivery: the work keeps running and late subscribers are still served. Raise it for a handler you know is slow (a first LSP spawn) so it does not warn on every open. Ignored by the hot-path events, whose handlers must be synchronous.

The callback receives one table describing the event: { id, event, match, buf, file, data } — id this autocmd’s id, event the event name, match the matched pattern string, buf the buffer number, file its name, and data an event-specific payload (e.g. LspAttach carries { client_id = … }), nil for most events.

An invalid glob in pattern raises here, at registration, naming the pattern and the reason — an autocmd that could never match is a typo, and the alternative is one that silently never fires for the rest of the session.

btv.autocmd.create("FileType", {
  pattern = "markdown",
  callback = function(ev)
    btv.bo[ev.buf].textwidth = 80
  end,
})

Defined in autocmd.lua.

btv.autocmd.del(id)

btv.autocmd.del(id) [alias nvim_del_autocmd]: remove the autocmd with this id, so it stops firing.

Defined in autocmd.lua.

btv.autocmd.pending()

btv.autocmd.pending() -> the autocmd handler promises still in flight past their settle budget, as { event, buf, site, elapsed_ms, budget } entries. A handler that hangs forever never settles and so never warns on completion — this is where it stays visible. Inspect with :lua print(vim.inspect(btv.autocmd.pending())).

Defined in autocmd.lua.

btv.autocmd.exec(event, opts)

btv.autocmd.exec(event, opts) [alias nvim_exec_autocmds]: fire event (or a list of events) manually. opts.pattern (string or list) is matched as in registration; opts.buffer supplies the buffer context (defaulting to the current snapshot buffer), and the callback’s args.file is the snapshot name when firing for it. opts.data is an arbitrary payload delivered to each handler as args.data (e.g. btv.autocmd.exec("User", { pattern = "MyEvent", data = … })).

opts.group (an augroup id or name) narrows the fire to THAT group’s handlers. Reach for it when a plugin needs to re-run its own handlers for a buffer — a late-loading plugin catching up on a FileType that already fired, say — without re-broadcasting the event to every other subscriber (the LSP dispatcher, treesitter, the user’s own autocmds), which would make them redo work they have already correctly done. An unknown group name fails loud.

Defined in autocmd.lua.

btv.autocmd.get(opts)

btv.autocmd.get(opts) [alias nvim_get_autocmds]: introspect the registered autocmds — a debugging affordance for confirming what clear/del left behind. Returns a list of {id, event, group, group_name, pattern, buffer, command, site} entries — site being the "src:line" the autocmd was registered at — optionally filtered by opts.event (string or list) and opts.group (id or name). Run it interactively as :lua print(vim.inspect(btv.autocmd.get({}))).

Defined in autocmd.lua.

btv.autocmd.clear(opts)

btv.autocmd.clear(opts) [alias nvim_clear_autocmds]: remove every autocmd matching the filter — the bulk analogue of btv.autocmd.del. opts.event (string/list), opts.group (id or name), opts.buffer, and opts.pattern (string/list) all narrow the set; an empty opts clears everything. Mirrors btv.autocmd.get’s matching.

Defined in autocmd.lua.

btv.buf

btv.buf.name(bufnr)

btv.buf.name(bufnr) -> string [alias nvim_buf_get_name / vim.fn.bufname]: the full name (path) of buffer bufnr; 0 / nil means the current buffer. Returns "" for an unnamed or unknown buffer. Read from the buffer mirror, so it can name any open buffer — e.g. a custom ‘tabline’ labelling a buffer shown in another tab.

Defined in api.lua.

btv.buf.current()

The buffer / window / cursor getters read the btv._bufs / btv._cur_* mirror the server refreshes before each Lua entry, so they return live state as of the start of this chunk. There is deliberately no buffer mutation surface here (no btv.buf.set_lines): plugins supply data through the higher-level surfaces, per this file’s header. Throughout, a bufnr of 0 / nil means the current buffer.

btv.buf.current() -> bufnr [alias nvim_get_current_buf]: the current buffer’s number (0 if there is none).

Defined in api.lua.

btv.buf.call(buf, fn)

btv.buf.call(buf, fn) -> any [alias nvim_buf_call]: run fn with buf (0/nil = current) installed as the current-buffer context, then restore the previous context and return whatever fn returned. Use it so buffer-relative lookups inside fn (name, options) resolve against buf. An error in fn propagates.

Defined in api.lua.

btv.buf.is_loaded(bufnr)

btv.buf.is_loaded(bufnr) -> bool [alias nvim_buf_is_loaded]: whether bufnr (0/nil = current) names a buffer that is loaded into memory. Backed by the buffer mirror, which carries every loaded buffer.

Defined in api.lua.

btv.buf.is_valid(bufnr)

btv.buf.is_valid(bufnr) -> bool [alias nvim_buf_is_valid]: whether bufnr (0/nil = current) names a buffer bemtvi knows about. There is no separate “valid but unloaded” state in the mirror yet, so this currently matches btv.buf.is_loaded.

Defined in api.lua.

btv.buf.changedtick(bufnr)

btv.buf.changedtick(bufnr) -> integer [alias nvim_buf_get_changedtick]: the buffer’s change counter (0/nil = current buffer), bumped by the core on every text change and never otherwise. 0 for an unknown buffer.

This is the canonical “did this buffer’s text change” signal: cache derived state against it and recompute only when it moves, instead of redoing the work on every event. A statusline component that enumerates matches, a plugin that parses the buffer, any per-buffer memo — key it on (bufnr, changedtick):

local memo = {}
local function matches(buf)
  local tick = btv.buf.changedtick(buf)
  local m = memo[buf]
  if m and m.tick == tick then
    return m.value
  end
  local value = expensive_scan(buf)
  memo[buf] = { tick = tick, value = value }
  return value
end

Defined in api.lua.

btv.buf.line_count(bufnr)

btv.buf.line_count(bufnr) -> integer [alias nvim_buf_line_count]: the number of lines in bufnr (0/nil = current); 0 for an unknown buffer.

Defined in api.lua.

btv.buf.offset(bufnr, index)

btv.buf.offset(bufnr, index) -> integer [alias nvim_buf_get_offset]: the byte offset at which 0-based line index starts — the sum of every preceding line’s bytes plus its newline. index == line_count gives the buffer’s total byte length. Returns -1 for an unknown buffer.

Defined in api.lua.

btv.buf.text(bufnr, start_row, start_col, end_row, end_col, _opts)

btv.buf.text(bufnr, start_row, start_col, end_row, end_col[, opts]) -> lines [alias nvim_buf_get_text]: the text of bufnr spanning (start_row, start_col) up to (end_row, end_col), returned as a list of lines (the span split on newlines). Rows are 0-based; columns are 0-based byte indices into their line; the end position is exclusive. Use this for a sub-line span — use btv.buf.lines for whole lines.

Defined in api.lua.

btv.buf.lines(bufnr, start, end_, strict)

btv.buf.lines(bufnr, start, end_[, strict]) -> lines [alias nvim_buf_get_lines]: the lines of bufnr (0/nil = current) in the 0-based, end-EXCLUSIVE range [start, end_). Negative indices count back from the end (-1 is one past the last line), so (0, -1) is the whole buffer. With strict true an out-of-range index errors; otherwise indices clamp into range. Returns a list of strings, each without its trailing newline.

Defined in api.lua.

btv.buf.set_lines(bufnr, start, end_, strict, replacement)

btv.buf.set_lines(bufnr, start, end_, strict, replacement) -> promise [alias nvim_buf_set_lines]: replace lines [start, end_) of bufnr (0/nil = current) with replacement (a list of whole-line strings), 0-based and end-EXCLUSIVE. Negative indices count back from the end (-1 is one past the last line), so (0, -1, …) replaces the WHOLE buffer and (n, n, …, { "x" }) appends. With strict true an out-of-range index errors; otherwise indices clamp.

This is the editor’s ONE buffer-text mutation, and it is ASYNCHRONOUS: the Lua VM cannot touch the live buffer mid-chunk, so the edit is QUEUED and applied right after this chunk. The returned promise fulfils (with nil) on the next tick — once the edit has landed and the buffer mirror reflects it — so btv.await(btv.buf.set_lines(…)) is the point at which a following btv.buf.lines read sees the new content. The shape and the buffer’s modifiability are validated SYNCHRONOUSLY and raise (fail loud) before anything is queued: a non-table replacement, a non-string / newline-bearing line, an unknown buffer, a nomodifiable buffer, or (under strict) an out-of-range index. A read-only buffer KIND (terminal / btv.view / quickfix) is refused loudly server-side.

Defined in api.lua.

btv.buf.set_text(bufnr, start_row, start_col, end_row, end_col, replacement)

btv.buf.set_text(bufnr, start_row, start_col, end_row, end_col, replacement) -> promise [alias nvim_buf_set_text]: replace the CHARACTER range (start_row, start_col)..(end_row, end_col) of bufnr (0/nil = current) with replacement (a list of lines). 0-based; rows are line indices, columns are byte offsets within the line; the range is end-EXCLUSIVE. Unlike set_lines this is a precise SUB-LINE edit: set_text(0, 4, 0, 7, { "xy" }) replaces bytes 4..7 of line 0 in place, and replacement is spliced verbatim (its lines joined by \n, with NO trailing newline added). The precise edit a snippet / structural-edit plugin uses to swap a trigger word for an expanded body, or to update a mirror inline.

Like set_lines this is the editor’s ONE buffer-text mutation and is ASYNCHRONOUS: the edit is QUEUED and applied right after this chunk, and the returned promise fulfils (with nil) on the next tick — once the mirror reflects it — so btv.await(btv.buf.set_text(…)) is the point a following read sees the new content. The shape and modifiability are validated SYNCHRONOUSLY and raise before anything is queued (a non-table replacement, a non-string / newline-bearing line, an unknown or nomodifiable buffer); the server refuses a read-only KIND (terminal / btv.view / quickfix) and an inverted span loudly. Out-of-range coordinates clamp into the buffer.

Defined in api.lua.

btv.buf.attach(buffer, opts)

btv.buf.attach(buffer, { on_bytes = fn, on_reload = fn }) -> detach() [partial alias nvim_buf_attach]: subscribe to buffer’s (0/nil = current) change stream. At least one callback is required; an unsupported key fails loud (only on_bytes / on_reload are wired — on_lines / on_detach / on_changedtick stay deferred). Returns a detach() closure that removes this subscription; a callback may also return true to detach itself (neovim’s convention). The callbacks fire on the editor thread AFTER every mirror is consistent, so one may read btv.buf.lines / btv.buf.extmarks and see the post-edit state.

on_bytes is called on_bytes("bytes", bufnr, changedtick, start_row, start_col, start_byte, old_end_row, old_end_col, old_end_byte, new_end_row, new_end_col, new_end_byte) — the exact neovim tuple (rows 0-based, *_byte a total buffer byte offset, the old_* / new_* the removed / inserted extent). on_reload is called on_reload("reload", bufnr) when the whole rope was replaced (the delta stream is invalid — re-read the buffer whole).

Defined in api.lua.

btv.buf.search(bufnr, pattern, opts)

btv.buf.search(bufnr, pattern, opts) -> match | nil: find pattern in bufnr (0/nil = current) over the buffer mirror, line by line. The native counterpart to scanning lines in Lua — it runs the match in Rust (the regex crate or the vim engine) so a plugin can jump straight to a section (a conflict marker, a heading).

opts (all optional):

plain      = false             -- literal substring (ignores `engine`)
engine     = "pcre" | "vim"    -- regex dialect (default "pcre")
from       = { line=1, col=0 } -- start position: 1-based line, 0-based byte col
backward   = false             -- search upward from `from` instead of down
ignorecase = false             -- case-insensitive match

Returns nil when there is no match, else:

{ line, col, end_line, end_col, text, captures }

with line/end_line 1-based, col/end_col 0-based byte offsets (end exclusive), text the matched substring, and captures the submatch strings (\1.., "" for a group that didn’t participate). Matching is line-by-line, so a multi-line (\n-spanning) pattern is not supported.

Defined in api.lua.

btv.buf.set_extmark(buffer, ns, line, col, opts)

btv.buf.set_extmark(buffer, ns, line, col[, opts]) -> id [alias nvim_buf_set_extmark]: place (or update, via opts.id) an extmark in buffer under namespace ns (see btv.ns.create) at 0-based line / col (col a byte offset). opts carries the highlight-relevant attrs — end_row / end_col for a ranged mark, hl_group, priority, … — and an unsupported decoration key fails loud rather than being ignored. Returns the mark id. The mutation is queued for the server, but the mirror is written through, so a read later in this chunk sees it.

right_gravity (default true) and end_right_gravity (default false) set the anchor gravity — the direction each edge is dragged when text is inserted at it — and are HONORED against the live rope, not just stored. The default is a range that does NOT grow when you type at its edges (a highlight span); right_gravity = false with end_right_gravity = true makes an (even empty) range GROW to swallow text typed at either edge — the anchor shape a live snippet tabstop needs.

gutter_text (btv-native) fills the gutter text column, between the line numbers and the text, on the mark’s line: a non-empty list of { text, group? } chunks, such as a diff’s old and new line numbers. The column is as wide as the widest gutter_text in the buffer, in screen cells, but never wider than the window has left beside its other gutters, and a shorter one is padded on the right. A chunk with no group paints as LineNr (CursorLineNr on the cursor line). The highest priority mark on a line wins, then the higher mark id; they don’t concatenate. A malformed list raises, naming the chunk, and so does a newline, a tab or any other control character in the text.

btv.buf.set_extmark(0, ns, 4, 0, { gutter_text = { { "  12 ", "DiffDelete" }, { "     " } } })

Defined in api.lua.

btv.buf.del_extmark(buffer, ns, id)

btv.buf.del_extmark(buffer, ns, id) -> bool [alias nvim_buf_del_extmark]: remove mark id of namespace ns from buffer. Returns whether the mark existed.

Defined in api.lua.

btv.buf.clear_namespace(buffer, ns, line_start, line_end)

btv.buf.clear_namespace(buffer, ns, line_start, line_end) [alias nvim_buf_clear_namespace]: drop namespace ns’s extmarks in buffer whose line is in the 0-based range [line_start, line_end) — line_end == -1 means to the end of the buffer. ns == -1 clears every namespace a plugin made; the editor’s own highlights stay.

Defined in api.lua.

btv.buf.extmarks(buffer, ns, start, end_, opts)

btv.buf.extmarks(buffer, ns, start, end_[, opts]) -> list [alias nvim_buf_get_extmarks]: the extmarks of namespace ns in buffer within the position range start..end_ — each bound is 0 (buffer start), -1 (buffer end), or a {row, col} pair. Entries come in (row, col, id) order, each {id, row, col} (or {id, row, col, details} with opts.details). ns == -1 returns marks from every namespace. Reads the mirror, so it reflects marks set earlier in this chunk; positions are current as of chunk start.

Defined in api.lua.

btv.buf.set_option(buf, name, value)

btv.buf.set_option(buf, name, value) [alias nvim_buf_set_option]: set buffer-local option name to value on buf (0/nil = current). A pre-0.10 accessor kept because plugins call it pervasively (bufhidden / modifiable / filetype / buftype on scratch buffers); in new code prefer btv.option.set(name, value, { buf = buf }), which this wraps.

Defined in api.lua.

btv.buf.get_option(buf, name)

btv.buf.get_option(buf, name) -> value [alias nvim_buf_get_option]: read buffer- local option name from buf (0/nil = current) — the read counterpart of btv.buf.set_option. In new code prefer btv.option.get(name, { buf = buf }).

Defined in api.lua.

btv.buf.list(opts)

btv.buf.list([opts]) -> list of bufnr [alias nvim_list_bufs, which always lists all]: the buffer handles the mirror knows, ascending. By default every buffer across every layer (main area + all docks). Pass { focused = true } to list only the focused layer’s buffers — the per-region list (:ls is scoped the same way), so a dock reports just its own buffers and the main area just its own.

Defined in api.lua.

btv.buf.alternate()

btv.buf.alternate() -> bufnr | nil: the alternate buffer (vim’s #, the <C-^> target), or nil when there is none. This is the handle, not the name: vim.fn.expand("#") answers what :e # would reopen — a name that outlives a :bdelete of the buffer it came from — while this answers which open buffer a list flags # (what :ls and the buffers picker mark). A buffer that has been closed is nobody’s alternate here, so the two disagree exactly when they should.

Defined in api.lua.

btv.buf.getline(buf, lnum, lend)

btv.buf.getline(buf, lnum[, end]) [alias vim.fn.getbufline]: lines lnum..end (1-based inclusive) of a buffer, or just lnum when end is omitted. Wraps btv.buf.lines (0-based, end-exclusive). An out-of-range request yields {} (vim). buf may be a number or a btv.buf.nr expression ("%", a name); lnum / end may be "$" for the last line.

Defined in api.lua.

btv.buf.nr(expr)

btv.buf.nr(expr) [alias vim.fn.bufnr]: the buffer number for expr. "" / "%" / nil / 0 -> current buffer; "#" -> the alternate buffer (-1 when there is none); "$" -> the last (largest) buffer number; a string -> the loaded buffer whose name matches (exact, else suffix), -1 when none. Backed by the Phase-6 btv._bufs mirror.

Defined in api.lua.

btv.buffers

btv.buffers.actions.open()

btv.buffers.actions.open(): the default <CR> action inside the :ls / :buffers list panel (filetype btvbuffers). Reads the buffer number off the start of the cursor’s row, closes the panel, and switches to that buffer — scheduled so the switch lands in the main window after the panel close restores focus. Bound buffer-locally as a default map (a user <CR> map overrides it); rebindable like the other panel actions (btv.qf.actions.jump, btv.panels.actions.open). Note this is the buffer-list panel’s key action, distinct from the btv.buf.* buffer API.

Defined in keymap.lua.

btv.bufinfo

btv.bufinfo.get(arg)

btv.bufinfo.get([arg]) [alias vim.fn.getbufinfo]: per-buffer info dicts. arg is a bufnr (one buffer), an opts table ({buflisted=1, bufloaded=1, …} — filters), or absent (all buffers). bemtvi’s core doesn’t model buflisted, so every buffer reports listed/loaded and the filters only narrow; changed / changedtick / lnum are real (the buffer’s modified flag, its change counter, and the cursor line :ls reports for it).

Defined in api.lua.

btv.clipboard

btv.clipboard.read()

btv.clipboard.read() -> a promise of the system clipboard’s text ("" when it is empty). It rejects with a message when the editor has no clipboard, or when the browser won’t let the page read it.

btv.clipboard.read():next(function(text)
  btv.notify(("%d characters on the clipboard"):format(#text))
end)

Defined in state.lua.

btv.clipboard.write(text, opts)

btv.clipboard.write(text, opts) -> a promise that settles once text is on the system clipboard. opts.linewise marks it as whole lines, so a "+p puts it on lines of its own; it defaults to whether text ends in a newline. Rejects with a message when the editor has no clipboard. The unnamed register is left alone.

btv.clipboard.write(btv.buf.name(0))

Defined in state.lua.

btv.cmdline

btv.cmdline.open(text, opts)

btv.cmdline.open(text, opts) opens the command line from any mode, prefilled with text ("" when nil) and the cursor at its end. opts.kind picks the line: ":" (the default) for an ex command, "/" or "?" for a search. Opened from Insert, the line returns to Insert when it closes, which is how the modeless key layout reaches ex commands without a Normal mode:

btv.cmdline.open("%s/")                -- a substitute, ready for its pattern
btv.cmdline.open("", { kind = "/" })   -- a forward search

Defined in keymap.lua.

btv.cmdline_complete

btv.cmdline_complete.setup(opts)

btv.cmdline_complete.setup{ docs = true }: enable the engine. docs toggles the params/help preview pane (Phase 3; default true). A bare call enables it with defaults. Failing loud on a non-table argument (no silent stub).

Defined in cmdline_complete.lua.

btv.cmdline_complete.catalog()

btv.cmdline_complete.catalog() -> every command the : line knows, as a list of { name, synopsis, description, user }: the curated built-in commands first, then the registered user commands (user = true, with their usage as the synopsis and their desc as the description). A required argument is spelled {arg} in the synopsis, an optional one [arg]. The commands picker lists this.

Defined in cmdline_complete.lua.

btv.cmdtype

btv.cmdtype.get()

No documentation comment in the prelude.

Defined in api.lua.

btv.complete

btv.complete.setup(opts)

btv.complete.setup { sources = { { "buffer", min_chars = 3 } }, auto = true, keys = { next = "<C-n>", prev = "<C-p>", confirm = { "<C-y>", "<CR>" }, abort = "<C-e>" } } (confirm accepts the highlighted row; <CR> only accepts once you’ve navigated to one — an unnavigated popup is noselect, so Enter still inserts a newline.) Enables the engine and captures its options. sources is a list of { name, opts... } entries — a built-in (buffer / lsp / snippets) or a registered plugin source. Listing a plugin source is optional: every source registered with btv.complete.source{} is active by default, so a plugin adds completions just by registering (even after this call). List it only to override its options (min_chars / priority / debounce) or to name it under exclusive mode. exclusive = true restricts the active set to only the sources named here (opt out of auto-join). min_chars gates how many prefix chars a source needs before it contributes, and is honored per source: { "buffer", min_chars = 3 }, { "btvsnip", min_chars = 2 } shows snippets from 2 chars while buffer words wait for 3. A top-level min_chars sets the default for sources that don’t override it; the popup opens at the minimum across all sources (each then filters by its own). A trigger-char source and a manual trigger (btv.complete.trigger()) bypass the gate. auto (default true) completes as you type. With auto = false nothing opens on its own, but a manual trigger still follows the prefix: the popup it opens narrows as you type and widens as you backspace, until you accept, abort, or type a prefix nothing matches — so on-demand completion is a session, not a one-shot snapshot. keys overrides any of the four control actions. accept (default "replace") decides what the confirm keys do when the caret is in the middle of a word: "replace" swaps the whole word, "insert" keeps the suffix past the cursor. btv.complete.accept{ behavior } overrides it for a specific key (bind one key to each behavior). confirm (default "selected") decides what a confirm key does when nothing is selected — the popup is noselect until you navigate. "selected" keeps confirm inert (a mapped <CR> still inserts a newline until you pick a row); "first" accepts the top row un-navigated (Enter-to-accept). An explicit selection confirms either way.

Defined in complete.lua.

btv.complete.trigger()

btv.complete.trigger(): manually open (or refresh) the completion popup at the cursor, ignoring auto / min_chars (an explicit request always offers what’s there). A no-op outside insert mode or before btv.complete.setup{}.

The popup it opens is a session, not a snapshot: it keeps following the prefix through the edits that follow — narrowing as you type, widening as you backspace — even with auto = false, and ends when it closes (accept, abort, <Esc>, or a prefix nothing matches). The whole session keeps the manual contract: min_chars stays bypassed, and the top row stays preselected so a confirm key (<C-y> / <CR>) accepts without a separate navigation step.

Defined in complete.lua.

btv.complete.accept(behavior)

btv.complete.accept(behavior): accept the highlighted completion row under an explicit accept behavior, ignoring the engine’s configured default. behavior is "insert" (replace only the typed prefix, keeping any word suffix past the cursor) or "replace" (swap the whole word the caret sits inside); nil / omitted uses the configured default. Passed a table, it reads behavior from it (so btv.complete.accept{ behavior = "replace" } works too). Bind two keys to the two behaviors for insert-vs-replace on demand, e.g. btv.keymap.set("i", "<C-y>", function() btv.complete.accept("insert") end) btv.keymap.set("i", "<C-l>", function() btv.complete.accept("replace") end) A no-op when the popup is closed or nothing is highlighted (like the confirm key).

Defined in complete.lua.

btv.complete.choice(items, opts)

btv.complete.choice(items, opts) — open a non-grabbing dropdown at the cursor listing items (strings), the completion-popup widget rather than the grabbing btv.ui.select: <C-n>/<C-p> move, <C-y>/<CR> pick, and typing / <Esc> dismiss it (input keeps flowing to the buffer). Accepting a row replaces opts.range — { start_row, start_col, end_row, end_col }, 0-based byte cols — with the pick; the row already sitting in that range is preselected. There is no callback: the splice is a normal buffer edit, so a caller watching the buffer (btv.buf.attach on_bytes) reacts to it — how a plugin snippet engine drives a ${1|a,b,c|} choice tabstop and syncs its mirrors. Defaults the range to the empty span at the cursor (inserting the pick) when opts.range is omitted.

Defined in complete.lua.

btv.complete.source(spec)

btv.complete.source { name, complete = function(ctx)[, debounce] }: register an async completion source. complete streams candidates for the prefix in ctx ({ prefix, buf, row, col }): it calls ctx.push(item) per result — a string (used as both the menu label and the inserted text) or a table { text = <label>, insert = <applied on accept>[, kind = <label>] } — where an optional kind is the short category shown right-aligned on the row ("Function", "Module", …), like an LSP item’s kind — and signals completion by returning (an btv.async source returns its promise; a synchronous one just returns — btv is promise-only, so there is no done callback). The source runs off the input path (debounced by debounce ms, default btv.complete.debounce), and its results are generation-gated: a reply for a prefix the user has typed past is dropped. Register a ctx.on_cancel(fn) reaper to kill an in-flight job when the next prefix supersedes this one.

Registering activates the source: it joins the live engine immediately (or when btv.complete.setup{} next runs, if it hasn’t yet) — a plugin adds completions by calling this, with no need for the user to list it in setup{ sources }. The spec’s own priority (merge rank, default 0), min_chars (its prefix gate), and debounce become the source’s defaults; a setup{ sources } entry for the same name overrides any of them, and enabled = false (here or on that entry) opts it out. A setup{ exclusive = true } engine ignores unlisted sources.

trigger = { chars = { ":" } } (optional) gates the source: the engine wakes it only when the completion prefix leads with one of those chars (the emoji shape), folding the char into the prefix so the source matches :smi and accept replaces from the :. resolve = function(item) (optional) supplies docs lazily: push an item with no doc, and when the user selects it the engine calls resolve(item), which returns a PROMISE of the docs — a doc string, or an item whose .doc is used. Use it when computing docs up front for every candidate is wasteful.

A pushed item may carry on_accept = function(item, ctx) — a per-item callback that OWNS the edit when that row is accepted, run INSTEAD of core splicing the item’s insert. ctx is { buf, start_row, start_col, end_row, end_col } — the trigger word RANGE under the cursor (0-based, byte columns, end-exclusive), ready to hand to btv.buf.set_text. This is the seam a snippet engine uses: match a trigger, then on accept btv.buf.set_text(ctx.buf, ctx.start_row, ctx.start_col, ctx.end_row, ctx.end_col, {""}) and btv.snippet.expand(body) (or drive its own tabstop session). Not a snippet-only hook — additionalTextEdits, post-accept commands, and any non-literal expander use it too.

Defined in complete.lua.

btv.complete.scorer(src)

btv.complete.scorer(src): install a re-ranker over the completion popup’s rows, or clear it with nil. The sibling of btv.picker.scorer.

src is a string of Lua source — an expression, not a function value — because the re-ranker runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there.

Four names are in scope, and the expression returns a number — the new sort key, higher first:

label   the candidate's text
query   the word prefix being completed
score   the native key this row already earned (fuzzy score + source bias)
kind    the row's kind label: "Snippet", an LSP kind name, or "" (a buffer word)

score is the blended key the popup already sorted on — the fuzzy score plus the source’s own bias (lsp 8 > snippets 5 > buffer 0) — so nudging it composes with source order instead of fighting it:

-- keep snippets available but below real code completions
btv.complete.scorer([[ score - (kind == "Snippet" and 20 or 0) ]])

-- prefer shorter candidates among equally-good matches
btv.complete.scorer([[ score - #label / 10 ]])

btv.complete.scorer(nil)   -- back to the native order

It re-ranks the filtered rows only, at most the top 1000 of them, and at most once per repaint rather than once per streamed batch — an LSP server can answer with thousands of candidates. The caret follows the row it was standing on, so a re-rank never accepts a candidate you did not choose.

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. A scorer that errors, exceeds its deadline, or returns a non-number reports once and is then uninstalled, rather than repeating the error on every keystroke.

Defined in complete.lua.

btv.connect

btv.connect.register(scheme_or_matcher, resolver)

btv.connect.register(scheme_or_matcher, resolver) — register a connector.

  • scheme_or_matcher — either a scheme string ("container", "ssh"), matched against the URL’s scheme:// prefix, or a matcher function fn(url) -> boolean for host-pattern routing (e.g. only *.internal hosts).
  • resolver — fn(url) -> spec. It resolves url to a session-swap spec (the table btv.session.reconnect takes: { transport = …, config_source = …, keep_buffers = … }). spec.config_source (§D) picks whose config the swapped session runs — "remote" (default; the daemon’s config + plugins) or "local" (this machine’s config, the daemon backing only the seams — the dev-container shape). It MAY provision asynchronously — return a promise that fulfils with the spec — and MAY stream progress with btv.notify (“detecting arch…”, “starting daemon…”).

Registering the same scheme twice keeps both; the later wins (reverse scan in _resolve). Fails LOUD on a bad argument (a mistyped registration is a bug, not a silent no-op).

Defined in connect.lua.

btv.connect.connect(url)

btv.connect.connect(url) — the :connect entry point (also callable directly). Route url through the provider registry: a matching resolver runs (async-aware) and its spec swaps the window (§B); with no provider, hand url to the client’s built-in direct dial. A resolver error / a non-spec return surfaces LOUD (a message), leaving the session intact — the swap only happens once a valid spec resolves.

Defined in connect.lua.

btv.contextmenu

btv.contextmenu.groups

btv.contextmenu.groups is the group registry: a table from a group’s name to the group. Change a group in place, or add your own:

btv.contextmenu.groups.clipboard = {
  -- The rows: a list, or a function `(ctx) -> list` that may return a promise.
  items = {
    {
      label = "Copy",
      action = function(ctx) end,  -- runs when the row is chosen; may return a promise
      detail = "Ctrl+C",           -- the right-hand hint: a string or `(ctx) -> string|nil`
      when = function(ctx) end,    -- hide the row unless this returns true
      enabled = function(ctx) end, -- grey it out unless this returns true
    },
  },
  when = function(ctx) end,        -- hide the whole group unless true (or `false`)
}

when and enabled may also be plain booleans. A field a group or a row doesn’t know about is an error naming the group, so a typo doesn’t silently drop a row.

Defined in contextmenu.lua.

btv.contextmenu.default

btv.contextmenu.default is the group list an editable buffer starts from, the first time its own list is asked for. Editing it changes what later buffers start with; a buffer that already has its list keeps it.

Defined in contextmenu.lua.

btv.contextmenu.default_readonly

btv.contextmenu.default_readonly is the group list a read-only buffer (one that isn’t 'modifiable': help, the quickfix list, a tree) starts from.

Defined in contextmenu.lua.

btv.contextmenu.list(buf)

btv.contextmenu.list(buf) -> buffer buf’s group list (0 or nil: the current buffer). The list is live: edit it in place to change that buffer’s menu, and no other’s. The first time it’s asked for, it’s made from btv.contextmenu.default (or default_readonly for a buffer that isn’t 'modifiable' at that moment).

table.insert(btv.contextmenu.list(buf), 1, "tree")

Defined in contextmenu.lua.

btv.contextmenu.set(buf, names)

btv.contextmenu.set(buf, names) replaces buffer buf’s group list with a copy of names (0 or nil: the current buffer). {} gives the buffer no menu.

Defined in contextmenu.lua.

btv.contextmenu.entries(buf)

btv.contextmenu.entries(buf) -> a promise of the rows buffer buf’s menu would show right now (0 or nil: the current buffer), as btv.ui.menu entries: each { label, detail, enabled, action }, with { separator = true } between groups. The testing and introspection seam: what open would show, without showing it.

Defined in contextmenu.lua.

btv.contextmenu.open()

btv.contextmenu.open() opens the current buffer’s menu at the pointer, which is what a right-click does. Returns a promise that settles once the chosen row’s action has run (an action that returns a promise is waited for), or once the menu closed with nothing chosen. A menu with no rows doesn’t open. Bound to a key with no click behind it, the menu opens at the text cursor.

Defined in contextmenu.lua.

btv.cursor

btv.cursor.get(win)

No documentation comment in the prelude.

Defined in api.lua.

btv.cursor.set(pos, win)

btv.cursor.set(pos[, win]): move window win’s cursor (0/nil = the current window) to pos, a { row, col } pair in the SAME convention btv.cursor.get returns — a 1-based row and a 0-based byte col. The target is clamped into the buffer. This is the setter half of the cursor surface: the reveal / jump-to primitive a picker or a “go to definition”-style plugin uses; ordinary navigation stays plain normal-mode motion. Like the rest of the window mutation API it queues a window op the server applies after this chunk (the same “Lua queues, core mutates” flow), via the btv._win_set_cursor bridge.

Defined in api.lua.

btv.daemon

btv.daemon.status()

btv.daemon.status() -> "connected"|"reconnecting"|"disconnected"|nil The live daemon connection phase, or nil when this session has no daemon link (local). A statusline component renders connected green / reconnecting yellow / disconnected red, and hides itself on nil.

Defined in btv.lua.

btv.decor

btv.decor.provider(spec)

btv.decor.provider { name, bufs?, on_range }: register a viewport decoration provider. on_range(ctx, publish) is called off the frame, once per visible-range change of a matching window, with a snapshot ctx = { win, buf, top, bot, lines, filetype, buftype, gen } (top/bot are 0-based inclusive buffer rows; lines is exactly that slice; gen is the viewport generation a publish carries back). bufs scopes the provider: bufs.filetype = { "lua", "rust" } runs it only in those filetypes, bufs.buftype = { "quickfix" } only in those buffer kinds, bufs.buf = id only in a specific buffer (constraints AND together); omitted ⇒ every buffer (the engine skips non-matching windows). The provider calls publish(marks) with a list of marks shaped like an extmark: row/col (positional { row, col, ... } or named) plus any option btv.buf.set_extmark takes — they are validated and lowered by the same code, so a provider draws the full decoration vocabulary, not just highlights:

publish {
  { row, 0, end_col = 4, hl = "Comment" },                   -- a highlight span
  { row, 0, sign_text = ">>", sign_hl_group = "DiffAdd" },   -- a gutter sign
  { row, 0, virt_text = { { "  3 days ago", "Comment" } } }, -- inline blame
  { row, 0, line_hl_group = "DiffChange" },                  -- a line background
}

hl is the decor-native shorthand for hl_group. A mark that would draw nothing — carrying no hl_group, virt_text, virt_lines, sign_text, line_hl_group, line_fill or gutter_text — fails loud rather than silently painting nothing, and so does a mark with an unknown key.

Defined in decor.lua.

btv.decor.invalidate(opts)

btv.decor.invalidate([opts]): tell the engine a provider has new content to draw, and re-dispatch it. A provider is normally woken by the viewport signal — scroll, resize, or an edit to the visible slice — so a change in the data it draws from (git blame that came back off a promise, an LSP response, a palette or setting the user just changed) would otherwise not repaint until the user happened to move: a rainbow-bracket provider whose colours were swapped keeps painting the old ones until you scroll past them. This is that missing edge — it marks the windows in scope, and the next engine pass re-runs on_range there with a fresh snapshot and a fresh generation token.

The scope (omitted ⇒ every visible window):

btv.decor.invalidate()                  -- every visible window
btv.decor.invalidate({ buf = bufnr })   -- every window showing that buffer (0 = current)
btv.decor.invalidate({ win = winid })   -- exactly that window (0 = current)

Pass buf for the usual case: a provider’s data is per-buffer, and the same buffer can be open in several splits, each of which needs its own viewport re-run. Passing both buf and win is an error (they are alternative scopes, not a conjunction).

Scoped to the window, not to the provider: every provider matching the window runs again, exactly as it would on a scroll. A publish still in flight from the run this supersedes is dropped by the generation check, so nothing is lost.

It is a HINT, not a repaint: like everything else a plugin hands the decoration engine, the ask is optimistic and the engine decides when it is served. Repeated asks for the same window coalesce, and each window is served at most ONCE per pass — so a provider that asks to be re-run in response to its own run cannot spin the editor, it just paces to the next pass. Nothing is dropped; an ask stays outstanding until it is served. (Asking from inside your own on_range is still pointless work rather than an error: you already hold the ctx, so publish what you want drawn from the run you are in.)

The re-dispatch happens on the current pass, off the frame like every other dispatch — no redraw is forced and no Lua runs at frame time.

Defined in decor.lua.

btv.decor.expr(src)

btv.decor.expr(src): install a frame-time paint block over every visible line, or clear it with nil. The pure sibling of btv.decor.provider.

src is a string of Lua source — a function body, not a value — because the block runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there. It is a body rather than a single expression because a per-line paint loops over the matches on the line, so it ends in its own return.

Two names are in scope, and the block returns a list of spans to highlight:

line    the text of the line being painted
lnum    its 1-based line number

Each span is { first, last, group } — 1-based inclusive columns, which is what string.find hands back, so a match drops straight in:

btv.decor.expr([[
  local out, i = {}, 1
  while true do
    local s, e = line:find("TODO", i, true)
    if not s then break end
    out[#out + 1] = { s, e, "Todo" }
    i = e + 1
  end
  return out
]])

btv.decor.expr(nil)   -- stop painting

Returning an empty list (or nil) declines the line. A malformed span fails loud rather than silently not painting.

A span can draw more than a colour. Beside the three positional slots it takes named keys, each the scalar form of the extmark key it lowers into:

virt_text          text drawn beside the line
virt_hl            its highlight group
virt_pos           where it draws: "eol" (the default), "inline", "overlay", "right_align"
hl_mode            how it merges with the cell under it: "replace" (default), "combine", "blend"
virt_lines         a whole extra screen row, or a list of them
virt_lines_hl      their highlight group
virt_lines_above   draw them above the line instead of below
sign_text          a 1-2 cell glyph in the gutter
sign_hl            its highlight group
line_hl            a highlight group backing the whole line
line_fill          a glyph repeated from the end of the line to the right edge
line_fill_hl       its highlight group
priority           where the span sits in the paint stack (default 4096)
-- a badge after the match, and a gutter mark on its line
btv.decor.expr([[
  local s, e = line:find("FIXME", 1, true)
  if not s then return {} end
  return { { s, e, "Todo", virt_text = "  <- fix me", virt_hl = "Comment",
            sign_text = ">>", sign_hl = "DiagnosticWarn" } }
]])

-- a heading drawn above every `## ` line, and a rule out to the right edge
btv.decor.expr([[
  if not line:find("^## ") then return {} end
  return { { virt_lines = line:gsub("^## ", ""), virt_lines_hl = "Title",
            virt_lines_above = true, line_fill = "-", line_fill_hl = "NonText" } }
]])

The columns and the group are each optional when the span carries a decoration: a sign or a line background anchors on the line, not on a stretch of it, so { sign_text = ">>" } is a whole span. What is refused is a span that draws nothing — no group and no decoration — and a qualifier with nothing to qualify (virt_hl without virt_text, sign_hl without sign_text), because both are half-written spans that would otherwise vanish in silence.

priority belongs to the span rather than to its decoration, so a plain highlight takes one too: below the treesitter layer (priority = 90) a paint colours only what the syntax left plain, above the default it covers a diagnostic’s underline.

Which one to use. btv.decor.provider is the general surface: full Lua, async, any editor state, and the extmark vocabulary at full width (per-chunk styling, which a span — one run per key — cannot express). It runs off the frame, so its marks land on the next one. btv.decor.expr can only see the line it was handed — and in exchange it runs during the frame, so a paint that is a pure function of the text (indent guides, colour swatches, trailing whitespace, a keyword badge) appears in the same frame as the edit or scroll, with no round trip and no flicker.

It is evaluated over the visible rows of each window, memoized on the viewport, so a steady screen makes no calls at all. A block that errors, exceeds its deadline, or returns a malformed span reports once and is then uninstalled.

Defined in decor.lua.

btv.diagnostic

btv.diagnostic.get(bufnr, opts)

btv.diagnostic.get([bufnr, [opts]]): diagnostics as plain tables. nil bufnr → every buffer; 0 → the current one. opts.severity filters: one severity (a number or a name like "WARN"), a range { min =, max = } (lower numbers are more severe, so { min = WARN } keeps warnings and errors), or a list of severities. The entries are copied out (callers must not mutate the mirror), each tagged with its bufnr, matching neovim’s shape.

Defined in diagnostic.lua.

btv.diagnostic.goto_next(opts)

btv.diagnostic.goto_next([opts]): move to the next diagnostic in the current buffer, wrapping. opts.severity takes the same shapes as btv.diagnostic.get.

Defined in diagnostic.lua.

btv.diagnostic.goto_prev(opts)

btv.diagnostic.goto_prev([opts]): move to the previous diagnostic, wrapping. opts.severity takes the same shapes as btv.diagnostic.get.

Defined in diagnostic.lua.

btv.diagnostic.toqflist(diagnostics)

btv.diagnostic.toqflist(diagnostics): convert a list of diagnostic tables (the shape btv.diagnostic.get returns) into quickfix/location-list items. Mirrors neovim’s vim.diagnostic.toqflist: 0-based diagnostic positions become 1-based list columns, the message becomes the entry text, and severity maps to the type char. The buffer name is resolved into filename so the entry is jumpable.

Defined in diagnostic.lua.

btv.diagnostic.setqflist(opts)

btv.diagnostic.setqflist([opts]): replace the quickfix list with every buffer’s diagnostics and (unless opts.open == false) open the quickfix window. opts: severity filter, title, open.

Defined in diagnostic.lua.

btv.diagnostic.setloclist(opts)

btv.diagnostic.setloclist([opts]): populate the current window’s location list with the current buffer’s diagnostics (a real, navigable loclist now that one exists) and open it. opts: bufnr (default current), severity, title, open.

Defined in diagnostic.lua.

btv.diagnostic.open_float(opts)

btv.diagnostic.open_float([opts]): open a float listing the cursor line’s diagnostics in full (the multi-line messages with source/code that the inline virtual text truncates). The server reads the cursor at apply time and routes through the float surface (the bottom panel hover uses); a clean line opens nothing.

What bemtvi shows is exactly neovim’s DEFAULT scope = "line", so that value is accepted. Everything else neovim models — scope of "cursor"/"buffer", the severity filter, bufnr/pos, and the presentation options (border, header, source, format) — is rejected loudly rather than dropped: a silently-ignored scope = "buffer" would show one line’s diagnostics while the caller believed it asked for the whole buffer’s.

Defined in diagnostic.lua.

btv.diagnostic.config(opts, _namespace)

btv.diagnostic.config([opts]): merge opts into the stored config and return the merged table when called bare. bemtvi renders three surfaces — the underline spans (underline), the inline end-of-line message (virtual_text), and the gutter sign column (signs) — so those keys drive rendering; update_in_insert gates when an update reaches all three. Any other key is stored without behavior until a surface exists, and warns once naming it.

update_in_insert decides what happens to a diagnostic update that arrives while you are typing. A language server re-diagnoses after every didChange — i.e. after every keystroke — so applied as they land, the squiggles, signs and inline messages churn under the cursor over errors that exist only because the line isn’t finished. bemtvi takes a number of milliseconds here as well as neovim’s two booleans:

-- the default: apply the newest update once typing has been quiet for 3s
btv.diagnostic.config({ update_in_insert = 3000 })

btv.diagnostic.config({ update_in_insert = true })   -- apply every update at once
btv.diagnostic.config({ update_in_insert = false })  -- hold everything until InsertLeave

Nothing is ever dropped: while an update is held the newest one is kept, and leaving insert mode applies it immediately whatever the interval is. Nor does anything drift out of place while you wait — a displayed diagnostic is anchored to the text it flags, so its squiggle, sign, inline message and ]d target follow that text as you edit around it.

INCOMPLETE: virtual_lines, severity_sort, … are recorded and echoed back (with a one-time warning) but have no rendering surface yet (float is honored by btv.diagnostic.open_float, but the config.float defaults that pre-style it are not). _namespace is ignored (one global config). Faithful once those diagnostic surfaces exist.

Defined in diagnostic.lua.

btv.diagnostic.set(namespace, bufnr, diagnostics, _opts)

btv.diagnostic.set(namespace, bufnr, diagnostics[, opts]): replace the diagnostics for (namespace, bufnr). bufnr 0 means the current buffer. The entries are stored as given (each typically { lnum, col, message, severity }); opts (display overrides) is accepted but not yet honored — see the INCOMPLETE note on btv._diagnostics_ns. A plugin drives its own buffer’s diagnostics through this.

Defined in diagnostic.lua.

btv.diagnostic.reset(namespace, bufnr)

btv.diagnostic.reset([namespace, [bufnr]]): clear client-set diagnostics. With no args, every namespace in every buffer; with a namespace, that namespace in every buffer (or just bufnr when given). A plugin calls this when its float closes — it used to crash because the function was missing.

Defined in diagnostic.lua.

btv.dock

btv.dock.opt(side)

btv.dock.opt(side) — an options proxy for one dock, mirroring btv.wo/btv.bo: reads return the cached value (or the default), writes queue the change.

Defined in btv.lua.

btv.dock.open(o)

No documentation comment in the prelude.

Defined in btv.lua.

btv.env

btv.env.get(name)

No documentation comment in the prelude.

Defined in state.lua.

btv.env.set(name, value)

btv.env.set(name, value) [alias vim.fn.setenv]: set an environment variable for this session. bemtvi can’t mutate the real process environment from Lua, so the value lands in a shadow store getenv/vim.env read back — observable within the editor, which is what a plugin setting e.g. $GIT_DIR before spawning an in-process child expects. A nil/v:null value unsets it.

Defined in state.lua.

btv.explorer

btv.explorer.actions.open()

btv.explorer.actions — the public, rebindable activation actions (current-buffer wrappers over the stateless _open/_up). A user/plugin extends or rebinds the explorer with an ordinary buffer-local map in a FileType btvdir autocmd, e.g. btv.autocmd.create("FileType", { pattern = "btvdir", callback = function(a) btv.keymap.set("n", "<C-j>", btv.explorer.actions.open, { buffer = a.buf }) end }) The default <CR>/-/<2-LeftMouse> maps (prelude/keymap.lua) are these too.

Defined in explorer.lua.

btv.explorer.actions.up()

No documentation comment in the prelude.

Defined in explorer.lua.

btv.explorer.enable()

btv.explorer.enable(): turn the explorer on. Registers the BufReadCmd handler that claims directory opens — pattern = "*" deciding per path via args.isdir, so it claims directories and lets every file read fall through to the editor’s default load (netrw’s exact model). Idempotent. Calling it makes a BufReadCmd handler exist, which is what tells the core to defer a :e dir so this handler gets the chance to claim it (see Editor::should_defer_open). Called at the bottom of this file, so the explorer is on by default; a config could turn it off by clearing the autocmd.

Defined in explorer.lua.

btv.filetype

btv.filetype.match(path)

btv.filetype.match(path) -> the filetype path’s name says, or nil: the same tables a buffer’s name goes through (exact basename, globs, extension), for a name no buffer has, like a file a plugin shows in a view. Nothing is read, so a #! line and btv.filetype.detect have no say.

Defined in filetype.lua.

btv.filetype.detect(src)

btv.filetype.detect(src): decide a buffer’s filetype from its content, or clear the sniffer with nil.

bemtvi’s built-in tables resolve a filetype from the file’s name, a path pattern, or its extension, and deliberately stop there: extensions neovim resolves by looking inside the file (.h C-vs-C++, .r, .v, .m, .pl) are omitted rather than guessed, because guessing in the core would be exactly the kind of heuristic that silently rots. This is the seam that answers them — your rule, not the editor’s guess.

src is a string of Lua source — an expression, not a function value — because it runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*.

Three names are in scope:

name   the file's basename ("" for an unnamed buffer)
ext    its extension, without the dot ("" when it has none)
head   the first few lines of content (bounded, ~2KB)

Return a filetype string, or nil (or "") to decline — declining is a normal answer, and leaves the built-in name/pattern/extension tables to resolve it as before. A returned filetype wins over them, which is what makes the .h case work:

btv.filetype.detect([[
  ext == "h" and (head:find("template", 1, true) or head:find("::", 1, true))
    and "cpp" or nil
]])

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. That is deliberate — no call shape is a clean once-per-item traversal (:s re-runs on every keystroke of the live preview, a foldexpr sees only the rows an edit touched, the picker scorer only the top survivors, foldtext is memoized), so an accumulator would be quietly wrong. It runs once per buffer, and its verdict is stored as that buffer’s explicit filetype — the same thing :setf writes — so nothing on the render path ever calls back into the sandbox.

An expression that errors, exceeds its deadline, or returns a non-string reports once and is then uninstalled.

Defined in filetype.lua.

btv.fname

btv.fname.modify(fname, mods)

btv.fname.modify(fname, mods) [alias vim.fn.fnamemodify]: apply vim’s filename modifiers left to right. A pure path-string helper (no I/O beyond reading cwd), so it lives with the vim.fn read builtins — expand('%:t') / '%:h' and a 'statusline' %f route through it. Supported: :p (absolute against cwd), :~ (relative to $HOME with ~), :. (relative to cwd when under it), :h (head/dir), :t (tail), :r (root, strip one extension — a leading dot isn’t one), :e (extension; consecutive :e widen it to the last k dot-components, vim’s quirk). An unsupported modifier errors loud rather than silently passing the name through. Cases match real neovim’s vim.fn.fnamemodify.

Separators and roots follow btv.utils.path_style: on Windows / and \ both split components, C:\ and \\server\share\ are roots, and :p spells its result with the native \ (as vim reports a full name there).

Defined in vimfn.lua.

btv.fname.escape(fname)

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.fold

btv.fold.text(src)

btv.fold.text(src): set the text a closed fold shows on its collapsed row (vim’s 'foldtext'), or restore the built-in default with nil.

src is a string of Lua source — an expression, not a function value — because it runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there.

Three names are in scope, and the expression returns the row’s text (a number is accepted and rendered as one):

first   the fold's first line, exactly as it appears in the buffer
lines   how many lines the fold covers
lnum    the 1-based line the fold starts on
btv.fold.text([[ first:gsub("%s+$", "") .. "  (" .. lines .. " lines)" ]])
btv.fold.text(nil)   -- back to the built-in "+-- 12 lines: …"

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. That is deliberate — no call shape is a clean once-per-item traversal (:s re-runs on every keystroke of the live preview, a foldexpr sees only the rows an edit touched, the picker scorer only the top survivors, foldtext is memoized), so an accumulator would be quietly wrong. The result is memoized on the fold’s first line, so a steady screen makes no sandbox calls at all and only a fold whose content changed re-renders.

An expression that errors, exceeds its deadline, or returns a table reports once and is then uninstalled, rather than repeating the error every frame.

Defined in fold.lua.

btv.fs

btv.fs.stat(path)

btv.fs.stat(path) -> promise of { type, size, mtime, atime, mode } (follows links)

Defined in fs.lua.

btv.fs.lstat(path)

btv.fs.lstat(path) -> like stat but does NOT follow a symlink (type may be "link").

Defined in fs.lua.

btv.fs.exists(path)

btv.fs.exists(path) -> promise of a boolean. The one op that never rejects: a missing path (or any error) resolves false. Existence of the entry itself, so a dangling symlink is true. (The exists job resolves a bool rather than rejecting, so no reject-to-false mapping is needed in the wrapper.)

Defined in fs.lua.

btv.fs.readdir(path)

btv.fs.readdir(path) -> promise of { { name=, type="file"|"directory"|"link" }, … }, the entries directly under path (no "."/".."), each with its dirent kind in the SAME call (no per-entry stat). type is lstat-flavoured (a symlink reports "link").

Defined in fs.lua.

btv.fs.gitignore(text)

btv.fs.gitignore(text) -> a matcher for one ignore file’s contents (a .gitignore, .ignore, .rgignore or .git/info/exclude), plus the list of messages for lines it had to skip (an invalid glob — git and rg skip those too). The semantics are ripgrep’s own (the ignore crate), so negation, anchoring, directory-only patterns and ** decide exactly as rg does.

matcher:match(path, is_dir) -> "ignore", "whitelist" (a !negated line) or nil (no line matches). path is relative to the ignore file’s own directory and /-separated; is_dir matters for a build/-style pattern. The last matching line decides, as in git.

local m = btv.fs.gitignore("*.log\n!keep.log\nbuild/\n")
m:match("debug.log")       --> "ignore"
m:match("keep.log")        --> "whitelist"
m:match("build", true)     --> "ignore"
m:match("src/main.rs")     --> nil

Defined in fs.lua.

btv.fs.walk(dir, opts)

btv.fs.walk(dir[, opts]) -> promise of a LIST of file paths relative to dir, a recursive directory listing built from readdir. The transport-agnostic file enumeration the codebase reaches for when rg/fd aren’t available (the pure web client, where a spawn has no real shell) — it rides the same off-tick fs seam as every other btv.fs op, so it works against local disk, a daemon, and OPFS alike. The relative paths are spelled with the session’s separator (btv.utils.path_join), as rg / fd print them — so a file reads the same whichever enumerator found it. opts:

max        cap on files returned (default 50000) — a runaway guard on huge trees
hidden     include dotfiles / dotdirs (default false)
skip       set of directory basenames to prune (default { [".git"] = true })
gitignore  honour ignore files the way `rg` does (default false)

With gitignore = true the walk reads each directory’s .gitignore, .ignore and .rgignore as it descends (and those of the directories above dir), plus the repository’s info/exclude at a repository root, and prunes what they ignore — negation, anchoring and precedence as in rg, through btv.fs.gitignore. As in rg, a .gitignore counts only inside a git repository (a directory with a .git at or above it) and not above that repository’s root, while .ignore / .rgignore count anywhere. A nested repository (a submodule) starts afresh: the enclosing repository’s git rules stop at its root. The global git excludes file (core.excludesFile) is not read. Directories with no ignore file cost nothing extra.

An unreadable subdirectory is skipped (not fatal); an ignore file that exists but cannot be read, or is not a regular file, is skipped with a warning naming it (once a session), as rg warns and walks on. MUST be awaited inside btv.async.

Defined in fs.lua.

btv.fs.grep(dir, query, opts)

btv.fs.grep(dir, query[, opts]) -> promise of a LIST of matches, each { path = <rel>, row = <1-based lnum>, col = <1-based>, text = <line> }: a recursive, transport-agnostic plain-substring search. The fallback the grep picker reaches for when rg/grep aren’t available (the pure web client) — it rides the same off-tick fs seam as every other btv.fs op, so it works against local disk, a daemon, and OPFS. Walks dir (btv.fs.walk), reads each file (btv.fs.read_text), and matches query as a LITERAL substring per line. Binary / unreadable files (read_text rejects) are skipped. opts pass through to btv.fs.walk (max / hidden / skip / gitignore). MUST be awaited inside btv.async.

Defined in fs.lua.

btv.fs.read(path)

btv.fs.read(path) -> promise of the file’s RAW bytes (a Lua byte-string).

Defined in fs.lua.

btv.fs.read_text(path, opts)

btv.fs.read_text(path[, { encoding = "utf-8" }]) -> promise of decoded text. Decodes through the encoding seam and REJECTS (EILSEQ) on invalid input — never lossy replacement text. Use btv.fs.read for raw bytes.

Defined in fs.lua.

btv.fs.write(path, data)

btv.fs.write(path, data) -> promise (resolves nil). Truncates / creates.

Defined in fs.lua.

btv.fs.append(path, data)

btv.fs.append(path, data) -> promise (resolves nil). Creates if absent.

Defined in fs.lua.

btv.fs.mkdir(path, opts)

btv.fs.mkdir(path[, { recursive = false, mode = 0o755 }]) -> promise (resolves nil). mode is the Unix permission bits applied to every directory created (defaults to 0o755 in Rust when omitted; ignored off Unix) — pass it to keep a private data/state dir from being created world-readable.

Defined in fs.lua.

btv.fs.rename(from, to)

btv.fs.rename(from, to) -> promise (resolves nil).

Defined in fs.lua.

btv.fs.remove(path, opts)

btv.fs.remove(path[, { recursive = false }]) -> promise (resolves nil). A file is unlinked; a directory needs recursive unless already empty.

Defined in fs.lua.

btv.fs.copy(src, dst, opts)

btv.fs.copy(src, dst[, { recursive = false }]) -> promise (resolves nil). A file copies (overwriting); a directory needs recursive.

Defined in fs.lua.

btv.fs.realpath(path)

btv.fs.realpath(path) -> promise of the canonical absolute path (symlinks resolved).

Defined in fs.lua.

btv.fs.which(name)

btv.fs.which(name) -> promise of the absolute path of the executable name, or nil when nothing matches. A bare name is searched across $PATH; a name that already contains a / is taken as an explicit path and accepted only when it is an executable file. The async, transport-agnostic replacement for vim’s blocking executable() / exepath() — a build/tool lookup is I/O, so it rides the same off-tick btv.fs seam as every other op and works unchanged against local disk, a daemon, and a browser session.

It resolves nil rather than rejecting when the program is absent: “not installed” is a true answer, and only a transport failure is an error. That makes the two common shapes read naturally —

-- prefer a project-local binary, fall back to the one on $PATH
local local_bin = btv.utils.joinpath(root, "node_modules/.bin", "eslint")
local cmd = btv.await(btv.fs.which(local_bin)) or "eslint"

-- gate a feature on a tool being present
btv.fs.which("rg"):next(function(path)
  if path then use_ripgrep(path) end
end)

In a serverless browser session there are no executables at all, so every lookup resolves nil; with a daemon attached the daemon’s own $PATH answers — which is the right one, since that is where the language servers and tools actually run.

Defined in fs.lua.

watch:next()

:next() -> promise of the next change batch, nil at end (after :stop()), or a rejection carrying the watch error. SEQUENTIAL like the process Stream: one outstanding :next() at a time (what a for loop does); batches arriving between pulls buffer in _queue.

Defined in fs.lua.

watch:stop()

:stop() cancels the native watch and ends iteration (a parked :next resolves nil).

Defined in fs.lua.

btv.fs.watch(path, opts)

btv.fs.watch(path[, { recursive = false }]) -> a Watch you iterate with btv.await_each (the streaming sibling of the one-shot ops, same shape as btv.run_stream). Each step yields a COALESCED change batch:

{ kind = “create”|“modify”|“remove”|“rename”, paths = { <abspath>, … } }

A consumer that stops pulling while changes keep arriving buffers at most 1024 batches; the next change after that is replaced by one { kind = "overflow", paths = {} } batch (later ones are dropped until the consumer catches up), which says “changes were missed, rescan”.

bursts within a 10 ms window are merged in the server, one batch per kind (paths deduped within each). A burst that mixes kinds therefore yields one batch each rather than a single coarsened one: writing a new file is a "create" plus a "modify", and folding them together would lose the only signal that says the file is new. Want a longer settle? Wrap btv.utils.debounce on top.

btv.async(function() local w = btv.fs.watch(dir, { recursive = true }) for ev in btv.await_each(w) do redraw(ev.paths) end – until w:stop() end)()

The watch is armed where the session’s files are: locally in a local session, and on the daemon in a daemon session (native or browser) — so a remote project reports its own changes rather than this machine’s. A native daemon link re-arms its watches after a reconnect, so a live iterator survives an outage.

A watch that can’t arm (bad path, watch limit) or a session with no change source at all (serverless browser) REJECTS the first pull — fail loud, never a dead watch. :stop() cancels the watch and ends the iteration.

Defined in fs.lua.

btv.fs_local

btv.fs_local.exists(path)

btv.fs_local.exists(path) -> promise of boolean.

Defined in localseam.lua.

btv.fs_local.readdir(path)

btv.fs_local.readdir(path) -> promise of { { name, type }, … }.

Defined in localseam.lua.

btv.fs_local.read_text(path, opts)

btv.fs_local.read_text(path[, { encoding = "utf-8" }]) -> promise of the decoded text. encoding selects the decoder exactly as in btv.fs.read_text (which REJECTS on invalid input rather than substituting replacement characters).

Defined in localseam.lua.

btv.fs_local.write(path, data)

btv.fs_local.write(path, data) -> promise; writes data, replacing the file.

Defined in localseam.lua.

btv.fs_local.append(path, data)

btv.fs_local.append(path, data) -> promise; appends data to the file.

Defined in localseam.lua.

btv.fs_local.mkdir(path, opts)

btv.fs_local.mkdir(path[, { recursive, mode }]) -> promise; creates the directory. mode is the Unix permission bits, exactly as in btv.fs.mkdir — pass it to keep a private local store (a trust/credential dir) from being created world-readable.

Defined in localseam.lua.

btv.fs_local.rename(from, to)

btv.fs_local.rename(from, to) -> promise (resolves nil). Atomic on a POSIX filesystem, so a write-temp-then-rename gives a torn-read-free local update.

Defined in localseam.lua.

btv.fs_local.realpath(path)

btv.fs_local.realpath(path) -> promise of the canonical absolute path.

Defined in localseam.lua.

btv.fs_local.remove(path, opts)

btv.fs_local.remove(path[, { recursive }]) -> promise; removes the file/dir.

Defined in localseam.lua.

btv.git

btv.git.discover(path)

discover(path) -> promise of { root, git_dir, prefix }. path is any path inside the repo. Replaces rev-parse --show-toplevel / --absolute-git-dir / --show-prefix. Rejects (ENOREPO) when path is not inside a repository.

Defined in git.lua.

btv.git.head(path)

head(path) -> promise of { branch, detached, sha }. branch is nil on a detached HEAD (detached then true); sha is the full resolved commit oid (empty on an unborn HEAD). Replaces rev-parse --abbrev-ref HEAD.

Defined in git.lua.

btv.git.show(file, rev)

show(file, rev) -> promise of the RAW bytes of file (an absolute path) as it was at rev (e.g. "HEAD"). Replaces git show HEAD:<rel>, but you pass the plain file path — the repo-relative path is computed for you (symlink-safe). Rejects (ENOENT) when the file has no version at rev (new / untracked file).

Defined in git.lua.

btv.git.diff_file(path, file)

diff_file(path, file) -> promise of { added, changed, removed, hunks } for file’s working tree vs its HEAD blob (each hunk { old_start, old_count, new_start, new_count }). Replaces git diff -U0 -- <file> plus hunk parsing. path locates the repo (any path inside it); file is the file to diff.

Defined in git.lua.

btv.git.status(path, opts)

status(path, opts) -> promise of { dirty, entries } in git status --porcelain XY terms. Each entry is { path, index, worktree, orig_path }:

path       repo-relative path of the changed file
index      the staged (index-vs-HEAD) column, one letter
worktree   the unstaged (worktree-vs-index) column, one letter
orig_path  where the content came from, for a rename/copy; "" otherwise

A column is " " when the file is unmodified in it. There is exactly ONE entry per path: a file that is staged and then edited again arrives once with both columns set (porcelain’s MM), never as two entries carrying one column each. An untracked file is porcelain’s ?? — both columns.

Unlike git status, an unstaged rename IS detected: it reads R on the destination with orig_path set, where git prints a deletion plus an untracked file.

opts.ignored (default false) additionally reports git-ignored paths as porcelain’s !! — both columns — like git status --porcelain --ignored. It is opt-in because the default walk PRUNES ignored directories, which is what keeps a status over a repo with a large target/ fast; asked for, the walk must descend into them. A wholly-ignored directory collapses to ONE entry naming the directory (target, not its 50k files), exactly like a collapsed untracked directory — so a consumer resolves directory-ness from its own model and matches descendants by path prefix:

local st = btv.await(btv.git.status(root, { ignored = true }))
for _, e in ipairs(st.entries) do
  if e.index == "!" then ignored[e.path] = true end   -- a file OR a directory
end

A repository that is not trusted — owned by another user and not listed in safe.directory, git’s rule — has its own config ignored, so the filter drivers it defines (filter.<name>.clean and friends) do not run and files are compared unfiltered. The result then carries notice, a message naming the repository and the skipped drivers, and the first such notice per repository is also shown with btv.notify at WARN.

Defined in git.lua.

btv.git.clone(url, dir, opts)

clone(url, dir, opts) -> promise of the created dir. opts.depth makes it a shallow clone (1 = only the tip commit); opts.branch checks out a named branch or tag instead of the remote default. Replaces git clone. (git’s --filter=blob:none has no gix analog — a shallow depth supplies the same speed-up.) Rejects (EGIT) on any clone/fetch failure.

Defined in git.lua.

btv.git.checkout(dir, rev, opts)

checkout(dir, rev, opts) -> promise resolving nil. Checks out rev in the repo at dir, updating the worktree to match. Two modes:

btv.git.checkout(dir, sha, { detach = true })  -- git checkout --detach <sha>
btv.git.checkout(dir, "main")                  -- git checkout main

With opts.detach the rev is any revision (a sha, tag, or ref) and HEAD is left pointing straight at that commit — how an exact pin is applied. Without it, rev names a BRANCH and HEAD stays symbolic on it; a branch that exists only as a remote-tracking ref (the usual case for anything but the default branch of a fresh clone) is created locally from it first, as git checkout <branch> does.

Attaching is what makes a detached checkout movable again: pull fast-forwards the current branch, so it rejects outright while HEAD names a bare commit. Rejects (ENOENT) when an attach target is neither a local nor a remote-tracking branch.

Defined in git.lua.

btv.git.fetch(dir, opts)

fetch(dir, opts) -> promise resolving nil. Fetches the repo’s remote, updating the remote-tracking refs and leaving HEAD and the worktree alone — the half of pull that touches no working state. Replaces git fetch.

opts.unshallow drops a shallow clone’s boundary (git fetch --unshallow), so history a depth = 1 clone omitted becomes reachable. That is the prerequisite for checking out an arbitrary older revision in place — without it, a shallow clone simply does not contain the commit and the checkout rejects. A no-op on a repo that was never shallow.

Defined in git.lua.

btv.git.pull(dir)

pull(dir) -> promise of { updated, sha }. Fetches the repo’s remote and fast-forwards only the current branch (rejecting ENOTFF on a divergence, never merging). updated is false when already current. Replaces git pull --ff-only.

Defined in git.lua.

btv.git.submodule_update(dir, opts)

submodule_update(dir, opts) -> promise resolving nil. Clones-if-missing (opts.init) and checks out every submodule to its recorded commit, opts.recursive-ly into nested ones. Replaces git submodule update --init --recursive.

Defined in git.lua.

btv.git_local

btv.git_local.discover(path)

discover(path) -> promise of { root, git_dir, prefix }. path is any path inside the repo. Replaces rev-parse --show-toplevel / --absolute-git-dir / --show-prefix. Rejects (ENOREPO) when path is not inside a repository.

Defined in git.lua.

btv.git_local.head(path)

head(path) -> promise of { branch, detached, sha }. branch is nil on a detached HEAD (detached then true); sha is the full resolved commit oid (empty on an unborn HEAD). Replaces rev-parse --abbrev-ref HEAD.

Defined in git.lua.

btv.git_local.show(file, rev)

show(file, rev) -> promise of the RAW bytes of file (an absolute path) as it was at rev (e.g. "HEAD"). Replaces git show HEAD:<rel>, but you pass the plain file path — the repo-relative path is computed for you (symlink-safe). Rejects (ENOENT) when the file has no version at rev (new / untracked file).

Defined in git.lua.

btv.git_local.diff_file(path, file)

diff_file(path, file) -> promise of { added, changed, removed, hunks } for file’s working tree vs its HEAD blob (each hunk { old_start, old_count, new_start, new_count }). Replaces git diff -U0 -- <file> plus hunk parsing. path locates the repo (any path inside it); file is the file to diff.

Defined in git.lua.

btv.git_local.status(path, opts)

status(path, opts) -> promise of { dirty, entries } in git status --porcelain XY terms. Each entry is { path, index, worktree, orig_path }:

path       repo-relative path of the changed file
index      the staged (index-vs-HEAD) column, one letter
worktree   the unstaged (worktree-vs-index) column, one letter
orig_path  where the content came from, for a rename/copy; "" otherwise

A column is " " when the file is unmodified in it. There is exactly ONE entry per path: a file that is staged and then edited again arrives once with both columns set (porcelain’s MM), never as two entries carrying one column each. An untracked file is porcelain’s ?? — both columns.

Unlike git status, an unstaged rename IS detected: it reads R on the destination with orig_path set, where git prints a deletion plus an untracked file.

opts.ignored (default false) additionally reports git-ignored paths as porcelain’s !! — both columns — like git status --porcelain --ignored. It is opt-in because the default walk PRUNES ignored directories, which is what keeps a status over a repo with a large target/ fast; asked for, the walk must descend into them. A wholly-ignored directory collapses to ONE entry naming the directory (target, not its 50k files), exactly like a collapsed untracked directory — so a consumer resolves directory-ness from its own model and matches descendants by path prefix:

local st = btv.await(btv.git.status(root, { ignored = true }))
for _, e in ipairs(st.entries) do
  if e.index == "!" then ignored[e.path] = true end   -- a file OR a directory
end

A repository that is not trusted — owned by another user and not listed in safe.directory, git’s rule — has its own config ignored, so the filter drivers it defines (filter.<name>.clean and friends) do not run and files are compared unfiltered. The result then carries notice, a message naming the repository and the skipped drivers, and the first such notice per repository is also shown with btv.notify at WARN.

Defined in git.lua.

btv.git_local.clone(url, dir, opts)

clone(url, dir, opts) -> promise of the created dir. opts.depth makes it a shallow clone (1 = only the tip commit); opts.branch checks out a named branch or tag instead of the remote default. Replaces git clone. (git’s --filter=blob:none has no gix analog — a shallow depth supplies the same speed-up.) Rejects (EGIT) on any clone/fetch failure.

Defined in git.lua.

btv.git_local.checkout(dir, rev, opts)

checkout(dir, rev, opts) -> promise resolving nil. Checks out rev in the repo at dir, updating the worktree to match. Two modes:

btv.git.checkout(dir, sha, { detach = true })  -- git checkout --detach <sha>
btv.git.checkout(dir, "main")                  -- git checkout main

With opts.detach the rev is any revision (a sha, tag, or ref) and HEAD is left pointing straight at that commit — how an exact pin is applied. Without it, rev names a BRANCH and HEAD stays symbolic on it; a branch that exists only as a remote-tracking ref (the usual case for anything but the default branch of a fresh clone) is created locally from it first, as git checkout <branch> does.

Attaching is what makes a detached checkout movable again: pull fast-forwards the current branch, so it rejects outright while HEAD names a bare commit. Rejects (ENOENT) when an attach target is neither a local nor a remote-tracking branch.

Defined in git.lua.

btv.git_local.fetch(dir, opts)

fetch(dir, opts) -> promise resolving nil. Fetches the repo’s remote, updating the remote-tracking refs and leaving HEAD and the worktree alone — the half of pull that touches no working state. Replaces git fetch.

opts.unshallow drops a shallow clone’s boundary (git fetch --unshallow), so history a depth = 1 clone omitted becomes reachable. That is the prerequisite for checking out an arbitrary older revision in place — without it, a shallow clone simply does not contain the commit and the checkout rejects. A no-op on a repo that was never shallow.

Defined in git.lua.

btv.git_local.pull(dir)

pull(dir) -> promise of { updated, sha }. Fetches the repo’s remote and fast-forwards only the current branch (rejecting ENOTFF on a divergence, never merging). updated is false when already current. Replaces git pull --ff-only.

Defined in git.lua.

btv.git_local.submodule_update(dir, opts)

submodule_update(dir, opts) -> promise resolving nil. Clones-if-missing (opts.init) and checks out every submodule to its recorded commit, opts.recursive-ly into nested ones. Replaces git submodule update --init --recursive.

Defined in git.lua.

btv.glob

btv.glob.match(pattern, path, opts)

btv.glob.match(pattern, path, opts) -> boolean: does path match the glob pattern? The one-shot form — it goes through the same compiled-regex cache as btv.glob.compile, so calling it in a loop over many paths does not recompile. Raises on an invalid pattern.

The glob syntax, shared by every function in this namespace:

*        any run of characters, NOT crossing a `/` (see `literal_separator`)
**       any run of characters, crossing `/`  ( `**/x`, `a/**`, `a/**/b` )
         — and it spans ZERO components too, so `**/x` matches a bare `x`
?        exactly one character — also `/`-stopped, per `literal_separator`
[abc]    one of the listed characters       [a-z] a range
[!abc]   none of them                       `[^abc]` means the same thing
{a,b}    either alternative                 `{a,b/**}` nests
\x       a literal `x` (unix style only — in windows style `\` separates)

Two defaults worth knowing:

  • * stops at /. btv.glob.match("*.lua", "a/b/c.lua") is false; say "**/*.lua", or pass basename = true to match the path’s last component. This is shell / gitignore / LSP semantics. Pass literal_separator = false for vim’s autocmd rule, where * does cross /.
  • Path style defaults to the host’s, so pass it when you know the path’s. A pattern and its candidates are unix-style (/ separates, \ escapes) on a Unix build and windows-style (\ separates too) on a Windows build, unless you pass style = "unix" / style = "windows". Pass it whenever the path’s origin is known: a daemon or remote session can hand a Unix-hosted editor Windows paths, so the convention belongs to the path, not the build.

An invalid pattern raises. The one exception is an unclosed class (foo[bar), which is taken as literal text — a filename may genuinely contain a bracket.

path is matched as bytes, not decoded text: a name that is not valid UTF-8 (a latin-1 filename on disk, a name that arrived over the encoding seam) matches by its real bytes rather than raising or being lossily rewritten. The pattern must be valid UTF-8 — glob syntax is ASCII, so a pattern that isn’t is a mistake worth hearing about.

opts (all optional):

style             = "unix" | "windows"  -- separator convention (default: the host's)
ignorecase        = false               -- ASCII-case-insensitive match
literal_separator = true                -- `*`/`?` stop at a separator
basename          = false               -- a separator-less pattern matches the
                                           path's last component
empty_alternates  = false               -- allow the empty branch in `{a,}`
btv.glob.match("*.lua", "init.lua")            --> true
btv.glob.match("*.lua", "conf/init.lua")       --> false  ( `*` stops at `/` )
btv.glob.match("**/*.lua", "conf/init.lua")    --> true
btv.glob.match("*.lua", "conf/init.lua", { basename = true })  --> true
btv.glob.match("src/**/*.{rs,toml}", "src/a/b/Cargo.toml")     --> true

Defined in glob.lua.

btv.glob.compile(pattern, opts)

btv.glob.compile(pattern, opts) -> glob: compile pattern into a reusable glob object. Equivalent to btv.glob.match for a single test, but worth holding onto when a pattern is stored and matched later (a watcher, a filter kept across events) — it makes the compile explicit and its cost obvious. opts is as documented on btv.glob.match. Raises on an invalid pattern.

The returned object has:

g:test(path)  -> boolean: does `path` match
g:pattern()   -> the glob as written
g:regex()     -> the regex source the glob translated to

g:regex() is for debugging: a glob that mismatches is far easier to reason about against the regex it actually became. It is the regex this glob runs, not a standalone equivalent — under basename = true the path is reduced to its last component before the regex sees it, and that reduction is not in the regex. (Which is why btv.glob.to_regex, whose job is to hand the translation to another engine, refuses a basename glob rather than answering with a regex that means something else.)

local ignore = btv.glob.compile("**/target/**")
if ignore:test(path) then return end

Defined in glob.lua.

btv.glob.set(patterns, opts)

btv.glob.set(patterns, opts) -> globset: compile a list of globs into one matcher. All the patterns share a single regex set, so testing a path against 50 globs is one pass rather than 50 — the shape an ignore list or a set of file-watcher patterns wants. opts is as documented on btv.glob.match and applies to every pattern. An invalid pattern fails the whole set (naming itself), rather than being quietly dropped.

The returned object has:

s:test(path)     -> boolean: does ANY pattern match
s:matches(path)  -> list of the 1-based indices of the patterns that matched
s:patterns()     -> the pattern list as written, in set order
local ignore = btv.glob.set({ "**/.git/**", "**/target/**", "*.tmp" })
local kept = btv.tbl.filter(function(p) return not ignore:test(p) end, paths)

Defined in glob.lua.

btv.glob.any(patterns, path, opts)

btv.glob.any(patterns, path, opts) -> boolean: does any glob in patterns match path? The one-shot form of btv.glob.set — cached the same way, so a repeated call with the same list reuses the compiled set. patterns may also be a single pattern string, so a caller taking “a glob or a list of globs” from user config need not branch.

btv.glob.any({ "*.rs", "*.toml" }, "Cargo.toml")  --> true

Defined in glob.lua.

btv.glob.filter(patterns, paths, opts)

btv.glob.filter(patterns, paths, opts) -> list: the entries of paths matching any glob in patterns, in their original order. patterns may be a single pattern string or a list, as in btv.glob.any. One compiled set for the whole sweep.

btv.async(function()
  local rs = btv.glob.filter({ "**/*.rs" }, btv.await(btv.fs.walk(".")))
end)

Defined in glob.lua.

btv.glob.to_regex(pattern, opts)

btv.glob.to_regex(pattern, opts) -> string: the regex source pattern translates to, without compiling or caching it. For introspection, and for handing the translation to another engine (it is a byte-oriented Rust regex pattern, the dialect btv.regex speaks). opts is as documented on btv.glob.match.

A basename = true glob raises here instead of answering. basename reduces the candidate to its last component before the regex runs; that is a step on the path, not something the pattern can express (with literal_separator = false a * crosses /, so no (?:.*/)? prefix is faithful). Since this function exists to hand the regex to an engine that will not perform the reduction, returning one anyway would be silently wrong — reduce the path yourself, or drop the option.

btv.glob.to_regex("*.lua")  --> "(?-u)^[^/]*\\.lua$"

Defined in glob.lua.

btv.glob.is_glob(s)

btv.glob.is_glob(s) -> boolean: does s carry a glob metacharacter (* ? [ {) — i.e. is it a pattern at all, rather than a plain path? The canonical predicate for code that branches on “did the user type a glob here” (expand it) or “a literal path” (use it as-is).

btv.glob.is_glob("src/*.rs")   --> true
btv.glob.is_glob("src/lib.rs") --> false

Defined in glob.lua.

btv.glob.split(list)

btv.glob.split(list) -> a list of patterns: split one comma-separated line of globs — the shape a “files to include” text box takes — trimming whitespace around each entry and dropping empty ones.

The comma is not a plain separator: {...} alternation puts commas inside a single glob, so only commas at brace depth zero split. That is the whole reason to call this rather than a hand-rolled gmatch.

btv.glob.split("src/**, docs/**")   --> { "src/**", "docs/**" }
btv.glob.split("**/{a,b}/**")       --> { "**/{a,b}/**" }   ( one pattern )
btv.glob.split("  , *.lock ,, ")    --> { "*.lock" }

Defined in glob.lua.

btv.hash

btv.hash.file(path, algo)

btv.hash.file(path[, algo]) -> promise of the file’s lowercase-hex digest. The streaming member of the btv.hash.* family (the in-memory string hashers and the incremental btv.hash.new live in hash.lua): hashing a file is I/O, so it routes through the fs machinery rather than reading the file into Lua first. The server streams the file in fixed 64 KiB chunks and folds each into the hasher, so a 300 MB file costs 64 KiB of memory — not 300 MB, as btv.hash.sha256(btv.await(btv.fs.read(path))) would. On a remote/browser build the hashing runs entirely on the daemon; only the short digest crosses the wire, never the file’s bytes. algo is one of "sha1" / "sha256" / "sha512" / "md5" (default "sha256"); an unknown algorithm rejects (EINVAL).

Defined in fs.lua.

btv.hash.sha1(data)

btv.hash.sha1(data) -> the SHA-1 digest of data, a 40-character lowercase-hex string. data is hashed as raw bytes (binary-safe). Good for content addressing and cache keys; NOT collision-resistant, so never rely on it for security.

Defined in hash.lua.

btv.hash.sha256(data)

btv.hash.sha256(data) -> the SHA-256 digest of data, a 64-character lowercase-hex string. data is hashed as raw bytes (binary-safe). The usual default for a checksum or content hash.

Defined in hash.lua.

btv.hash.sha512(data)

btv.hash.sha512(data) -> the SHA-512 digest of data, a 128-character lowercase-hex string. data is hashed as raw bytes (binary-safe).

Defined in hash.lua.

btv.hash.md5(data)

btv.hash.md5(data) -> the MD5 digest of data, a 32-character lowercase-hex string. data is hashed as raw bytes (binary-safe). For checksums and cache keys only — MD5 is cryptographically broken; never use it for security.

Defined in hash.lua.

btv.hash.new(algo)

btv.hash.new(algo) -> an incremental hasher for data that arrives in pieces — a subprocess’s stdout, a download — so you can hash a stream as it flows without ever holding it all in memory. algo is one of "sha1" / "sha256" / "sha512" / "md5"; an unknown name errors here, at construction. The returned object has two methods:

h:update(chunk)  -- fold more raw bytes in; call as many times as you like
h:hexdigest()    -- lowercase-hex digest of everything fed so far. NON-consuming:
                    you may read an intermediate digest and keep updating after.

Drive it from a stream with btv.await_each — feed each chunk in as it arrives:

btv.async(function()
  local h = btv.hash.new("sha256")
  for batch in btv.await_each(btv.run_stream({ cmd = "curl", args = { "-s", url } })) do
    for _, line in ipairs(batch) do h:update(line) end
  end
  print(h:hexdigest())
end)()

Note btv.run_stream is line-oriented (newlines are stripped from each batch). For a byte-exact digest of arbitrary binary output, feed the raw chunks from btv.process.open’s on_stdout instead. To digest a file on disk, prefer btv.hash.file, which streams the file server-side and never sends its bytes to Lua.

Defined in hash.lua.

btv.helix

btv.helix.enable(opts)

No documentation comment in the prelude.

Defined in helix.lua.

btv.helix.disable()

No documentation comment in the prelude.

Defined in helix.lua.

btv.helix.smart_case(on)

btv.helix.smart_case(on) sets whether Helix document search (//?/n/N) defaults to smart-case — case-insensitive unless the pattern carries an uppercase character — Helix’s own default (on == nil means true). This is self-contained: it never touches the global :set ignorecase/smartcase that vim-mode search reads, so toggling it leaves your vim-mode search behavior untouched. Turn it off for case-sensitive Helix search (which then follows the global options like vim mode does).

Defined in helix.lua.

btv.hl

btv.hl.get(ns, opts)

No documentation comment in the prelude.

Defined in api.lua.

btv.hl.exists(name)

btv.hl.exists(name): is the highlight group name defined? Returns a native boolean (the rest of btv.* is boolean, not vim’s 1/0). Backed by the same btv._hl_defs registry nvim_get_hl reads (concrete groups and links both count).

Defined in api.lua.

btv.hl.palette()

btv.hl.palette() -> the ACTIVE colorscheme’s semantic palette, as a table of "#rrggbb" strings. This is the canonical way a plugin picks default colors: read the hues the running theme actually uses instead of hardcoding one theme’s hex values, which go wrong the moment any other colorscheme (or a light flavour of the same one) is loaded.

Each slot resolves through a chain of the canonical vim/treesitter groups that carry that hue, most specific first, falling back to the built-in bemtvi colorscheme’s value when the active theme defines none of them — the editor’s default look, so a bare session still lands on the editor’s own colors. The slot names are the roles (purple is the keyword hue, green the “added” hue), whatever colour the running theme gives them:

SURFACES
  bg             Normal.bg
  bg_alt         NormalFloat.bg -> StatusLine.bg    (a float / sidebar / status strip)
  bg_cursorline  CursorLine.bg
  bg_sel         Visual.bg
TEXT
  fg             Normal.fg
  muted          Comment.fg -> LineNr.fg -> NonText.fg   (guides, dimmed entries)
ACCENTS
  red            DiagnosticError.fg -> ErrorMsg.fg -> Exception.fg
  green          Added.fg -> String.fg -> DiagnosticOk.fg
  yellow         Type.fg -> DiagnosticWarn.fg -> WarningMsg.fg
  blue           Function.fg -> Directory.fg -> Title.fg
  purple         Keyword.fg -> Statement.fg -> PreProc.fg
  cyan           Operator.fg -> Special.fg -> DiagnosticInfo.fg
  orange         Constant.fg -> Number.fg -> Boolean.fg

Links are followed to the concrete definition, so a theme that writes Function = { link = "Blue" } still resolves. A fresh table is returned each call and nothing is cached: re-call it from a ColorScheme handler to restyle live.

local function paint()
  local p = btv.hl.palette()
  btv.hl.define(0, "MyPluginKey", { fg = p.cyan })
  btv.hl.define(0, "MyPluginDim", { fg = p.muted, italic = true })
end
paint()
btv.on("ColorScheme", {}, paint)

Defined in api.lua.

btv.hl.blend(a, b, ratio)

btv.hl.blend(a, b, ratio) -> "#rrggbb": colour a mixed into colour b, ratio of the way (0 is b, 1 is a; each channel is ratio * a + (1 - ratio) * b, rounded). Each colour is a "#rrggbb" string or a colour number as btv.hl.get returns it. This is how a plugin derives a background tint from the active theme, which btv.hl.palette alone can’t give: a palette accent faded into the palette background reads as that theme’s own “added” line or highlighted row, on a dark and a light flavour alike. A colour that isn’t one, or a ratio outside 0..1, raises.

local p = btv.hl.palette()
btv.hl.fallback("MyPluginAdded", { bg = btv.hl.blend(p.green, p.bg, 0.2) })

Defined in api.lua.

btv.hl.fallback(name, spec)

btv.hl.fallback(name, spec) -> true when it installed: define highlight group name as a DEFAULT that yields to the active colorscheme and to the user. It writes spec only when the group is undefined, or when the group still holds exactly what this API last wrote for it — so a theme (or an explicit btv.hl.define) that claims the group is never clobbered, and a stale default from a previous theme is.

That second half is what a plain if not btv.hl.exists(name) guard gets wrong. :colorscheme drops only the outgoing scheme’s OWN groups; a plugin’s stay. So a group the new theme doesn’t model still holds the default derived from the old one, exists reports true, and the guard skips the re-apply — leaving, say, a dark-flavour hex on a light theme. Pair this with btv.hl.palette and a ColorScheme handler and the defaults track whatever is loaded:

local function paint()
  local p = btv.hl.palette()
  btv.hl.fallback("MyPluginKey", { fg = p.cyan })
  btv.hl.fallback("MyPluginDim", { fg = p.muted, italic = true })
end
paint()
btv.on("ColorScheme", {}, paint)

Defined in api.lua.

btv.host

btv.host.name()

btv.host.name() -> the id of the app embedding this editor (what it named itself with, SessionOptions::host), or nil when nothing does: the TUI, bemtvi-gui, the browser. It’s set before any config runs, so an init.lua can branch on it.

Defined in host.lua.

btv.host.send(name, payload)

btv.host.send(name, payload): send the app embedding this editor a message, which it gets as EditorEvent::Host { name, payload }. name is a non-empty string the app and the config agree on; payload is anything msgpack carries: nil, booleans, numbers, strings, and tables of them (a table whose keys are exactly 1..n goes as a list). Anything else in it (a function, a coroutine) raises.

Raises when no app embeds the editor (btv.host.name() is nil), so a config meant to run both ways checks that first:

if btv.host.name() then
  btv.keymap.set("n", "<leader>p", function()
    btv.host.send("preview", { file = btv.buf.name(0) })
  end)
end

Defined in host.lua.

btv.host.forward(events)

btv.host.forward(events): tell the app embedding this editor about edits as they happen, as btv.host.send messages. events is a list of:

"changed"  a buffer's text changed: { buf, name, modified }, once typing pauses
           for 200 ms (the last buffer changed wins)
"wrote"    a buffer was written: { buf, name }

name is the buffer’s file ("" for one with none). The text isn’t sent — a large buffer on every keystroke would stall the editor; the app asks for what it needs (btv.buf.lines(buf, 0, -1) through its handle). Calling it again replaces what it forwards (btv.host.forward({}) stops). Raises when no app embeds the editor, or for an event it doesn’t know.

Defined in host.lua.

btv.http

response:text()

res:text() -> the body as a Lua string (already the raw bytes; this is the identity read, present for fetch-parity and readability). Never rejects.

Defined in http.lua.

response:json()

res:json() -> the body decoded from JSON into a Lua value. Uses btv.json.decode (the shared codec). Raises on malformed JSON — inside btv.async that surfaces as a promise rejection, exactly like the browser response.json() rejecting.

Defined in http.lua.

btv.http.encode_uri_component(s)

btv.http.encode_uri_component(s) -> s percent-encoded for use INSIDE a URL component (one query key/value, a path segment), matching JavaScript’s encodeURIComponent: every byte outside the RFC 3986 unreserved set (A-Z a-z 0-9 - _ . ~) becomes %XX. UTF-8 is encoded byte-by-byte, so a space is %20 and é is %C3%A9. Public. The encoding is the percent-encoding (rust-url) crate’s, not a bespoke implementation (btv._url_encode_component).

Defined in http.lua.

btv.http.encode_query(params)

btv.http.encode_query(params) -> an encoded query string with NO leading ?, built from params: either a MAP ({ q = "hi there", page = 2 } -> q=hi+there&page=2) or an ordered PAIR-LIST ({ { "q", "a" }, { "q", "b" } } -> q=a&q=b, for repeated / fixed-order keys). A LIST value in a map repeats the key ({ tag = { "x", "y" } } -> tag=x&tag=y). Keys and values are tostring’d. A map has no guaranteed key order — use a pair-list when order matters. Public.

The Lua side only flattens the map/list shapes into a pair-list; the actual application/x-www-form-urlencoded serialization is the form_urlencoded (rust-url) crate’s (btv._url_encode_query), so a space is + and a +/&/= in a value is escaped correctly — no bespoke encoder.

Defined in http.lua.

btv.http.build_url(url, query)

btv.http.build_url(url, query) -> url with query (a table, see encode_query) appended as a query string — joined with ?, or & if url already has a ?. A nil / empty query returns url unchanged. Public — the exact join opts.query does.

Defined in http.lua.

btv.http.fetch(url, opts)

btv.http.fetch(url[, opts]) -> promise of a Response. The one entry point of the btv.http client — modeled on the browser’s fetch, fully async, non-blocking.

Args:

  • url — the absolute request URL (http:// or https://). Required.
  • opts — an optional table:
    • method — HTTP method (default "GET"); upper-cased for you.
    • query — query parameters appended to url (encoded), so you never build ?a=1&b=2 by hand. A { [name] = value } map or a { {name, value}, ... } pair-list (repeated/ordered keys); a LIST value repeats the key. See btv.http.encode_query.
    • headers — request headers, a { [name] = value } map or a { {name, value}, ... } pair-list (for repeated / ordered headers).
    • body — the request body. A string is sent verbatim; any other value is encoded to JSON (btv.json.encode) AND a Content-Type: application/json header is added unless the caller set one.
    • form — a table sent as an application/x-www-form-urlencoded body (same shape as query); sets the content-type header unless the caller did. Mutually exclusive with body.
    • timeout — an overall timeout in milliseconds (default: the backend’s).
    • redirect — "follow" (default), "manual" (don’t follow; resolve with the 3xx response), or "error" (reject on a redirect) — as in fetch.
    • max_redirects — cap on redirects to follow when redirect == "follow".

The Response it resolves with is { status, ok, statusText, headers, body } — ok is true for a 2xx status, headers a { [lowercased-name] = value } map — plus :text() (the raw body) and :json() (the body decoded from JSON).

RESOLVES with the Response for ANY HTTP status (a 404 / 500 still resolves — check res.ok / res.status); REJECTS with a { message } table only on a network / transport failure (DNS, connect, TLS, timeout, a bad URL) — the exact split the browser fetch makes. Await it inside btv.async, or chain :next / :catch:

btv.async(function()
  local res = btv.await(btv.http.fetch("https://api.example.com/search", {
    query = { q = "hello world", page = 2 },   -- ?q=hello+world&page=2 (encoded for you)
  }))
  if res.ok then btv.print(res:json().name) else btv.print("HTTP " .. res.status) end
end)()

Defined in http.lua.

btv.http.fetch_local(url, opts)

btv.http.fetch_local(url[, opts]) — identical to btv.http.fetch, but the request runs on THIS machine’s network (the native ureq, or the browser’s own fetch()) even when the session’s btv.http routes to a daemon. The HTTP analogue of the plugin manager’s local-only btv.fs (btv._local_fs_op): reach for it when a request must originate from the client, not the daemon (local is a Lua keyword, hence the _local name rather than an option). In a bare/local session it is exactly btv.http.fetch.

Defined in http.lua.

mount:url()

mount:url() — the mount’s base URL, trailing-slashed (http://127.0.0.1:53124/<token>/plugin/example/). Hand this to a browser, an <iframe>, or btv.ui.open. Absorbs the difference between worlds: natively it is the editor’s bound port plus the session token, on the web build the page’s own origin. It is the only way to address a mount — build every URL from it.

Defined in httpmount.lua.

mount:path()

mount:path() — the mount’s path on the origin (/<token>/plugin/example natively, /plugin/example on the web build), no trailing slash.

Defined in httpmount.lua.

mount:origin()

mount:origin() — the origin serving this mount (http://127.0.0.1:53124).

Read through the central btv._http_origin rather than captured per-handle, so a :set httpport=9000 rebind moves EVERY live mount’s URL at once. A handle that cached its origin at mount time would keep handing out a dead one.

Defined in httpmount.lua.

mount:close()

mount:close() — retire the route. Idempotent. In-flight requests are dropped (their clients get a 503). The listener itself stays bound for the session: an idle listener costs nothing, and a stable origin survives a plugin reload.

Defined in httpmount.lua.

mount:is_open()

mount:is_open() — false once :close() ran.

Defined in httpmount.lua.

btv.http.mount(opts)

btv.http.mount(opts) -> a promise of a Mount.

Publishes opts.on_request at /plugin/<opts.name>/* on the editor’s one origin (natively under the session-token segment — address it only through mount:url()), binding the listener if this is the first mount. The promise RESOLVES with the handle once the route is live, and REJECTS (a { message } table) when the listener cannot bind ('httpport' is taken) or the name is already mounted — never a silent fallback to some other port or a suffixed name.

opts = {
  name = "example",              -- required; [%w_-]+. Mounts at /plugin/example
  on_request = function(req, respond) end,  -- required
  timeout = 30000,               -- ms before an unanswered request gets a 504
  same_origin = false,           -- DANGEROUS when true: serve WITHOUT the sandbox CSP
}

Every response is sandboxed into an opaque origin by default (a content-security-policy of sandbox allow-scripts … is added beside any CSP the plugin sets). same_origin = true turns that off so the page can fetch() its own mount and use storage — on the web build it then runs with the EDITOR’s privileges (its OPFS, the wasm, the daemon link), so only use it for a page made entirely of the plugin’s own code, never for untrusted HTML.

The handler is called on_request(req, respond):

req = {
  method = "GET",                -- upper-cased
  path = "/style.css",           -- MOUNT-RELATIVE; a bare /plugin/example is "/"
  raw_path = "/plugin/example/style.css",  -- below the session token, in every world
  query = { v = "2" },           -- decoded
  headers = { ["accept"] = "*/*" },  -- lowercased names
  body = "",                     -- raw bytes, binary-safe
  name = "example",              -- the mount it routed to
}

req.path is mount-relative so the same handler works under any prefix or origin — the native port and the browser Service Worker’s origin included.

respond(res) answers it, exactly once:

respond({ status = 200, headers = { ["content-type"] = "text/html" }, body = html })

Every res field is optional ({} is a bare 200). respond may be called LATER — that is why it is a callback rather than a return value: a handler is free to await a file read or an upstream fetch first.

on_request = function(req, respond)
  btv.fs.read("/tmp/page.html"):next(function(html)
    respond({ body = html })
  end)
end

Defined in httpmount.lua.

btv.http.origin()

btv.http.origin() -> the origin serving plugin mounts (http://127.0.0.1:53124), or nil when nothing has mounted yet. Only the origin: natively a mount also needs the session-token path segment, so address mounts with mount:url(), not this. Nil rather than a guess: until the first btv.http.mount there is no listener, so there is no origin to report and inventing one from 'httpport' would describe a port nothing is listening on.

Defined in httpmount.lua.

btv.indent

btv.indent.expr(src)

btv.indent.expr(src): compute a line’s indent, or clear the expression with nil.

bemtvi indents from tree-sitter’s indents.scm where a grammar has one; this is the escape hatch for a filetype that has no grammar, or whose indent query gets a construct wrong. It sits below the tree-sitter verdict — structure beats a hand-written rule — and above smartindent/autoindent.

src is a string of Lua source — an expression, not a function value — because it runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. That means it cannot read the buffer; everything it needs is passed in:

prev         the previous non-blank line ("" when there is none)
line         the line being indented
lnum         its 1-based number
sw           the effective 'shiftwidth'
previndent   the previous non-blank line's indent, in columns

Return the target indent in columns, or nil to decline and let smartindent/autoindent answer:

btv.indent.expr([[
  line:match("^%s*end") and previndent - sw
    or prev:match("then%s*$") and previndent + sw
    or nil
]])

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. That is deliberate — no call shape is a clean once-per-item traversal (:s re-runs on every keystroke of the live preview, a foldexpr sees only the rows an edit touched, the picker scorer only the top survivors, foldtext is memoized), so an accumulator would be quietly wrong. It is consulted per line, so a =G over a large range calls it once per line; an expression that errors, exceeds its deadline, or returns a non-number reports once and is then uninstalled rather than repeating per line.

Defined in indent.lua.

btv.intro

btv.intro.show()

btv.intro.show() — draw the intro screen in the current window, as :intro does. It works in any session, and btv.g.intro = false only stops the one drawn at startup.

The window must show an empty, unnamed, unmodified buffer (:enew gives one): the intro is drawn below that buffer’s single line, never over text, so on any other buffer this raises. It goes the ways the startup intro goes: the first edit, another buffer in the window, :e file, or a split showing the buffer twice.

Defined in intro.lua.

btv.intro.dismiss()

btv.intro.dismiss() — clear the intro screen, if it shows.

Defined in intro.lua.

btv.json

btv.json.encode(value, opts)

btv.json.encode(value[, opts]) -> string. opts.pretty (default false) emits a 2-space-indented, multi-line document for human-readable / diff-friendly files; omit it for the compact one-liner.

Defined in stdlib.lua.

btv.json.decode(str)

btv.json.decode(str) -> value. Parses a JSON document (objects -> string-keyed tables, arrays -> sequences, null -> nil); raises on malformed input.

An EMPTY object decodes to btv.json.empty_object() rather than a bare {}, so decode -> edit -> encode (how a plugin rewrites a JSON file it owns) preserves what the document said: without the mark {"pylsp":{}} came back out as {"pylsp":[]}, since a bare empty table is equally an empty array. It reads like any other empty table (next(t) == nil, fill it in and it is an object with those keys); the one place the mark shows is btv.tbl.deep_extend, where — like every JSON sentinel — it is a VALUE that replaces, so a merged-in {} means {}.

Defined in stdlib.lua.

btv.json.null

btv.json.null: the value that encodes as JSON null.

Lua has no way to say null: storing nil under a key simply removes the key, so { token = nil } encodes as {}, not {"token": null}. To a protocol peer those are different messages — “unset, use your default” versus “explicitly nothing” — and LSP servers do read them differently (snyk_ls expects a null token when there is none). Put this in the table instead:

init_options = { token = btv.env.get("SNYK_TOKEN") or btv.json.null }

Identify it on the way back out with btv.json.is_null(value), not value == btv.json.null: every path that stores it copies it (btv.tbl.deepcopy, and so the btv.tbl.deep_extend behind btv.lsp.config), and a copy carries the mark but is a different table.

btv.json.decode still turns an incoming null into nil (there is nowhere else for it to go in a Lua table), so this is a write-side value.

Defined in stdlib.lua.

btv.json.empty_object()

btv.json.empty_object() -> a table that encodes as JSON {} rather than [].

The other thing Lua can’t say: an empty table is both an empty array and an empty object, and bemtvi’s codec — like neovim’s — has to pick one, so it picks []. A server given [] where its schema says object either rejects the message or silently ignores the field, and the resulting “the server started but does nothing” is exactly the kind of quiet wrongness worth a sentinel.

init_options = { memory = { file_store = btv.json.empty_object() } }

A fresh table each call, and the mark answers “array or object?” rather than standing in for the contents: fill it in afterwards and it is still an object, with everything you put in it (integer keys become the string keys JSON objects take).

btv.json.decode hands back this same shape for an empty object it read, which is what makes decode -> encode round-trip a {} in a file rather than flattening it to [].

Its sibling is the value btv.json.null, which encodes as JSON null — the other thing a Lua table cannot carry, since a nil value simply removes the key.

Defined in stdlib.lua.

btv.json.is_null(value)

btv.json.is_null(value) -> is value the JSON-null sentinel?

The read side of btv.json.null. It answers on a copy of the sentinel as well as on the sentinel itself, which is what makes it the right test: a config’s value has almost always been through btv.tbl.deep_extend by the time anyone reads it back, and that copies. Everything else — including btv.json.empty_object(), a bare {}, and nil — is false.

if btv.json.is_null(cfg.init_options.token) then
  -- the user said "explicitly no token", not "I forgot to set one"
end

Defined in stdlib.lua.

btv.jumplist

btv.jumplist.get(winnr, _tabnr)

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.keymap

btv.keymap.set(mode, lhs, rhs, opts)

btv.keymap.set(mode, lhs, rhs, opts): map lhs to rhs in mode. rhs is a function (stored in btv._keymap_fns) or a string (fed as keys). Maps are non-recursive by default (the btv.keymap.set convention); pass opts.remap = true for a recursive map whose RHS keys are re-fed through the mapping layer (or, equivalently, opts.noremap = false). opts.desc is stored but unused; opts.buffer ties the map to one buffer (0 = current), opts.default marks an overridable built-in — both feed the precedence ladder the server applies.

opts.buffer may also be a btv.view handle: the map binds to the view’s buffer, and when that buffer doesn’t exist yet (it is created after the chunk that creates the view) it binds as soon as it does. A view’s :bufnr() reads false until then, and buffer = false is refused, so a map meant for one buffer never lands globally. A view that is already closed is refused; one closed while the bind waits drops it.

There is no <Cmd> key: a string rhs spelling one raises E5806 (it would type <, C, m, d, >). To run a command, map to a function, or to :Command<CR>:

btv.keymap.set("n", "<leader>w", function() btv.cmd("write") end)
btv.keymap.set("n", "<leader>q", ":quit<CR>")

Defined in keymap.lua.

btv.keymap.expand_leader(lhs)

btv.keymap.expand_leader(lhs): the lhs with <leader> / <localleader> replaced by the CURRENT vim.g.mapleader / vim.g.maplocalleader (each "\" when unset), matching the set-time expansion btv.keymap.set performs on every LHS it registers.

Call this when you need the concrete key sequence a map is registered under — most often to feed it back through btv._feedkeys, which parses raw vim key-notation and does NOT know <leader>. Expand once, at the moment you set the map, and reuse that string: the leader in force then is the one baked into the registration.

Defined in keymap.lua.

btv.keymap.del(mode, lhs, opts)

btv.keymap.del(mode, lhs, opts): remove the mapping(s) for lhs in mode. opts.buffer (0 = current) targets a buffer-local map; absent targets globals.

Defined in keymap.lua.

btv.keymap.raw_buf_set(buffer, mode, lhs, rhs, opts)

btv.keymap.raw_buf_set(buffer, mode, lhs, rhs, opts) [alias nvim_buf_set_keymap].

Defined in keymap.lua.

btv.keymap.raw_set(mode, lhs, rhs, opts)

btv.keymap.raw_set(mode, lhs, rhs, opts) [alias nvim_set_keymap]: the global form of the raw setter (a buffer-less raw_buf_set).

Defined in keymap.lua.

btv.keymap.buf_del(buffer, mode, lhs)

btv.keymap.buf_del(buffer, mode, lhs) [alias nvim_buf_del_keymap]: remove a buffer-local mapping. The global form, nvim_del_keymap, aliases btv.keymap.del directly — its (mode, lhs) call already matches btv.keymap.del’s signature.

Defined in keymap.lua.

btv.keymap.get(mode)

btv.keymap.get(mode) [alias nvim_get_keymap]: every GLOBAL mapping that applies in mode, as maparg dicts. Buffer-local maps are excluded (btv.keymap.buf_get returns those).

Defined in keymap.lua.

btv.keymap.buf_get(buffer, mode)

btv.keymap.buf_get(buffer, mode) [alias nvim_buf_get_keymap]: the BUFFER-LOCAL mappings of buffer (0 = current) that apply in mode, as maparg dicts.

Defined in keymap.lua.

btv.keymap.arg(name, mode, _abbr, dict)

btv.keymap.arg(name, mode, abbr, dict) [alias vim.fn.maparg]: the mapping bound to name in mode. With dict truthy returns the full maparg dict (or {} when unmapped); else the rhs string (“” when unmapped, or for a function RHS). abbr (abbreviation lookup) is accepted and ignored — bemtvi has no abbreviations. A buffer-local map for the current buffer shadows a global one at the same lhs.

Defined in keymap.lua.

btv.layer

btv.layer.focus(target)

btv.layer.focus(target) -> nil. Move keyboard focus across the layout’s layers: target is "main" (the main editing area) or a dock’s name.

Defined in btv.lua.

btv.layer.main()

btv.layer.main() -> nil. Shorthand for btv.layer.focus("main") — focus the main editor area.

Defined in btv.lua.

btv.list

btv.list.extend(dst, src, start, finish)

btv.list.extend(dst, src, start, finish) [alias vim.list_extend]: append src[start..finish] onto dst.

Defined in stdlib.lua.

btv.list.slice(list, start, finish)

btv.list.slice(list, start, finish) [alias vim.list_slice]: a copy of list[start..finish] (1-based, inclusive; negative indices count from the end, as neovim). A completion plugin caps its menu with vim.list_slice(entries, 1, max_view_entries).

Defined in stdlib.lua.

btv.list.is_list(t)

No documentation comment in the prelude.

Defined in stdlib.lua.

btv.lsp

btv.lsp.find_root(bufnr, markers, name)

btv.lsp.find_root(bufnr, markers) -> promise of the workspace root: the nearest ancestor directory of the buffer’s file holding one of markers, or nil.

The upward search behind the declarative root_markers config key, public because a config whose root depends on something the declarative form can’t express — “the lockfile root, unless a Deno config is nearer” — needs to run the same search by hand inside its root_dir. It walks the project tree through the async btv.fs seam (local on native-bare, the daemon’s luafs_op over the wire otherwise), so it works on every front end with NO editor-thread block: the btv.async body runs as a coroutine and the caller :nexts the result (or btv.awaits it). Each btv.fs.readdir that rejects — an unreadable or non-directory ancestor — is treated as “no markers here” and the walk continues upward.

markers may carry PRIORITY TIERS — a list of lists rather than a flat list of names. A tier is exhausted over the WHOLE tree before the next one is tried anywhere, which is the entire point of the form: with { { "package-lock.json" }, { ".git" } } a lockfile six directories up beats a nested .git one directory up, so a package inside a monorepo attaches at the monorepo root rather than at its own sub-repo. A flat list is one tier, where the nearest directory holding any marker wins.

root_dir = function(bufnr, on_dir)
  btv.lsp.find_root(bufnr, { { "package-lock.json" }, { ".git" } }):next(on_dir)
end

Each level’s listing is read once and memoized, so a second tier re-walks the cached entries instead of re-reading the tree — N directory listings for any number of tiers, not N per tier.

Defined in lsp.lua.

btv.lsp.get_config(name)

btv.lsp.get_config(name) -> the fully-resolved config for name — the "*" base, the runtimepath lsp/<name>.lua preset, and every btv.lsp.config(name, …) override so far, deep-merged. This is exactly what a server would start with right now, so it is the honest answer to “what is this server configured as?” (neovim’s indexable vim.lsp.config[name], as a function — bemtvi’s btv.lsp.config is call-only).

Reads through the same cached preset loader the dispatcher uses, so there is one copy of the “source a preset off the runtimepath” logic and a caller can’t observe a config the dispatcher wouldn’t use. Returns a fresh table each call: mutating it changes nothing, which is what btv.lsp.config is for.

local cfg = btv.lsp.get_config("gopls")
if cfg.filetypes and btv.tbl.contains(cfg.filetypes, "go") then … end

A name with no preset and no override resolves to an empty table ({}), not nil — “configured as nothing” rather than “unknown”, since a config can be built up purely from btv.lsp.config calls with no bundled preset behind it at all.

Defined in lsp.lua.

btv.lsp.config(name, opts)

btv.lsp.config(name, opts): accumulate opts into name’s override layer (deep-merged over any prior call — configs compose across files and plugins). "*" is the all-clients base inherited by every server. Function-call form only: there is no btv.lsp.config[name] = {…} table-assignment sugar.

Two keys are easy to confuse, because they are the same word in different worlds:

root_dir / root_markers  -- the WORKSPACE root, sent as `rootUri`
cmd_cwd                  -- the directory the server PROCESS is launched in

They are unrelated, exactly as in vim. The root is a protocol fact: it tells the server which project to index, and resolving it is what root_markers does. The spawn directory defaults to the editor’s own working directory (:cd-aware) and only matters to a cmd that cares where it was invoked from — a launcher resolving a virtualenv, or uvx, which refuses to run at all when its cwd sits inside its own cache. Set cmd_cwd to pin it; a relative value resolves against the editor’s cwd.

btv.lsp.config("pylsp", {
  cmd = { "uvx", "--from", "python-lsp-server", "pylsp" },
  cmd_cwd = btv.workspace.dir(),   -- launch it here, whatever buffer attached
})

opts.priority (an integer, default 0) is the routing rank among the servers attached to one buffer, higher first. A buffer carries every server enabled for its filetype, and when several can answer the same request the default order is the config name alphabetically — stable, but an arbitrary preference. priority states the real one:

btv.lsp.config("pyright", { priority = 10, … })   -- the type-checker leads
btv.lsp.config("ruff",    { priority = 5,  … })   -- the linter follows

It decides the order the merging verbs present in — the hover float’s sections, the signature lines, the code-action chooser’s rows, the reference, symbol and goto lists, and the completion popup’s rows (plus the docs float’s sections for a candidate several servers offer) — and which server the verbs that ACT (format, rename) pick, since two servers’ edits cannot both be applied. It does not decide whether a server is asked: that is capability, and a server that doesn’t advertise the feature is skipped whatever its rank. btv.lsp.hover{ name = … } / :LspHover <server> override the rank outright for one call. A change takes effect on the next start, so btv.lsp.restart(name) applies it to a server already running.

opts.docs_format ("markdown" | "plaintext" | "rst") says how to render what this server sends as plaintext — the hover float and the completion docs float.

It exists because the protocol cannot say. LSP’s MarkupKind is a closed two-value set, plaintext or markdown; a server whose docstrings are reStructuredText has no value to name that with, so it declares plaintext, and nothing in the reply distinguishes rst from genuinely-plain text. Guessing from the content (“it starts with :param”) would silently reinterpret a plain docstring that happens to contain a *, so bemtvi does not guess — you say it:

btv.lsp.config("pylsp", { docs_format = "rst" })

Only blocks the server declared plaintext are affected: one it declared markdown is markdown whatever this says, because there the server told us. Note that python-lsp-server and jedi-language-server convert rst to markdown themselves when the docstring-to-markdown package is installed alongside them, and bemtvi asks for markdown first — so install that and you need none of this.

Defined in lsp.lua.

btv.lsp.disabled()

btv.lsp.disabled() -> whether LSP is off in this editor: an app embedding it turned it off, so no language server starts here. btv.lsp.enable, start and restart then raise E5804, so a config meant for both checks first:

if not btv.lsp.disabled() then
  btv.lsp.enable({ "lua_ls" })
end

Defined in lsp.lua.

btv.lsp.enable(names)

btv.lsp.enable(names): mark configs for auto-activation on current and future buffers and install the dispatcher. names is a string or a list. "*" is the base layer, not an activatable server, so it is rejected here.

Defined in lsp.lua.

btv.lsp.disable(names)

btv.lsp.disable(names): the inverse of enable — future buffers won’t start the named servers (already-running servers keep serving until their buffers close).

To shut a server down now, use btv.lsp.stop — on its own, disable closes the gate without touching what is already through it.

Defined in lsp.lua.

btv.lsp.stop(name, opts)

btv.lsp.stop(name[, opts]): shut down every running server with config name, detaching it from the buffers it was serving. Returns a promise that resolves with the NUMBER of servers stopped (0 when nothing by that name was running) — so a caller can report “no server named X is running” rather than a silent success.

btv.lsp.stop("gopls"):next(function(n)
  btv.notify(n > 0 and ("stopped " .. n) or "gopls was not running")
end)

A stopped server can come back: the config stays enabled, so the next buffer whose filetype matches starts a fresh one. Pass opts.disable = true to stop it and close the gate (btv.lsp.disable), which is what :LspStop does — otherwise the very next matching buffer would silently restart what you just stopped.

Defined in lsp.lua.

btv.lsp.restart(name)

btv.lsp.restart(name): tear down and respawn every running server with config name, re-starting it from the config in force NOW. A server that reads its whole configuration only at startup (efm-langserver’s languages map is the canonical case) does not see a btv.lsp.config change applied to an already-running instance; restarting it makes the grown config take effect. Each buffer bound to the server re-attaches to the fresh process. A no-op when nothing named name is running.

Defined in lsp.lua.

btv.lsp.start(cfg, opts)

btv.lsp.start(cfg, opts): the low-level, un-merged direct start (the raw LspOp::Start) for advanced/manual use — bypasses the registry. cfg is a resolved config ({ name, cmd, root_dir, filetypes, settings, … }); opts.bufnr is the buffer to attach (default the current one), opts.filetype its languageId.

Defined in lsp.lua.

btv.lsp.definition(opts)

btv.lsp.definition([opts]): jump to the definition of the symbol under the cursor, resolving with the { text, path, row, col } item list.

Asks every capable server and merges: a definition can genuinely live in two places to two servers (a generated stub and its source, a .d.ts and its implementation). Duplicates collapse, so the merged list holding ONE place still jumps — the one-server case is unchanged — and only a real disagreement opens btv.picker. opts.name narrows the round to that client.

Defined in lsp.lua.

btv.lsp.declaration(opts)

btv.lsp.declaration([opts]): the definition twin for textDocument/declaration (C headers, extern declarations). Merges across servers and opts.name routes it, exactly as btv.lsp.definition does.

Defined in lsp.lua.

btv.lsp.type_definition(opts)

btv.lsp.type_definition([opts]): jump to the definition of the type of the symbol under the cursor. Merges across servers and opts.name routes it, exactly as btv.lsp.definition does.

Defined in lsp.lua.

btv.lsp.implementation(opts)

btv.lsp.implementation([opts]): the implementations of the interface / trait method under the cursor. Merges across servers and opts.name routes it, exactly as btv.lsp.definition does.

Defined in lsp.lua.

btv.lsp.references(opts)

btv.lsp.references([opts]): the references to the symbol under the cursor, opened in btv.picker. Fans out to every capable server and merges (two servers indexing one project each know references the other does not); opts.name narrows the round to that one client’s list.

Defined in lsp.lua.

btv.lsp.hover(opts)

btv.lsp.hover([opts]): the hover documentation for the symbol under the cursor, in a float, resolving with the shown text.

Asks every attached server advertising hoverProvider and composes one float: on a pyright + ruff buffer each knows something the other doesn’t (a type, a lint rationale), and answering from one silently hides the other. With more than one contributor each section is headed # <client name>, in priority order; with one it renders bare. opts.name narrows the round to that client alone.

Defined in lsp.lua.

btv.lsp.signature_help(opts)

btv.lsp.signature_help([opts]): the signature of the call under the cursor, in a float. opts.name routes it; see btv.lsp.signature_help_autotrigger for showing it as you type instead of on demand.

A call with more than one parameter is laid out one parameter per line, with a ▸ marking the parameter the cursor is inside — drawn in the foreground of the LspSignatureActiveParameter highlight group, since a theme that spells that group as a background band would otherwise paint a lone block into the popup:

def connect(
    host: str,
  ▸ port: int = 5432,
    timeout: float = 30.0,
) -> Connection

The split reads the server’s own parameter ranges, not commas, so f(items: dict[str, int]) stays one parameter. A one-parameter call — or a server whose parameters can’t be located in the signature it sent — stays on a single line with the active parameter named in brackets.

Asks every capable server, like btv.lsp.hover. With more than one answering they are shown together in priority order, each labelled with its client — where neovim shows one at a time and binds <C-s> to cycle. bemtvi’s float is passive (the next keystroke dismisses it), so there is nothing to cycle through and no mode to leave.

Defined in lsp.lua.

btv.lsp.signature_help_autotrigger(enable)

btv.lsp.signature_help_autotrigger(enable): opt into auto-showing signature help as you type a call (after (, refreshed at each ,), instead of only on <C-k>. It is driven by the server’s advertised signatureHelpProvider.triggerCharacters, so it only fires for servers that offer signature help. enable defaults to true; pass false to turn it back off. Off unless a config opts in.

Defined in lsp.lua.

btv.lsp.format(opts)

btv.lsp.format([opts]): format the buffer, resolving nil once the edits apply.

opts.name   format with the attached server of this config name, instead of the
            first one advertising `documentFormatting`. Meaningful when a buffer
            carries several servers — `pyright` + `ruff`, `ts_ls` + `eslint` —
            where the default pick is not necessarily the formatter you want.

A name that is not attached to the buffer reports so on the message line and resolves nil; it never falls back to formatting with a different server, since silently using the wrong formatter is the failure the option exists to prevent. Any other key is rejected loudly (as in btv.lsp.code_action).

Defined in lsp.lua.

btv.lsp.code_action(opts)

btv.lsp.code_action(opts): run a code action at the cursor. Bare, it is interactive — the reply only opens the chooser menu, and the returned promise resolves once you pick an action and its edit applies (through a codeAction/resolve round-trip if the action is lazy), or nil if you cancel the chooser (Esc) — so you can, e.g., organize imports then format:

btv.lsp.code_action():next(function() return btv.lsp.format() end)

opts narrows it to a specific action, which is what makes it usable non-interactively (a format-on-save chain, say):

btv.lsp.code_action({ context = { only = { "source.fixAll" } }, apply = true })

A code action is asked about a range, and the refactor kinds (refactor.extract, refactor.inline) are exactly the ones a server offers only over a non-empty one. Called with a live Visual / Select selection — from a "v"-mode keymap, say — the selection is the range (and is consumed, like any : command acting on a selection); opts.range states one explicitly; with neither, the request is a point at the cursor. :'<,'>LspCodeAction is the ex-command form, scoped to the addressed whole lines.

btv.keymap.set({ "n", "v" }, "<leader>ca", function() btv.lsp.code_action() end)
context.only   list of code-action kinds to ask for; sent as the request's
               `context.only` AND re-applied to the reply (honoring it is a
               protocol "should", so a server that ignores it can't turn a
               one-shot into a chooser). Matching follows the LSP kind
               hierarchy: `"source.fixAll"` matches the kind `source.fixAll`
               and `source.fixAll.ruff`. An action with no kind never matches.
apply          when exactly ONE action survives the filter, apply it directly
               with no chooser. Two or more still open the chooser (there is a
               real choice to make); none echoes "No code actions available"
               and the promise resolves `nil`.
range          the range to ask about, stated outright:
               { start_row = 0, start_col = 0, end_row = 2, end_col = 0 }
               0-based rows, 0-based BYTE columns, end-EXCLUSIVE (the
               `btv.win.select_range` convention). All four fields are
               required, and it wins over both a live selection and the
               cursor — the non-interactive way to act on a computed span.
name           ask only the attached client of this config name, instead of
               merging every capable server's actions into one chooser — "run
               eslint's fixes", not ts_ls's refactors as well.

Anything else in opts (neovim’s filter, context.diagnostics, context.triggerKind) is rejected loudly rather than silently ignored — bemtvi doesn’t model it yet, and a quietly-dropped filter would silently do the wrong thing.

Defined in lsp.lua.

btv.lsp.document_symbol(opts)

btv.lsp.document_symbol([opts]): the symbols defined in the current document, opened in btv.picker (kind 16 mirrors LspReqKind::DocumentSymbol::as_u16). Resolves with the symbol { text, path, row, col } item list. opts.name lists only that client’s symbols instead of merging every capable server’s.

Defined in lsp.lua.

btv.lsp.workspace_symbol(query, opts)

btv.lsp.workspace_symbol([query], [opts]): symbols across the workspace matching query, opened in btv.picker. With no query, prompt for one via btv.ui.input (non-blocking) — an empty/cancelled prompt resolves nil. Returns a promise of the matched symbol item list. opts.name searches only that client’s index instead of merging every capable server’s; since a query is always a string, the options table may take the first argument’s place when you want the prompt and a route (btv.lsp.workspace_symbol({ name = "ts_ls" })).

Defined in lsp.lua.

btv.lsp.rename(new_name, opts)

btv.lsp.rename([new_name], [opts]): rename the symbol under the cursor. With a name, request it straight away; with none (the bare btv.keymap.set("n", "<leader>rn", btv.lsp.rename) case), prompt for it via btv.ui.input (non-blocking promise), prefilled with the symbol under the cursor, and rename on confirm. Returns a promise that resolves nil once the rename applies (or immediately, nil, on an empty / cancelled prompt).

opts.name routes the request to one attached client. A new name is always a string, so the options table may take the first argument’s place when you want the prompt and a route (btv.lsp.rename({ name = "ts_ls" })).

Defined in lsp.lua.

client:request(method, params, handler, _bufnr)

client:request(method, params, handler[, bufnr]): issue a generic LSP request; the reply runs handler(err, result) off-tick (err a message string on failure, result the server’s value on success — exactly one set). An unimplemented or uncapable method fails loud through err, never a silent no-op.

bufnr is accepted for source compatibility with neovim’s fourth argument and has no effect: the request is addressed to THIS client, and bemtvi cancels a client’s in-flight requests when the client goes away rather than per buffer.

Defined in lsp.lua.

client:notify(method, params)

client:notify(method, params): fire-and-forget a generic LSP notification.

Defined in lsp.lua.

client:supports_method(method)

client:supports_method(method) -> boolean: does this server answer method?

Read from what the server advertised at initialize (client.server_capabilities), so a config guards its own commands against a server build that lacks the feature instead of firing a request that comes back as an error the user has to interpret. A method bemtvi maps no capability for is supported — see METHOD_CAPABILITY.

Defined in lsp.lua.

client:exec_cmd(command, context, handler)

client:exec_cmd(command[, context[, handler]]): run an LSP Command ({ title, command, arguments }) — the verb behind a code action that carries a command, and the one a per-server :Lsp… command uses to drive its server’s own vocabulary (deno.cache, texlab.cleanArtifacts, _typescript.goToSourceDefinition).

Resolved in precedence order, which is the same order an applied code action takes:

1. the OFFERING config's own `commands` table   (this client's config)
2. the global `btv.lsp.commands` registry
3. `workspace/executeCommand` on this client

A client-side handler (1 or 2) is called as handler(command, ctx) and the round trip never happens — that is the whole point of registering one. Otherwise the command goes to the server and handler(err, result, ctx) receives the reply. ctx carries { client_id, bufnr, params }, so a failing handler can report which arguments failed.

context.bufnr names the buffer the command acts on (0/nil = current). Unlike neovim, a command the server did not list in executeCommandProvider is still sent: bemtvi reports whatever the server answers rather than refusing locally, since servers under-advertise this list routinely and a local refusal reads as “the command did nothing”.

Defined in lsp.lua.

btv.lsp.client_by_id(id)

btv.lsp.client_by_id(id): the handle for client id, or nil once its server has exited. The lookup behind a handler that is handed a client_id (a code-action ctx, an LspAttach autocmd’s args.data.client_id) rather than a handle.

Defined in lsp.lua.

btv.lsp.clients(filter)

btv.lsp.clients(filter): a snapshot list of active clients, narrowable by filter.bufnr (the clients attached to that buffer; 0/nil = current) and/or filter.name (the config name). Reads the mirror — no request is issued.

A buffer can have several clients attached (pyright + ruff, ts_ls + eslint): every server enabled for its filetype attaches, so a bufnr filter may return more than one. Don’t index [1] expecting “the” server — filter by name, or by what the client advertises in server_capabilities. The list is in routing order: priority descending, then config name, then client id.

Defined in lsp.lua.

btv.lsp.progress(filter)

btv.lsp.progress(filter): the $/progress work language servers are running right now — what a statusline renders as “lua_ls: Indexing 43%”.

Servers report long tasks (indexing, loading a workspace, building a crate graph) as a begin → report* → end sequence sharing a token; bemtvi folds that stream into settled records, so this returns a flat list of what is in flight, newest task last:

for _, p in ipairs(btv.lsp.progress()) do
  print(("%s: %s %s%s"):format(
    p.client_name, p.title, p.message or "", p.percentage and (" " .. p.percentage .. "%%") or ""))
end

Each item is:

client_id    the reporting client (resolve with `btv.lsp.client_by_id`)
client_name  its config name (`"lua_ls"`)
token        the task's `$/progress` token, unique within the client
title        the `begin` title (`"Indexing"`); `""` if the server never began
message      the detail line (`"3/25 files"`), or nil
percentage   0-100, or nil for an indeterminate task (show a spinner, not a bar)
cancellable  whether the server would honor a cancel for this token

filter.client_id narrows to one client; filter.bufnr (0 = current) narrows to the clients attached to that buffer, which is what a per-window statusline wants — a busy server serving some other buffer isn’t this buffer’s status.

The order is STABLE: clients ascending by id (so the longest-running server’s work comes first), and within a client the order the tasks began. A renderer showing only the first task therefore keeps showing the same one across updates.

The list is EMPTY when nothing is in flight (a finished task is removed, not left at 100%), and a server that exits mid-task takes its entries with it. Reads the mirror — no request is issued. LspProgress fires on every update, with the kind ("begin" / "report" / "end") as the autocmd pattern, so a renderer invalidates on the event and reads through here rather than polling.

Defined in lsp.lua.

btv.lsp.request(method, params, handler, target)

btv.lsp.request(method, params, handler, target): sugar resolving one of the buffer’s clients and issuing client:request. target is a bufnr (0/nil = current) or { bufnr =, name = }, where name picks the attached client by config name instead of taking the first — the first in btv.lsp.clients order (highest priority, then config name). No matching client fails loud.

Defined in lsp.lua.

btv.lsp.notify(method, params, target)

btv.lsp.notify(method, params, target): the fire-and-forget sibling of request, with the same target routing.

Defined in lsp.lua.

btv.lsp.text_document_params(bufnr)

btv.lsp.text_document_params([bufnr]) -> { uri = … }, the LSP TextDocumentIdentifier for buffer bufnr (0/nil = current) — the params of every request that names a document and nothing else.

Defined in lsp.lua.

btv.lsp.position_params(opts)

btv.lsp.position_params([opts]) -> { textDocument = { uri }, position = { line, character } }, the LSP TextDocumentPositionParams for the cursor — the params shape most requests take.

opts:

  • win — the window whose cursor to read (0/nil = current).
  • bufnr — the document to name (default: win’s buffer).
  • encoding — the position encoding to count character in. Pass the answering client’s offset_encoding; the default is whatever the buffer’s first attached server negotiated, which is only right by luck when two servers with different encodings share a buffer.
local params = btv.lsp.position_params({ encoding = client.offset_encoding })
client:request("textDocument/build", params, on_reply)

Defined in lsp.lua.

btv.lsp.locations_to_items(locations, opts)

btv.lsp.locations_to_items(locations[, opts]) -> a PROMISE of quickfix items, one per location, sorted by file then position — the bridge from a server’s Location[] / LocationLink[] to the shape btv.qf.setqflist takes ({ filename, lnum, col, end_lnum, end_col, text }, all 1-based).

btv.lsp.locations_to_items(refs, { encoding = client.offset_encoding })
  :next(function(items)
    btv.qf.setqflist({}, " ", { title = "References", items = items })
    btv.qf.open()
  end)

A promise, not a value, and that is not incidental: each item’s text is the source line it points at, and a location into a file no buffer holds means reading that file. neovim reads them synchronously; bemtvi does no blocking I/O anywhere, so the conversion is async and the editor keeps running while a 900-reference result resolves.

opts.encoding is the position encoding the server counted its columns in (default "utf-16", the protocol’s) — columns come out as bemtvi’s own bytes. Locations naming a non-file:// document are dropped: a quickfix entry addresses a path, and a deno:/jdt: URI has none.

Defined in lsp.lua.

btv.lsp.semantic_tokens.start(bufnr)

start/stop the per-buffer semantic-token paint (neovim’s start/stop verbs).

Defined in lsp.lua.

btv.lsp.semantic_tokens.stop(bufnr)

No documentation comment in the prelude.

Defined in lsp.lua.

btv.lsp.semantic_tokens.enable(enabled)

bemtvi’s editor-wide gate (default on) — neovim has only the per-buffer verbs. Off ⇒ no semantic paint anywhere; flipping back on re-requests every buffer.

Defined in lsp.lua.

btv.lsp.semantic_tokens.force_refresh(bufnr)

Drop the cached result_id and re-request the whole token set (neovim’s force_refresh) — the one operation with no readable state to model as a noun.

Defined in lsp.lua.

btv.lsp.semantic_tokens.get_at_pos(bufnr, row, col)

get_at_pos(bufnr, row, col): the decoded tokens covering the 0-based (row, col) (neovim’s vim.lsp.semantic_tokens.get_at_pos). col is a 0-based byte column; a token covers [start_col, end_col). Returns a list (possibly empty).

A buffer served by several language servers carries every capable server’s tokens, each tagged with its client_id, so one column can hold more than one token. Filter on client_id when you mean a specific server’s.

Defined in lsp.lua.

btv.lsp.inlay_hint.enable(enable, filter)

enable(enable?, filter?): flip the per-buffer inlay-hint paint. enable defaults to true; filter.bufnr (0/nil = current) targets the buffer (neovim’s modern vim.lsp.inlay_hint.enable(enable, { bufnr })).

Defined in lsp.lua.

btv.lsp.inlay_hint.is_enabled(filter)

is_enabled(filter?): whether inlay hints are on for the buffer.

Defined in lsp.lua.

btv.lsp.inlay_hint.get(filter)

get(filter?): the decoded inlay hints in a buffer (filter.bufnr, 0/nil = current), each { bufnr, client_id, inlay_hint = <decoded entry> } to match neovim’s shape. filter.range ({ start_line, end_line }, 0-based inclusive) narrows by line. Reads the mirror — no request is issued.

Every capable server’s hints are here, in line-then-column order, each tagged with the client_id that produced it — a buffer served by two servers shows both sets.

Defined in lsp.lua.

btv.lsp.apply_workspace_edit(edit, opts)

Apply an LSP WorkspaceEdit — the same path a rename reply or a server-initiated workspace/applyEdit takes, exposed for a plugin (or an btv.lsp.commands handler) holding an edit a server handed it as command arguments.

edit is the protocol shape, either form:

btv.lsp.apply_workspace_edit({
  changes = { ["file:///p/a.rs"] = { { range = r, newText = "bar" } } },
})
btv.lsp.apply_workspace_edit({
  documentChanges = {
    { kind = "create", uri = "file:///p/new.rs" },
    { textDocument = { uri = "file:///p/new.rs", version = 0 },
      edits = { { range = r, newText = "fn helper() {}\n" } } },
  },
})

documentChanges is applied in the order given, resource operations (create / rename / delete) included: those move real files, so they run off the editor tick and settle a moment later — identically in a local, daemon or browser session. Text edits land in the buffers (left modified, written by :w / :wa) exactly as a rename’s do; a create is written out for you.

opts.encoding is the position encoding the edit’s character columns are counted in ("utf-8" / "utf-16" / "utf-32"), defaulting to the protocol’s "utf-16". Pass the encoding the server that authored the edit negotiated — btv.lsp.clients({ bufnr = 0 })[1].offset_encoding — when it isn’t utf-16, or every column on a line with a multi-byte character lands in the wrong place. Anything that fails is reported loud, never silently skipped.

Defined in lsp.lua.

btv.lsp.show_document(location, opts)

Jump to an LSP Location (or LocationLink), opening its file if it isn’t already in a buffer — for a location a server hands over, e.g. as a command’s arguments:

btv.lsp.commands["rust-analyzer.gotoLocation"] = function(command)
  local loc = command.arguments and command.arguments[1]
  if loc then btv.lsp.show_document(loc) end
end

opts.encoding is the position encoding the location’s columns are in ("utf-8" / "utf-16" / "utf-32"), defaulting to the protocol’s "utf-16". A location with no usable URI is an error, not a silent no-op.

Defined in lsp.lua.

btv.lsp.foldexpr(_lnum)

btv.lsp.foldexpr is the canonical LSP foldexpr, the foldmethod=expr fold source backed by textDocument/foldingRange:

btv.bo.foldmethod = "expr"
btv.bo.foldexpr   = "v:lua.btv.lsp.foldexpr()"

bemtvi recognizes that exact reference and folds the buffer from the language server’s folding ranges (requested on open/change while the buffer wants LSP folds — see crates/bemtvi-core/src/editor/fold.rs and crates/bemtvi-server’s lsp/folding.rs). Like the tree-sitter marker it is never evaluated per line, so calling it directly is a usage error — fail loud rather than return a wrong level. vim.lsp.foldexpr is the muscle-memory alias; bemtvi recognizes both.

Defined in lsp.lua.

btv.macro

btv.macro.recording()

btv.macro.recording() [alias vim.fn.reg_recording]: the name of the register a macro is being recorded into right now, or nil when nothing is recording. The vim.fn alias returns "" instead of nil, matching vim.

Defined in state.lua.

btv.macro.executing()

btv.macro.executing() [alias vim.fn.reg_executing]: the name of the register whose macro is playing back right now, or nil when none is. Useful to skip work that only matters to a human watching — a plugin can cheaply no-op while a long 100<F3>a runs. Nested playback reports the INNERMOST register.

Defined in state.lua.

btv.macro.play(name, count)

btv.macro.play(name [, count]): play register name back as keys, count times (default 1) — the <F3>{name} command from Lua. The keys are queued and run after the current chunk settles, like any other key feed; the call returns immediately.

Defined in state.lua.

btv.mark

btv.mark.list(opts)

No documentation comment in the prelude.

Defined in state.lua.

btv.markdown

btv.markdown.render(src)

btv.markdown.render(src) -> { lines = {string,..}, highlights = { hl, .. }, fills = { fill, .. }, code = { block, .. } }

Parse src (CommonMark + GFM: tables, strikethrough, task lists) into stripped display lines with the markup syntax removed (**bold** -> bold, # Title -> Title, ``` fences dropped, - x -> • x, > q -> ▎ q, - [ ] -> ☐), plus the styling to paint over them. Each highlight is a table:

{ line = <1-based line>, col_start = <1-based char col>,
  col_end = <exclusive char col>, group = "<@markup.* capture>" }

Columns are CHARACTER columns (not bytes), so they index lines[hl.line] the way Lua string.sub / display code counts. group is a neovim @markup.* treesitter capture (@markup.strong, @markup.heading.1, @markup.raw, @markup.link.label, @markup.quote, @markup.list, …), so a colorscheme that styles treesitter markdown styles these identically.

fills are row rules — a thematic break (---) or a GFM table’s header separator: each { line = <1-based>, char = "─", group = "<@markup.*>" } means “repeat char from the end of lines[line]’s text to the right edge”. Those lines are blank, so the rule spans the row; render it as a run out to your surface’s width.

code are the fenced code blocks, each { first_line = <1-based>, last_line = <1-based, inclusive>, lang = "<fence language>"? } — lang is absent for a bare ``` fence. Use it to back a block as a code region (set line_hl_group = "@markup.raw.block" on first_line..last_line — the doc-float look) or to syntax-highlight its body in lang.

Pure and infallible: unsupported constructs still contribute their text.

Defined in markdown.lua.

btv.markdown.to_view(src, opts)

btv.markdown.to_view(src[, opts]) -> { lines = {string,..}, decor = { mark, .. } }

Turn markdown src into view-ready content: the display lines plus the decor extmarks that style them — exactly the { lines, decor } shape an btv.view.component render returns (or hand lines to view:set_lines and decor to view:set_decor). The higher-level companion to btv.markdown.render: render gives the raw pieces, to_view assembles them into something you can drop straight onto a surface.

  • Prose is rendered: the stripped lines, styled with render’s @markup.* spans (as ranged hl_group extmarks in byte columns).
  • Thematic breaks / table separators become a full-line rule glyph.
  • Fenced code blocks are LEFT as raw ``` fences (so tree-sitter can highlight them — see below), backed with a full-width line_hl_group code background, and their fence delimiter lines are hidden behind a blanking virt_text overlay so the block reads as rendered.

IMPORTANT — code-block syntax highlighting: the fences are kept on purpose. Mount the surface with filetype = "markdown" (e.g. btv.view.component{...}:mount{ filetype = "markdown", … }) so the markdown grammar’s injections highlight each fenced block in its own language. Without that filetype the code still shows (backed, fences hidden) but unhighlighted. Per-language highlighting needs that language’s grammar installed.

opts (all optional):

  • rule_width (default 80): cells a thematic-break / table-separator rule spans.
  • code_hl_group (default "@markup.raw.block"): the code-block background group.

Pure Lua over btv.markdown.render; no editor state, so it runs on every build.

Defined in markdown.lua.

btv.match

btv.match.add(group, pattern, priority, id, opts)

btv.match.add(group, pattern[, priority[, id[, opts]]]) -> id [alias vim.fn.matchadd]: register a request to highlight every match of the regex pattern with highlight group group in a window. priority orders overlapping matches (default 10); id requests a specific match id (nil / -1 auto-allocates a fresh one); opts.window targets a window other than the current one. Returns the match id.

CAVEAT: the registry is faithful — ids are allocated and stored, and btv.match.get reflects them — but bemtvi does NOT yet render these matches (there is no :match / matchadd decoration path in the core). The highlight is recorded but not painted, and the call succeeds rather than failing loud. (A previewer that uses it to tint a search term shows correct content, just un-tinted for now.)

Defined in vimfn.lua.

btv.match.addpos(group, pos, priority, id, opts)

btv.match.addpos(group, pos[, priority[, id[, opts]]]) -> id [alias vim.fn.matchaddpos]: like btv.match.add, but highlights explicit positions instead of a regex. pos is a list whose items are a line number, {lnum}, or {lnum, col, len} (1-based). Same id / priority / opts.window handling — and the same not-yet-rendered caveat as btv.match.add.

Defined in vimfn.lua.

btv.match.delete(id, win)

btv.match.delete(id[, win]) -> 0 | -1 [alias vim.fn.matchdelete]: remove the match with id id from window win (0/nil = current). Returns 0 if it existed, else -1.

Defined in vimfn.lua.

btv.match.clear(win)

btv.match.clear([win]) -> 0 [alias vim.fn.clearmatches]: remove every match from window win (0/nil = current).

Defined in vimfn.lua.

btv.match.get(win)

btv.match.get([win]) -> list [alias vim.fn.getmatches]: the registered matches of window win (0/nil = current), id-ascending. Each entry is { group, id, priority, pattern? | pos? } — the pattern form from btv.match.add, the pos form from btv.match.addpos.

Defined in vimfn.lua.

btv.messages

btv.messages.actions.clear()

btv.messages.actions.clear(): the default C action inside the :messages panel (filetype btvmessages). Clears the recorded message history and re-renders the panel in place. Rebindable like the other panel actions (btv.qf.actions.jump, btv.buffers.actions.open).

Defined in keymap.lua.

btv.modeless

btv.modeless.enable()

btv.modeless.enable() turns the session’s 'modeless' option on, the same as btv.o.modeless = true. It fails loud under the terminal client and inside a Helix session.

Defined in modeless.lua.

btv.modeless.disable()

btv.modeless.disable() turns 'modeless' off, the same as btv.o.modeless = false: the session leaves Insert the ordinary way and goes back to the vim model.

Defined in modeless.lua.

btv.modeless.key_hint(action)

btv.modeless.key_hint(action) -> the key that runs action in the modeless layout, written the platform’s way (Ctrl+Shift+Z, or ⇧⌘Z on macOS), or nil when no single key runs it. action is a btv.modeless.actions name or a function. The key is read from the live modeless keymap, so a rebound key shows its new chord: a map you set beats a default on the same key, and where the client stands in another chord for an unreachable one (btv.ui.caps().key_labels), the one shown is the chord you press. The context menu’s rows use it for their hints (see btv.contextmenu).

Defined in modeless.lua.

btv.ns

btv.ns.create(name)

nvim_create_namespace(name): create-or-get a namespace id by name (an empty / nil name mints a fresh anonymous one each call). Ids are allocated Lua-side, so the call returns synchronously; the server only ever sees the id on a mark.

Defined in api.lua.

btv.opt

opt:append(v)

No documentation comment in the prelude.

Defined in state.lua.

opt:prepend(v)

No documentation comment in the prelude.

Defined in state.lua.

opt:remove(v)

No documentation comment in the prelude.

Defined in state.lua.

opt:get()

No documentation comment in the prelude.

Defined in state.lua.

btv.option

btv.option.set(name, value, opts)

btv.option.set(name, value, opts) [alias nvim_set_option_value]: set an option in the scope its name implies. A window-local option (number/relativenumber) — or any option with an explicit opts.win — routes through btv.wo (the targeted window, else the current one); otherwise it routes through btv.bo (opts.buf, else the current buffer). The wired options reach the core; everything else lands in the observable per-scope store. (The scoped tables btv.o / btv.bo / btv.wo are the primary option API; this is the by-name funnel plugins reach for.)

opts.scope is neovim’s local/global selector over the two tiers a buffer- or window-local option carries: "local" writes only the targeted instance (:setlocal), "global" only the value a new buffer is born from (:setglobal). Any other value fails loud. With no opts.scope and no opts.buf / opts.win, this is a plain :set and writes both — matching neovim, and the reason nvim_set_option_value("tabstop", 3, {}) in a config reaches the files you open afterwards rather than only the buffer that was current while it ran. Naming a buf / win narrows it back to that instance.

Defined in state.lua.

btv.option.get(name, opts)

btv.option.get(name, opts) [alias nvim_get_option_value]: read an option from the scope its name implies (see btv.option.set), so a wired option reflects the core’s current value (default until set). opts.scope = "global" reads the :setglobal tier instead; a read with no scope is the local value (neovim’s default for the getter, where the setter’s is :set).

Defined in state.lua.

btv.panel

btv.panel.open(opts)

No documentation comment in the prelude.

Defined in btv.lua.

btv.panels

btv.panels.actions.open()

:lspanels opens a btvpanels panel listing the named panels (rows begin with the panel buffer’s number). <CR> parses that number and :b-switches to it — but the current window IS the panel window, so switch_buffer swaps it in place, showing that panel’s last content (no close, no regenerating command). Contrast btvbuffers, which targets a document and so closes the panel and lands the switch in the main window.

Defined in keymap.lua.

btv.picker

btv.picker.disabled(name)

btv.picker.disabled(name) -> whether the picker name (a source name: "files", "live_grep") is disabled in this editor. An app embedding the editor can take pickers away; btv.picker.open then raises E5803 for one, and the commands and pickers lists leave it out.

Defined in picker.lua.

btv.picker.patterns(line)

btv.picker.patterns(line) -> the glob patterns one filter box’s text expands to. line is the raw comma-separated text (btv.glob.split splits it, so a {a,b} alternation stays one pattern). A list is accepted too, so a caller passing exclude = { "target/", "*.lock" } need not join it first. Returns an empty list for empty text — i.e. “no filter”.

Each entry is expanded so that one typed pattern means the same set to btv.glob and to ripgrep’s -g, which read a bare name differently:

*.lock       ->  **/*.lock                          no `/` ⇒ matches at any depth
target       ->  **/target, **/target/**            a plain name is also a directory
vendor/      ->  **/vendor, **/vendor/**            a trailing `/` only meant "directory"
*.egg-info/  ->  **/*.egg-info, **/*.egg-info/**    …glob or not
src/**       ->  src/**                             already a glob ⇒ taken as written
btv.picker.patterns("target/, *.lock")
--> { "**/target", "**/target/**", "**/*.lock" }

Defined in picker.lua.

btv.picker.setup(opts)

btv.picker.setup(opts): cross-picker defaults for the include/exclude boxes.

  • include / exclude — the line every filterable picker opens with, as a comma-separated string or a list of globs. This is the “stop showing me target/” knob: set it once and every files / live_grep opens narrowed.
  • history — how many past lines each box keeps for <C-Up> / <C-Down> (default 20). 0 disables the history and its persistence.
  • deprioritize — the paths filterable pickers list after every other row, in the include/exclude glob syntax. It REPLACES the shipped list (btv.picker.defaults.deprioritize); {} turns deprioritizing off. Matching paths are not hidden, only pushed down — see btv.picker.source for how a source searches them second.

These are the LOW end of the precedence ladder — a line you actually typed (recalled from history) and an explicit btv.picker.open{ include = … } both win, so a default never overrides an intent expressed later. Calling it again replaces only the keys given.

btv.picker.setup({
  exclude = { "target/", "*.min.js" },
  -- the shipped list, plus test files
  deprioritize = btv.list.extend(
    btv.tbl.deepcopy(btv.picker.defaults.deprioritize),
    { "*test*" }
  ),
})

Defined in picker.lua.

btv.picker.history(field)

btv.picker.history(field) -> the recallable lines for "include" / "exclude", most recent first. The read side of what <C-Up> / <C-Down> cycle; useful for a config that wants to seed a picker from a recent filter, or just to see what is stored.

local recent = btv.picker.history("exclude")[1]   -- the last exclude line used

Defined in picker.lua.

btv.picker.forget_history()

btv.picker.forget_history(): drop every stored filter line (both boxes). The escape hatch for a history that has accumulated patterns you don’t want back.

Defined in picker.lua.

btv.picker.source(spec)

btv.picker.source { name, items = function(ctx), dynamic, confirm, preview }: register a source. items(ctx) streams candidates: it calls ctx.push(item) per result (an item is a table with a text display field, plus any data confirm or the preview needs — e.g. path / row / col) and signals completion by returning — a synchronous source just returns when its loop ends, an asynchronous one is wrapped in btv.async and returns the promise (the engine awaits it; btv is promise-only, so there is no done callback). A streaming source consumes a btv.run_stream with btv.await_each, and reaps its job on close via ctx.on_cancel. dynamic = true re-runs items on every prompt edit (live grep — the matcher is bypassed), reading the live prompt from ctx.query and the working directory from ctx.cwd; the default is a static source matched locally in Rust as you type. confirm(item) acts on the chosen item. Optional preview adds a side pane for the highlighted item: "file" shows the head of item.path, "location" shows item.path positioned at item.row / item.col (1-based). Omitted ⇒ no preview pane; per-open overridable via btv.picker.open(name, { preview = … }). Optional width / height fix the box size — a cell count (number) or a CSS-style viewport fraction string ("80vw" / "60vh" / "50%"); omitted ⇒ the default (~80vw x 60vh). The picker is never content-sized. Optional align ("top-left"…"center"…"bottom-right", default centered) + margin (a gap from the editor edges: a number — the vertical gap, horizontal sides 2x to look even — or {vertical, horizontal} / {top, right, bottom, left} / {top=, …}) place the box like a float. Optional prompt_pos = "top" (default) or "bottom" places the input above or below the results list.

For a dynamic source (which spawns per query), debounce (ms) sets the trailing delay before a query edit re-runs the source — so a fast typist spawns one search per pause, not one per keystroke, and a new keystroke cancels the in-flight search. It defaults to the global btv.picker.debounce (250), and is also overridable per open via btv.picker.open(name, { debounce = N }); 0 disables it. While that search runs, the PREVIOUS results stay on screen (the list never flashes empty); they are swapped out only when the new search’s first result arrives, or cleared if it matched nothing. The widget windows its rendering and matches incrementally, so a source can stream 100k+ candidates and stay fast; max_results (default 100000) is only a runaway-source safety bound.

An item may also be a two-column row — a location column plus content, as live_grep’s path:line:col: <the matched line> is:

ctx.push({
  head = "src/main.rs:12:5: ",  -- the location column
  text = "let x = compute()",   -- the body (the row's label is `head .. text`)
  match = { 9, 15 },            -- 1-based INCLUSIVE char range of the hit in `text`
})

The widget then fits the two columns separately: the head keeps at least 40% of the row (a long line can never squeeze the file name off), and the body is windowed around match so the hit stays on screen instead of scrolling off the right edge. match also highlights, which is what a dynamic source wants — it bypasses the fuzzy matcher, so its own match is the only one to show. Every field is optional: a plain text-only item is a single-column row that truncates path-tail-first.

A row may also lead with a short tag — a classification the head must never lose — and declare its own color, the highlight group it is painted with:

ctx.push({
  tag = "E",                     -- pinned: the elision happens after it
  head = "src/main.rs:12 ",      -- the location column
  text = "unused variable",      -- the body
  hl = "DiagnosticError",        -- the group the row paints with
})

The tag is prepended to the head ("E src/main.rs:12 ") and pinned: a head too narrow for its path elides around the tag rather than dropping it, so the letter that says what the row IS survives at any width. Without it the head elides tail-first, which is right for a pure location (live_grep keeps file:line) and wrong for a classified one.

hl is an ordinary highlight-group name, resolved against the live colorscheme each frame (a group the scheme leaves undefined simply doesn’t paint — never an error, so a source may name groups a minimal theme lacks). It colors the row’s head column when the row declares one, and the whole label when it doesn’t: the head is the part that classifies the row, and leaving the body alone keeps the fuzzy-match highlight readable over it. The selected row keeps its selection background either way.

A row pushed with low = true is deprioritized: the list shows every normal row before every low one, each group in its own order (stream order with no query, match order under one), so a source can keep vendored or generated hits findable without burying the rest. A btv.picker.scorer reorders rows within each group, never across. A low row still counts toward max_results.

filter = true gives the picker the include/exclude glob boxes (<C-g>), and is what every candidate’s path is then tested against — declare it on any source whose items are paths. A source that shells out to rg should also splice ctx.rg_globs into its argv so the tool prunes the tree instead of streaming paths the sink is about to drop. Without it the filter keys are an echoed no-op, since there would be nothing to filter on.

A filterable source’s paths are also tested against the deprioritize globs (btv.picker.setup): a match is pushed as a low row. To keep those from filling max_results before the rest are found, a source can search in passes — ctx.passes lists them in order, each a { tier, rg_globs } table:

for _, pass in ipairs(ctx.passes) do
  ctx.begin_pass(pass)              -- the sink now keeps only this pass's tier
  run_rg(pass.rg_globs)             -- the box globs plus this pass's own
end

The first pass is tier = "normal", with the deprioritize globs negated so rg skips those trees entirely; the second is tier = "low", limited to them. With an empty list there is one tier = "all" pass whose rg_globs equal ctx.rg_globs.

A tool that honours ignore files must not be handed rg_globs (or ctx.rg_globs): rg lets a positive -g override .gitignore, so the low pass’s includes would bring an ignored node_modules/ back. Such a source uses pass.rg_excludes — the pass’s negations alone — and lets the sink apply the include box and the tier; pass.keeps(path) says whether a path belongs to the pass, for a source that lists candidates first and searches only its own. During a pass the sink drops a row of the other tier — the glob set, not the tool, decides a path’s tier — so a source can also push one in-hand list once per pass. ctx.begin_pass(nil) keeps every row again. A source that ignores ctx.passes still gets its low rows listed last; it just searches them in whatever order it finds them.

resumable = false opts the source out of btv.picker.resume() (<leader>fr): opening it never overwrites the resume slot, so a transient internal picker (the cmdline file completer) can’t shadow the last user-facing one. Defaults to true.

Defined in picker.lua.

btv.picker.open(name, opts)

btv.picker.open(name[, opts]): open the picker for the registered source name. Each opts field overrides the matching field on the source (which in turn overrides the picker default):

  • width / height — a FIXED box size: a cell count (e.g. 100) or a CSS-style viewport fraction string ("80vw" / "60vh" / "50%"). The picker is never content-sized (a content-hugging box looks ragged).
  • align + margin — placement, like a float (see btv.picker.source).
  • preview — "file" / "location" / nil (no pane).
  • prompt_pos — "top" (default) / "bottom".
  • query — initial prompt text: the picker opens already filtered against it (the gen-0 run uses it instead of ""), caret at its end. Default "".
  • title — a title centered on the box’s top border (e.g. "Find Files"); nil ⇒ no title. The shipped sources set their own ("Find Files"/"Live Grep"/…).
  • multiselect — whether <Tab> marks rows for a batch action (default true); false is a single-choice picker (no marking).
  • debounce — ms before a dynamic source re-runs on a query edit; 0 off.
  • include / exclude — pre-fill the glob filter boxes, so a keymap can open a picker already scoped (btv.picker.open("files", { include = "src/**" })). Each takes a comma-separated string or a list of patterns, and is a seed: the boxes stay editable. Only meaningful on a source with filter = true.
  • filters — "open" reveals the filter rows immediately (what a pre-filtered picker usually wants), "collapsed" (the default) keeps the picker’s ordinary single-line shape with the active patterns shown as a badge.
  • layer — where a confirmed item opens: "main" crosses back to the main editor area first (so a file picked while focused in a dock lands in the editor, not the sidebar), "active" opens in the focused layer. Defaults to "active"; the shipped files/live_grep sources set "main", buffers stays "active".

Defined in picker.lua.

btv.picker.resume()

btv.picker.resume(): reopen the most-recently-closed picker (telescope’s resume), restored to exactly where the user left off — the displayed rows, prompt text, highlighted row, and multi-select marks. The server replays a frozen snapshot it captured at close (bounded to a window around the cursor), so a live-grep picker comes back with its actual previous results, not a fresh (differently-ordered) search. Re-installs btv._picker so confirm works and a later query edit re-runs the source. No-op (a gentle notice) before any resumable picker has closed.

Defined in picker.lua.

btv.picker.edit(item, mode, layer)

btv.picker.edit(item): the common confirm action — open item.path, and if the item carries a 1-based row (and optional 1-based col, as live_grep / LSP location items do), jump the cursor there.

A located jump (item.row set) goes through the btv._jump_to bridge, NOT :edit: a jump must navigate, never reload. :edit-ing the location would (a) error with E37 when the target is the current modified buffer (the LSP hands back an absolute path, but the open buffer may be relatively named) and (b) strand a duplicate buffer when that absolute path doesn’t string-match the relative one. btv._jump_to reuses the open buffer cwd-aware and skips the modified guard, so selecting a symbol in the file you’re editing just moves the cursor. A location-less item (the files source) is a plain open instead. mode is the confirm gesture: "tab"/"split"/"vsplit" (<C-t>/<C-x>/<C-v>) open in a NEW tab / split regardless of 'switchbuf' (an explicit gesture); "current" (or nil) honors 'switchbuf'.

layer is the confirm target the picker resolved ("main"/"active"), forwarded to confirm and on to here. "main" crosses back to the main editor layer before opening, so a file picked while focused in a dock lands in the editor rather than the sidebar; "active" (or nil) opens in the focused layer.

Defined in picker.lua.

btv.picker.scorer(src)

btv.picker.scorer(src): install a re-ranker over a picker’s surviving rows, or clear it with nil.

src is a string of Lua source — an expression, not a function value — because the re-ranker runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there.

Three names are in scope, and the expression returns a number — the new sort key, higher first:

label   the row's text
query   the active query
score   the native fuzzy score this row already earned

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. That is deliberate — no call shape is a clean once-per-item traversal (:s re-runs on every keystroke of the live preview, a foldexpr sees only the rows an edit touched, the picker scorer only the top survivors, foldtext is memoized), so an accumulator would be quietly wrong. Because score is handed in, a scorer nudges the native order rather than reinventing matching:

-- push test files down, keep everything else as the matcher ranked it
btv.picker.scorer([[ score - (label:find("/test") and 50 or 0) ]])

btv.picker.scorer(nil)   -- back to pure fuzzy order

It is applied to the filtered rows only, never to every candidate, and to at most the top 1000 of them — a picker streams 100k+ candidates and scoring them all would freeze the editor. Rows past that keep native order. The scorer also runs at most once per repaint, not once per streamed batch.

A scorer that errors, exceeds its deadline, or returns a non-number reports once and is then uninstalled, rather than repeating the error every frame.

Defined in picker.lua.

btv.plugins

btv.plugins.disabled()

btv.plugins.disabled() -> whether the plugin manager is off in this editor: an app embedding it turned it off, so plugins are neither declared, loaded, installed nor updated through it. Every other btv.plugins call then raises E5805; plugins under the config’s pack/*/start/ still load.

Defined in plugins.lua.

btv.plugins.on_change(fn)

M.on_change(fn) — subscribe to manager state changes (task transitions, plugin loads). Returns an unsubscribe function. The manager UI re-renders on each call.

Defined in plugins.lua.

btv.plugins.load(name)

Load a plugin by name: dependencies first, then put it on the runtimepath, source its plugin/ scripts, and run its config. Idempotent (a second call is a no-op) and cycle-safe (a _loading guard breaks dependency loops). Returns a promise resolving true when newly loaded, false if already loaded / in flight; REJECTS (loud) if the plugin is not installed.

Defined in plugins.lua.

btv.plugins.on_loaded(name, fn)

btv.plugins.on_loaded(name, fn) — run fn once name is fully loaded (its plugin/ scripts sourced and its config finished, an async config awaited). fn is called with the plugin name. Returns an unsubscribe function.

This is the hook to reach for when your config must run after someone else’s plugin — it is race-free in the direction the raw PluginLoaded event is not. Registering btv.on("PluginLoaded", { pattern = name }, …) only ever hears a load that happens later, so the same code silently does nothing when the plugin turns out to be eager and already loaded (a plain ordering flip in your config, or a spec someone else made non-lazy). on_loaded closes that: if the plugin is already loaded it calls fn immediately, otherwise it waits for the plugin’s PluginLoaded. Either way fn runs exactly once.

The plugin need not be declared yet — on_loaded before the btv.plugins{…} that declares it is fine, and is the usual shape in an init.lua.

btv.plugins.on_loaded("bemtvi-lspconfig", function()
  require("bemtvi-lspconfig").setup()
end)

For “every eager plugin is ready” (not one named plugin) use the PluginsLoaded event instead: btv.on("PluginsLoaded", {}, fn).

Defined in plugins.lua.

btv.plugins.add(specs)

Register one or more plugin specs. Accepts a single spec (string or table) or a list of them. Each is normalized and stored; a lazy spec’s triggers are armed (and its init run) immediately, while an enabled eager spec that is already on disk begins loading. Returns the name of the LAST spec added (so a single-spec add — as dependency registration uses — yields that dependency’s name).

The FIRST declaration of a name arms it; re-declaring the same name (a config re-sourced, or a shared dependency re-added through a second plugin) updates the stored spec without re-arming — re-arming would double-register every trigger (autocmd / command / keymap) and re-run init.

Defined in plugins.lua.

btv.plugins.setup_manager(opts)

btv.plugins.setup_manager{ root=, config=, system=, lockfile=, github= }: override the install root (a test points it at a temp dir), the config dir, the system-plugin dir, the lockfile path (default <config>/bemtvi-lock.json), or the shorthand URL template. Merges onto the defaults. (Named setup_manager, not setup, so it reads distinctly from a plugin’s own require("plugin").setup{} and never looks like the call that declares plugins — that is btv.plugins{...} / btv.plugins.add.)

Defined in plugins.lua.

btv.plugins.locked()

btv.plugins.locked() — a synchronous snapshot of the last read/written lockfile. Cheap (no disk); call _read_lock() first if the file may have changed underneath.

Defined in plugins.lua.

btv.plugins.lock(opts)

btv.plugins.lock() — record every managed, installed plugin’s current commit to the lockfile. Resolves the written table. Writes only when the resolved set differs from what is already on disk, so a no-op sync leaves the file (and the user’s git status) untouched.

opts.plugins (a name, or a list of names — plus their dependencies) records just those plugins, carrying every other entry over verbatim: :PluginLock alpha pins the one plugin you have finished testing without also freezing everything else at whatever it happens to sit on today.

opts.realized (internal) narrows the re-record the same way for the plugins a verb just put in place — the difference being that a realized plugin’s spec tag is ATTESTED as the pin that produced its commit, which a hand-typed :PluginLock cannot claim. See resolve_lock.

Defined in plugins.lua.

btv.plugins.restore(opts)

btv.plugins.restore() — move every managed plugin to the commit the lockfile records: the “undo the update that broke my editor” verb, and the reason recording commits is worth anything. Resolves

{ restored = { name, … }, current = { name, … }, failed = { { name, message }, … } }

so a caller can tell the three outcomes apart. A plugin already at its locked commit is current (never re-checked-out); a dev dir plugin, an uninstalled one, and one with no lock entry are skipped silently (there is nothing recorded to restore them to).

A commit that cannot be reached even after unshallowing lands in failed and the checkout is left exactly as it was — a partial rollback that CLAIMED success would be worse than none, since the user would stop looking for the real problem. The promise still resolves (so the other plugins’ outcomes survive) and :PluginRestore reports the failures loud.

opts.plugins (a name, or a list of names — plus their dependencies) rolls back only those plugins: the update that broke your editor is usually one plugin, and there is no reason to un-do the twelve that are working.

Defined in plugins.lua.

btv.plugins.install(opts)

Install every declared, enabled plugin that is missing. Returns a promise of the count installed; activates any newly-present eager plugins as it finishes.

A failure aborts the run LOUD (a half-installed tree must never look complete), but the lockfile is recorded either way: the plugins that did land are on disk, and a lockfile that no longer describes the tree is precisely the silent disagreement it exists to prevent. The rejection is re-raised after the record is written.

Only the FRESH clones are recorded (they land exactly on their pin / lock entry, so their HEAD is evidence). A plugin that was already present is left as the lockfile has it: install does not check where it sits, so overwriting its entry from the checkout would throw away a lockfile the user just pulled in — the very thing the next sync pass is about to realize.

opts.plugins (a name, or a list of names) installs only those — plus their dependencies, since a plugin whose dependency is missing does not load. Every other declared plugin is left exactly as it is, lockfile entry included.

Defined in plugins.lua.

btv.plugins.update(opts)

Fast-forward every installed, unpinned plugin, ADVANCING past the lockfile (and re-recording it) — the cargo update half. opts.advance = false instead reproduces the lock, moving a locked plugin onto (or leaving it at) the recorded commit; that is what sync passes. Returns a promise of the count of plugins whose checkout MOVED. Records the lock even when a plugin fails, for the reason install does.

opts.plugins (a name, or a list of names — plus their dependencies) updates only those: the usual reason to update at all is one plugin whose fix you are waiting for, and pulling in eleven other people’s changes at the same time is how a working editor becomes a bisect. Every other plugin keeps its checkout AND its lockfile entry.

Defined in plugins.lua.

btv.plugins.sync(opts)

Install the missing, then update the rest — the one-shot “make the world match my declarations”. Returns a promise of the count NEWLY INSTALLED (so a UI can tell whether a fresh clone landed and prompt to restart). A lockfile makes this the “realize my declared state” verb, NOT an update: it installs what is missing at the locked commits, MOVES an existing checkout onto them when it has drifted (pull a colleague’s newer lockfile and sync, and you get the tree it names — reproducing the lock only for clones that happen to be missing would honour the file by accident of what is on disk), realizes any commit/tag pin the spec has changed, and fast-forwards only what nothing pins. Advancing PAST the lock is :PluginUpdate’s job — if sync advanced, the lockfile would be honoured for the instant between the install and the update pass and then thrown away, which is no lockfile at all. (Locking is inherited from the two halves — install records the fresh clones and update records the rest, after convergence. No third write here.)

opts.plugins (a name, or a list of names — plus their dependencies) realizes only those, by handing the same scope to both halves.

Defined in plugins.lua.

btv.plugins.clean(opts)

Remove cloned plugin directories under the install root that no plugin declares (a dev dir plugin lives outside the root and is never touched). Returns a promise of the removed names.

opts.plugins (a name, or a list of names) instead removes EXACTLY those clones, whether or not a spec still declares them — the “this checkout is wrong, give me a fresh one” verb (remove, then install). Unlike every other scoped verb it does not pull in dependencies: deleting a checkout you did not name is destruction by inference. A local dir plugin fails LOUD instead of being deleted — that is your own working tree, not a managed clone. A plugin still declared keeps its btv.shada.plugin data (it is coming back); only a name nothing declares any more has its namespace forgotten.

Defined in plugins.lua.

btv.plugins.list_system()

A synchronous snapshot of the system tier: { { name=, dir= }, … }, sorted by name.

Defined in plugins.lua.

btv.plugins.system(spec)

btv.plugins.system(spec) — inject a plugin into the system tier: clone/copy it into the system dir (via the local-always seam) and load it into the current session, so it takes effect now AND is re-seeded by the client into every future session (the VS Code “install a connector” move). spec is a normal plugin spec (string shorthand / table). Returns a promise of the plugin’s name. Callable form for init.lua.

Defined in plugins.lua.

btv.plugins.promote(name)

btv.plugins.promote(name) — promote an already-declared managed plugin into the system tier: clone its on-disk checkout into the system dir and register it, so it persists into every future session. Loads the system copy now unless the plugin is already loaded this session. REJECTS (loud) for an unknown plugin. Returns a promise of the name.

Defined in plugins.lua.

btv.plugins.recommend(specs)

Register the recommended set (replacing any prior). A recommended spec may carry every normal field EXCEPT a config/init function — those must be a STRING of Lua source, because the set is serialized into the user’s plugins.lua and a function cannot be written out. Fails loud on a function hook rather than silently dropping it.

Defined in plugins.lua.

btv.plugins.bootstrap()

First-run flow: on a fresh setup (the user has declared no plugins of their own, a recommended set exists, and we have not asked before), open the WELCOME view — a floating checklist (prelude/plugins_ui.lua → M.ui.welcome) explaining that bemtvi ships minimal and offering the recommended set pre-ticked, each item untickable. The chosen subset is written to the user’s config and installed; an empty / skipped choice does nothing. Returns a promise. Wired to VimEnter, and callable directly. Re-entrant-safe (_prompting guard) and asks only once (the marker, written before showing the view so a cancel still never nags again).

Defined in plugins.lua.

btv.plugins.list()

A synchronous snapshot of the declared set (declaration order): each { name, url, dir, lazy, pinned, loaded, locked }. Cheap (no disk) — loaded reflects the in-memory load state and locked the commit the LAST-READ lockfile records (nil when unlocked). Use status() for the disk-checked installed / head / drifted.

Defined in plugins.lua.

btv.plugins.status()

Like list() but disk-checked — a promise, since the reads are off-tick. Adds installed, the checked-out head commit, and drifted (the checkout is at a different commit than the lockfile records). Drift is the signal that the tree no longer matches what the config’s lockfile promises, so :PluginList and the dashboard can say so instead of leaving the user to compare shas by hand.

Reads the lockfile from disk first, so drift is computed against the CURRENT file (the user may have just checked out a different one) rather than whatever was last read.

Defined in plugins.lua.

btv.pos

btv.pos.get(expr)

btv.pos.get(expr) [alias vim.fn.getpos]: a position as { bufnr, lnum, col, off } (1-based lnum/col, off always 0). expr is one of:

"."     the cursor
"v"     the fixed end of the live Visual selection (the cursor is the other
        end); outside Visual mode, the cursor
"$"     the last line, with col 0
"'<"    start of the last Visual selection (after leaving Visual mode)
"'>"    end of the last Visual selection
"'x"    mark x: a-z, A-Z, 0-9, or one of " ' ` . ^ [ ]

A buffer-local mark reports bufnr 0; a file mark (A–Z, 0–9) reports the buffer it points into. A mark that is not set yields { 0, 0, 0, 0 }, as in vim. Any other expression ("w0", "w$", an invalid mark name) raises an error naming it. Reads the server-pushed cursor, selection-anchor and marks mirrors. To read a visual selection from a Visual-mode mapping, pair "v" with "."; "'<" / "'>" only update once the selection is left.

Defined in vimfn.lua.

btv.pos.set(expr, pos)

btv.pos.set(expr, pos) [alias vim.fn.setpos]: move the cursor or set a mark. pos is { bufnr, lnum, col, off } (1-based lnum/col; off is ignored), and the call returns 0. expr is one of:

"."     the cursor (in the current window; `bufnr` is ignored)
"'x"    mark x: a-z, A-Z, or one of ' ` " [ ] < >

A mark goes into buffer bufnr, 0 meaning the current one; an uppercase mark remembers that buffer as its file. The column is clamped to the line. The mark is written in order with btv.cmd, so btv.cmd("normal! gv") after setting "'<" and "'>" selects the new range. Any other expression ("'.", a numbered mark, "w0") raises an error naming it, and so does a line outside the buffer.

Defined in vimfn.lua.

btv.process

process:write(data)

Write raw bytes to the child’s still-open stdin. Accepts a string or a list of strings (concatenated). A no-op once the child has exited.

Defined in process.lua.

process:kill()

Terminate the child (its on_exit still fires with code -1).

Defined in process.lua.

btv.process.open(spec)

btv.process.open { cmd, args, cwd, env, on_stdout, on_stderr, on_exit } -> handle. Spawns a duplex child and returns a handle with :write(data) and :kill(). The callbacks fire on the editor thread (they may queue effects — extmarks, view renders — like any btv callback): on_stdout(chunk) / on_stderr(chunk) per raw batch, on_exit(code) exactly once.

Defined in process.lua.

btv.pum

btv.pum.visible()

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.qf

btv.qf.actions.jump()

btv.qf.actions.jump(): the default <CR> action in the quickfix window — jump to the entry on the cursor’s line. Bound buffer-locally as a default map by the FileType qf autocmd, so a user <CR> map overrides it (the rebindable panel-action pattern; cf. btv.buffers.actions.open).

Defined in keymap.lua.

btv.qf.getqflist(what)

btv.qf.getqflist([what]) -> list | dict [aliases btv.getqflist / vim.fn.getqflist]: the quickfix list. With no argument (or a non-table), returns the array of entry dicts (a shallow copy of the btv._qflist mirror the server pushes). With a what dict, returns a dict carrying only the requested keys (title / items / size).

Defined in vimfn.lua.

btv.qf.setqflist(list, action, what)

btv.qf.setqflist(list[, action[, what]]) -> 0 [aliases btv.setqflist / vim.fn.setqflist]: populate the quickfix list. list is an array of entry dicts; action is " " (new / the default), "a" (append), or "r" (replace current). what may instead carry lines (raw output parsed against efm), items, title, or efm. The work happens server-side (a queued op), so the parsed result is visible to btv.qf.getqflist() only after the server drains the op — read it on a later tick.

Defined in vimfn.lua.

btv.qf.getloclist(winnr, what)

btv.qf.getloclist(winnr[, what]) -> list | dict [aliases btv.getloclist / vim.fn.getloclist]: the location list of window winnr (0 = current window; otherwise an bemtvi window id, NOT vim’s 1-based window number). Same return shape as btv.qf.getqflist; an empty list when the window has none.

Defined in vimfn.lua.

btv.qf.setloclist(winnr, list, action, what)

btv.qf.setloclist(winnr, list[, action[, what]]) -> 0 [aliases btv.setloclist / vim.fn.setloclist]: populate the location list of window winnr (0 = current window; otherwise an bemtvi window id). Same list/action/what semantics as btv.qf.setqflist, only scoped to a window. Queued server-side like setqflist.

Defined in vimfn.lua.

btv.qf.send_to_loclist(list, opts)

send_to_loclist: results -> a (new) location list. add_to_loclist: append.

Defined in vimfn.lua.

btv.qf.add_to_loclist(list, opts)

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.qf.send_to_qflist(list, opts)

send_to_qflist: results -> the global quickfix list. add_to_qflist: append.

Defined in vimfn.lua.

btv.qf.add_to_qflist(list, opts)

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.qf.list(name, items, opts)

btv.qf.list(name, items[, opts]): create or replace the named list name from items (an array of entry dicts, the same shape setqflist takes), repainting its tab if open. Does NOT open or focus the tab — call btv.qf.show(name) for that.

  • opts.title (string) — the list title shown in the dock tab (defaults to name).
  • opts.action (string) — "r" (default, replace in place) / " " (push a new list onto the stack) / "a" (append to the current list).

Returns the name.

Defined in vimfn.lua.

btv.qf.show(name)

btv.qf.show(name): open or focus the named list name’s bottom-dock tab — the clean, window-independent reopen (no set_current / on_next_tick dance; the open is sequenced server-side after any btv.qf.list queued in the same tick). Showing a name with no items yet opens an empty tab. Returns the name.

Defined in vimfn.lua.

btv.qf.drop(name)

btv.qf.drop(name): forget the named list name — close its dock tab if open and remove its contents from the editor. A no-op for a name that was never used.

Defined in vimfn.lua.

btv.qf.open(height)

btv.qf.open([height]) [wraps :copen]: open the quickfix window, optionally height rows tall.

Defined in vimfn.lua.

btv.qf.close()

btv.qf.close() [wraps :cclose]: close the quickfix window.

Defined in vimfn.lua.

btv.qf.next()

btv.qf.next() [wraps :cnext]: jump to the next entry in the quickfix list.

Defined in vimfn.lua.

btv.qf.prev()

btv.qf.prev() [wraps :cprev]: jump to the previous entry in the quickfix list.

Defined in vimfn.lua.

btv.qf.older(count)

btv.qf.older([count]) [wraps :colder]: go to an older quickfix list in the stack (count lists back, default 1).

Defined in vimfn.lua.

btv.qf.newer(count)

btv.qf.newer([count]) [wraps :cnewer]: go to a newer quickfix list in the stack (count lists forward, default 1).

Defined in vimfn.lua.

btv.qf.lopen(height)

The location-list counterparts of the quickfix window / navigation wrappers above — thin wrappers over the :l* ex-commands, acting on the CURRENT window’s location list rather than the global quickfix list. btv.qf.lopen([height]) [wraps :lopen]: open the location-list window, optionally height rows tall.

Defined in vimfn.lua.

btv.qf.lclose()

btv.qf.lclose() [wraps :lclose]: close the location-list window.

Defined in vimfn.lua.

btv.qf.lnext()

btv.qf.lnext() [wraps :lnext]: jump to the next entry in the location list.

Defined in vimfn.lua.

btv.qf.lprev()

btv.qf.lprev() [wraps :lprev]: jump to the previous entry in the location list.

Defined in vimfn.lua.

btv.qf.lolder(count)

btv.qf.lolder([count]) [wraps :lolder]: go to an older location list in the window’s stack (count lists back, default 1).

Defined in vimfn.lua.

btv.qf.lnewer(count)

btv.qf.lnewer([count]) [wraps :lnewer]: go to a newer location list in the window’s stack (count lists forward, default 1).

Defined in vimfn.lua.

btv.qf.text(src)

btv.qf.text(src): install a custom rendering for the rows of the quickfix window, or restore the built-in one with nil. vim spells this 'quickfixtextfunc'.

src is a string of Lua source — an expression, not a function value — because it runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there.

Two names are in scope, and the expression returns the row’s text:

item    the entry, as a table (the keys `btv.qf.getqflist()` returns)
idx     its 1-based position in the list

item carries filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid. The built-in rendering is vim’s file|lnum col N| message:

-- the message first, the location trailing
btv.qf.text([[ item.text .. "  (" .. item.filename .. ":" .. item.lnum .. ")" ]])

-- number the rows, and lead with the severity letter
btv.qf.text([[ idx .. ". [" .. (item.type ~= "" and item.type or "-") .. "] " .. item.text ]])

btv.qf.text(nil)   -- back to the default rendering

One row per entry is a hard rule — the row index is the entry index for <CR>, :cc and the severity colouring — so a newline inside a rendered row is flattened to a space, exactly as the default flattens a multi-line message.

It applies to the quickfix list, every named list (btv.qf.list) and every window’s location list, and installing or clearing it re-renders the ones that are open. The severity colouring (a row tinted by its type) is unaffected: it paints whole rows, whatever text they hold.

The sandbox is stateless: nothing carries from one call to the next, and assigning a global raises. An expression that errors, exceeds its deadline, or returns a non-string reports once and is then uninstalled, with every open list restored to the default rendering.

Defined in vimfn.lua.

btv.qf.parse(src)

btv.qf.parse(src): parse the lines of build output into quickfix entries with a Lua block instead of 'errorformat', or restore 'errorformat' with nil.

src is a string of Lua source — a function body, not a value — because it runs in the bounded compute sandbox: a second, pure VM with a wall-clock deadline, no editor state and no btv.*. A closure cannot cross between VMs, so the source crosses instead and is compiled there. It is a body rather than a single expression because a parser matches and then builds a record, so it ends in its own return.

Two names are in scope, and the block returns one entry table (or nil):

line    the output line to parse
lnum    its 1-based position in the output, not in any file
btv.qf.parse([[
  local file, ln, col, msg = line:match("^(%S+)%((%d+),(%d+)%): (.*)$")
  if not file then return nil end
  return {
    filename = file, lnum = tonumber(ln), col = tonumber(col), text = msg,
    type = msg:find("^error") and "E" or "W",
  }
]])

btv.qf.parse(nil)   -- back to 'errorformat'

The entry may set filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid — the keys btv.qf.getqflist() returns. Any other key is an error, not an ignored one: a parser that writes line where it meant lnum would otherwise quietly produce entries that cannot be jumped to. valid defaults to vim’s rule (an entry with a line number is jumpable).

Returning nil declines the line, which is not the same as dropping it: like 'errorformat', a line no pattern matched is kept as an invalid entry carrying its raw text, which is what makes :copen show a build’s prose alongside its errors.

It applies wherever 'errorformat' does — :cbuffer, :cfile, :cexpr, setqflist{ lines = … }, :make, :grep — and replaces that pass rather than layering on it, because two parsers disagreeing about one line has no sensible answer.

What it cannot do. The sandbox is stateless: the block sees one line and carries nothing between calls. That rules out errorformat’s multi-line entries (%A/%C/%Z) and its directory stack (%D/%X) — exactly the features that need an accumulator. 'errorformat' stays for those, and stays the default. The trade is readability everywhere else, and output no errorformat can describe at all.

A block that errors, exceeds its deadline, or returns a malformed entry reports once, is uninstalled, and 'errorformat' parses the input instead.

Defined in vimfn.lua.

btv.reg

btv.reg.recording()

No documentation comment in the prelude.

Defined in state.lua.

btv.reg.executing()

No documentation comment in the prelude.

Defined in state.lua.

btv.reg.set(name, value, options)

btv.reg.set(name, value [, options]) [alias vim.fn.setreg]: write a register. name "" / "@" means the unnamed register ". value is a string (charwise) or a list of strings (one per line, linewise). options is a string of flags: c/v charwise, l/V linewise, a/A append; b / <C-v> (blockwise) is rejected (no visual-block mode yet). An uppercase register name also appends. A string ending in a newline is linewise when no type flag forces otherwise. Returns 0 on success (1 is vim’s failure code, but the failure cases here raise instead). The write is queued for the server (btv._set_reg) and write-through the mirror so a getreg later in the same chunk is consistent.

Defined in state.lua.

btv.reg.get(name)

btv.reg.get(name [, …]) [alias vim.fn.getreg]: the text stored in register name ("" / "@" / nil = the unnamed register), or "" when the register is empty / unset. Reads the btv._registers mirror the server refreshes before this chunk; an uppercase name reads its lowercase store, matching vim.

Defined in state.lua.

btv.reg.gettype(name)

btv.reg.gettype(name) [alias vim.fn.getregtype]: "v" (charwise), "V" (linewise), or "" for an unknown register. An empty / unset (but valid) register is charwise -> "v", matching vim. Blockwise ("<C-v>{width}") waits on visual-block mode.

Defined in state.lua.

btv.rst

btv.rst.render(src)

btv.rst.render(src) -> { lines = {string,..}, highlights = { hl, .. }, fills = { fill, .. }, code = { block, .. } }

Parse src as reStructuredText into stripped display lines with the markup syntax removed (**bold** -> bold, a section underline consumed, - x -> • x, :param path: laid out as an aligned column), plus the styling to paint over them. The return shape is identical to btv.markdown.render’s, so one piece of layout code renders either format:

{ line = <1-based line>, col_start = <1-based char col>,
  col_end = <exclusive char col>, group = "<@markup.* capture>" }

Columns are CHARACTER columns (not bytes). group is a neovim @markup.* treesitter capture, the same set the markdown renderer paints, so a colorscheme styles both identically. fills are row rules (a transition), and code are the code blocks: { first_line = <1-based>, last_line = <1-based, inclusive>, lang = ? }.

lang is where rst has the advantage: a .. code-block:: python directive declares its language and a >>> doctest block is python by definition, so those blocks come back ready to syntax-highlight, where a markdown rendering of the same docstring recovers nothing.

The docstring dialect, not the whole of Docutils: section titles, transitions, paragraphs, bullet/enumerated lists, definition lists, field lists (:param x:, with :type x: folded into its row the way Sphinx reads it), literal blocks, doctest blocks, line blocks, block quotes, admonitions and code directives, plus inline literals / strong / emphasis / interpreted text / references. Anything else degrades to showing its own text: an unknown directive renders its name and body, a table renders as the ASCII art it already is.

Pure and infallible: unsupported constructs still contribute their text.

Defined in rst.lua.

btv.screen

btv.screen.row()

No documentation comment in the prelude.

Defined in api.lua.

btv.screen.col()

No documentation comment in the prelude.

Defined in api.lua.

btv.screen.pos(win, lnum, col)

btv.screen.pos(win, lnum, col) [alias vim.fn.screenpos]: the 1-based screen cell {row, col, curscol, endcol} of buffer position [lnum, col] in window win (0/current). A completion plugin reads it to anchor its completion menu at the cursor. Computed from the window mirror’s origin + scroll: row counts down from the top text line; col is the display width of the line up to col, shifted by the horizontal scroll. INCOMPLETE: inherits btv.win.screenpos’s tiled-origin approximation ({1,1}) and does not add a number/sign textoff; curscol/endcol collapse onto col. Faithful for the common single-window, gutterless case.

Defined in api.lua.

btv.session

btv.session.reconnect(spec)

btv.session.reconnect(spec) — swap the current client onto spec. spec is: { transport = { kind = “spawn”, argv = { “ssh”, “host”, “bemtvi”, “–daemon” } } – structured, no shell or { kind = “spawn”, cmd = “ssh host bemtvi –daemon” } – sh -c line or { kind = “quic”, addr = “bemtvi://host:port/token?cert=…” }, config_source = “remote” | “local”, – optional; default “remote” (§D) keep_buffers = true | false, – optional; default false } config_source (§D) picks whose config the swapped session runs, independent of the transport: "remote" (default) materializes the daemon’s config + plugins locally and keeps shada on the daemon; "local" keeps THIS machine’s init.lua / plugins and the daemon backs only the fs / process / LSP seams (the dev-container shape — local editor settings, the container’s toolchain). Either way the client-owned system-plugin tier (§A) is re-seeded, so a connector persists across the swap regardless. "merged" (local UI config layered over the remote’s project config) is RESERVED but not implemented yet — it fails loud rather than silently picking one side.

Prefer argv (a list run WITHOUT a shell, so nothing can be smuggled through shell metacharacters); cmd is the sh -c convenience for ssh/docker one-liners (as safe as its origin — this runs in the LOCAL VM, which already has arbitrary execution). The client carries the system-plugin tier (§A) forward across the swap and feeds the command into the reconnecting dialer so a dropped link re-runs it. Fails LOUD on a malformed spec (a bad transport is a bug, not a silent no-op); a provisioning / spawn FAILURE surfaces later and leaves the current session intact (the client resolves fully, then swaps). Returns nothing.

Defined in btv.lua.

btv.shada

btv.shada.plugin(dev_namespace)

btv.shada.plugin() -> an isolated, cross-session key/value store for the calling plugin. The handle persists into the current shada store (global, workspace, or remote — whichever this session uses), walled off from the core registers / marks / history and keyed apart from every other plugin’s namespace.

The namespace is assigned, not chosen: it is derived from where the calling code lives (its runtimepath / plugin directory), so a plugin can persist its own data and can never name — and so never read or clobber — another plugin’s slice. Calling it from anywhere in a plugin’s files resolves to that one plugin’s namespace. Code in the user’s config maps to the reserved user namespace.

It is the plugin’s opt-in: only a plugin that calls this gets shada storage. Values may be any JSON-able Lua value (table / string / number / boolean); get returns a fresh copy. Persistence rides the ordinary shada cadence (the debounced checkpoint + the clean-exit flush); with shada disabled the store still works in memory for the session but isn’t written, exactly like registers and marks.

A namespace is capped at 1 MiB (of serialized key+value bytes) so one plugin can’t bloat the shared store; a set that would cross the cap raises (the prior value is left intact). Keep it to small, structured state — settings, a recent list, a cursor table — not bulk data.

local store = btv.shada.plugin()       -- no argument: namespace is assigned
store:set("recent", { "a.txt", "b.txt" })
local recent = store:get("recent")    -- the table back, or nil
store:delete("recent")
for _, k in ipairs(store:keys()) do … end
store:clear()

dev_namespace is an escape hatch for a context whose code attributes to no runtimepath entry — a bare :lua, an RPC exec_lua, a test, or a deferred/async callback whose stack no longer carries the plugin chunk — where the namespace can’t be derived, so an explicit one is required.

Defined in stdlib.lua.

btv.shada.namespaces()

btv.shada.namespaces() -> a sorted list of every plugin namespace currently stored (after a shada load that is all persisted namespaces, not just the ones a plugin opened this session). The audit primitive: a user can see what plugins have stowed away, and the package manager forgets a removed plugin’s namespace on :PluginClean.

Defined in stdlib.lua.

btv.shada.forget(namespace)

btv.shada.forget(namespace) -> drop a whole namespace’s stored data (it stops being written at the next shada flush). The cross-session counterpart of a handle’s :clear(), but addressed by name — for pruning an orphan (e.g. an uninstalled plugin’s leftovers). Fails loud on a non-string / empty name.

Defined in stdlib.lua.

btv.snippet

btv.snippet.setup(opts)

btv.snippet.setup { jump_next = "<Tab>", jump_prev = "<S-Tab>" } Configure the tabstop-jump keys. Either may be a string or a list of strings; an omitted key keeps its default (<Tab> / <S-Tab>).

Defined in snippet.lua.

btv.snippet.add(ft, list)

btv.snippet.add("rust", { { trigger = "fn", body = "fn ${1:name}() {\n\t$0\n}" }, … }) Register snippets for a filetype. trigger and body are required strings. Re-adding a trigger already registered for ft replaces that snippet’s body. Function bodies (dynamic / context-aware) are not supported yet and error loud rather than silently dropping the snippet (deferred to a later phase).

Defined in snippet.lua.

btv.snippet.expand(body)

btv.snippet.expand("for ${1:i} = 1, ${2:n} do\n\t$0\nend") Expand a snippet body at the cursor immediately, entering Insert mode at the first tabstop. Errors loud on a malformed / unsupported body.

Defined in snippet.lua.

btv.socket

socket:write(data)

Send raw bytes over the connection (a string, or a list of strings concatenated). A no-op once the connection has closed.

Defined in process.lua.

socket:close()

Close the connection (its on_close still fires).

Defined in process.lua.

btv.socket.connect(spec)

btv.socket.connect { host, port, on_connect, on_data, on_close } -> handle. Opens a TCP client connection and returns a handle with :write(data) and :close(). The callbacks fire on the editor thread: on_connect() once connected, on_data(chunk) per raw inbound batch, on_close(err) exactly once (err set on a connect/I-O failure).

Defined in process.lua.

btv.statusline

btv.statusline.segment(spec)

btv.statusline.segment { name = "git", events = { "BufEnter", "DirChanged" }, render = function(ctx) return { { text = " main", hl = "StatusGit" } } end } Register a custom segment. render(ctx) (ctx = { buf, win, focused }) returns a list of cells { text = "…", hl = "Group"? }, or nil/empty for nothing. events (optional) are standard autocmd event names that invalidate it.

Defined in statusline.lua.

btv.statusline.invalidate(name)

btv.statusline.invalidate(name): mark a custom segment dirty so the server re-renders it (per window) when the current input settles. The async pattern: a job finishes, caches its data, then invalidates its own segment. Deferring to the server (rather than rendering inline) means a re-render always runs against a fresh window mirror — see btv._statusline_invalidate.

Defined in statusline.lua.

btv.statusline.setup(opts)

btv.statusline.setup { left = { "mode", "filename" }, right = { "diagnostics", "location" } } Activate a segment layout. While the global layout is active it takes precedence over the 'statusline' %-format for every window without its own override.

opts.win (0 = current) sets a window-local layout that overrides the global one for that window. opts.format = true opts a window back to the 'statusline' %-format even while a global segment layout is active (the per-region mix); for the global target it clears the global layout.

opts.separator is the connector painted before, between, and after the segments of each half (default " ", in the base StatusLine look). Pass "" to disable it — a powerline / themed statusline manages its own padding and section arrows and wants a seamless coloured bar with no unstyled gaps.

Defined in statusline.lua.

btv.statusline.reset(win)

btv.statusline.reset([win]): drop a window-local override (0 = current window) so the window re-inherits the global layout. With no win it clears the global layout, returning every inheriting window to the 'statusline' %-format.

Defined in statusline.lua.

btv.str

btv.str.trim(text, mask, dir)

No documentation comment in the prelude.

Defined in stdlib.lua.

btv.str.startswith(s, prefix)

btv.str.startswith(s, prefix) [alias vim.startswith]: does s begin with prefix?

Defined in stdlib.lua.

btv.str.endswith(s, suffix)

btv.str.endswith(s, suffix) [alias vim.endswith]: does s end with suffix?

Defined in stdlib.lua.

btv.str.split(s, sep, opts)

btv.str.split(s, sep, opts) [alias vim.split]: split s on sep.

Defined in stdlib.lua.

btv.str.to_list(s, _utf8)

btv.str.to_list(s[, utf8]) [alias vim.fn.str2list]: the codepoint of each character in s, as a list of numbers (str2list("AB") == { 65, 66 }). bemtvi is always UTF-8, so the utf8 flag is accepted and ignored (the result is the same either way). A plugin’s key parser round-trips a keymap’s lhs through this and nr2char.

Defined in stdlib.lua.

btv.str.from_char(nr, _utf8)

btv.str.from_char(nr[, utf8]) [alias vim.fn.nr2char]: the string for codepoint nr (nr2char(65) == "A"). The inverse of one btv.str.to_list element; bemtvi is always UTF-8 so utf8 is accepted and ignored.

Defined in stdlib.lua.

btv.str.chars(s, _skipcc)

btv.str.chars(s[, skipcc]) [alias vim.fn.strchars]: number of characters (codepoints) in s. INCOMPLETE: skipcc (skip composing characters) is ignored — every codepoint counts, since bemtvi doesn’t classify combining marks.

Defined in stdlib.lua.

btv.str.displaywidth(s, col)

btv.str.displaywidth(s[, col]) [alias vim.fn.strdisplaywidth]: the display cells s occupies, expanding tabs to the next tabstop boundary and counting wide chars as two. col is the starting screen column used for tab-stop math (default 0); the return value is the width of s itself (cells consumed beyond col). INCOMPLETE: tabs expand on a fixed tabstop of 8, not the current buffer’s 'tabstop'.

Defined in stdlib.lua.

btv.str.utfindex(s, a, b)

No documentation comment in the prelude.

Defined in stdlib.lua.

btv.str.byteindex(s, a, b)

No documentation comment in the prelude.

Defined in stdlib.lua.

btv.str.charpart(s, start, len)

btv.str.charpart(s, start[, len]) [alias vim.fn.strcharpart]: the substring of s starting at character index start (0-based), spanning len characters (default: to the end). A negative start drops that many leading characters off the count (vim’s behavior) and clamps the start to 0.

Defined in stdlib.lua.

btv.str.trans(s)

btv.str.trans(s) [alias vim.fn.strtrans]: s with unprintable characters shown as printable text — control chars 0x00–0x1F as ^@…^_, 0x7F as ^? — matching vim, so a key label built from raw bytes displays readably. Multibyte UTF-8 is left intact.

Defined in stdlib.lua.

btv.str.substitute(str, pat, sub, flags)

btv.str.substitute(str, pat, sub, flags) [alias vim.fn.substitute]: a real vim-regex substitution, backed by the Rust engine (btv._substitute) so plugins that rely on vim’s magic dialect + replacement syntax (\(\), \{-}, &, \1, \U…\E, …) get the same result neovim gives. This is a DIFFERENT dialect from bemtvi’s / search (canonical regex); the divergence is intentional and lives in the compat layer. An invalid / unsupported pattern raises (fail loud).

Defined in stdlib.lua.

btv.tabpage

btv.tabpage.current()

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.list()

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.is_valid(tab)

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.number(tab)

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.wins(tab)

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.win(tab)

No documentation comment in the prelude.

Defined in api.lua.

btv.tabpage.set_current(tab)

btv.tabpage.set_current(tab) [alias nvim_set_current_tabpage]: make tab the active tab page (0/nil = the current one, which is a no-op). The lone tab MUTATION in the API — every other nvim_tabpage_* call is a read off the btv._tabs mirror. The switch is queued for the server (btv._set_current_tab) and written through the mirror, so a read later in the same chunk already agrees. An unknown tab id fails loud rather than silently doing nothing.

Defined in api.lua.

btv.tabpage.nr(arg)

btv.tabpage.nr([arg]) [alias vim.fn.tabpagenr]: the current tab page’s 1-based number, or with "$" the number of tab pages — the tab analogue of winnr(). Backs the loop in a custom 'tabline' (for i = 1, tabpagenr('$')). Resolves from the btv._tabs / btv._tab_order mirror the server pushes before evaluating the tabline.

Defined in api.lua.

btv.tabpage.buflist(nr)

btv.tabpage.buflist(nr) [alias vim.fn.tabpagebuflist]: the list of buffer numbers shown in tab page nr (1-based; nil/0 is the current tab), one per window in that tab — what a custom 'tabline' label reads to find the tab’s active file. Reads the tab mirror’s per-window buffers (parallel to windows), which the server fills for EVERY tab — unlike the global window mirror, which only carries the current tab, so nvim_win_get_buf would resolve an inactive tab’s window to the current buffer.

Defined in api.lua.

btv.tbl

btv.tbl.is_empty(t)

btv.tbl.is_empty(t) [alias vim.tbl_isempty]: does t have no entries?

Defined in stdlib.lua.

btv.tbl.contains(t, value)

btv.tbl.contains(t, value) [alias vim.tbl_contains]: is value one of t’s values?

Defined in stdlib.lua.

btv.tbl.keys(t)

btv.tbl.keys(t) [alias vim.tbl_keys]: a list of t’s keys.

Defined in stdlib.lua.

btv.tbl.values(t)

btv.tbl.values(t) [alias vim.tbl_values]: a list of t’s values.

Defined in stdlib.lua.

btv.tbl.count(t)

btv.tbl.count(t) [alias vim.tbl_count]: number of entries in t (any keys, not just the sequence).

Defined in stdlib.lua.

btv.tbl.deep_equal(a, b)

btv.tbl.deep_equal(a, b) [alias vim.deep_equal]: structural equality (recurses into tables, comparing keys and values). A general config/plugin helper.

Defined in stdlib.lua.

btv.tbl.get(o, ...)

btv.tbl.get(o, ...) [alias vim.tbl_get]: follow the ... keys into nested table o, returning the value reached or nil if any step is missing (or hits a non-table before the last key). The safe nested access lsp/<server>.lua configs use to read deep settings (e.g. rust_analyzer’s settings['rust-analyzer'].cargo.sysrootSrc).

Defined in stdlib.lua.

btv.tbl.filter(f, t)

btv.tbl.filter(f, t) [alias vim.tbl_filter]: Iterates with pairs (not ipairs) to match neovim: callers filter name-keyed maps too (a plugin manager filters its plugin set, keyed by plugin name), not just arrays. The result is always a fresh array.

Defined in stdlib.lua.

btv.tbl.map(f, t)

btv.tbl.map(f, t) [alias vim.tbl_map]: apply f to each value, keeping keys.

Defined in stdlib.lua.

btv.tbl.flatten(t)

btv.tbl.flatten(t) [alias vim.tbl_flatten]: a single list with every nested list flattened into it (depth-first). Deprecated in neovim but still called by lspconfig.util.

Defined in stdlib.lua.

btv.tbl.deepcopy(orig)

btv.tbl.deepcopy(orig) [alias vim.deepcopy]: a recursive copy of orig (metatables preserved).

Defined in stdlib.lua.

btv.tbl.deep_extend(behavior, ...)

btv.tbl.deep_extend(behavior, ...) [alias vim.tbl_deep_extend]: Merge ... maps into one. behavior is "force" | "keep" | "error". Nested maps merge recursively; lists are replaced whole; scalar conflicts resolve per behavior.

The list rule is neovim’s, and it is load-bearing rather than cosmetic: merging a list index-by-index fuses two unrelated entries and leaves a stale tail, so a re-registered tool list (btv.lsp.config’s settings.languages.<ft>) would keep the dropped tool’s keys on entry 1 and its old entry 2 — a config nobody wrote.

The JSON sentinels are values, not maps: btv.json.null and btv.json.empty_object() REPLACE whatever they are merged over (and are replaced by a later real value), so “explicitly nothing” written over a preset’s table means what it says.

Defined in stdlib.lua.

btv.tbl.extend(behavior, ...)

btv.tbl.extend(behavior, ...) [alias vim.tbl_extend]: Shallow variant of btv.tbl.deep_extend.

Defined in stdlib.lua.

btv.tbl.spairs(t)

btv.tbl.spairs(t) [alias vim.spairs]: pairs() in sorted-key order. Neovim’s stable-iteration helper — a custom 'tabline'/str_join uses it so output order is deterministic.

Defined in stdlib.lua.

btv.terminal

btv.terminal.open(opts)

btv.terminal.open([opts]) -> nil. Open a terminal job programmatically — the API twin of :terminal. opts.cmd is a string (whitespace-split into argv, no shell) or a list (argv verbatim, so an argument may contain spaces); omitted runs the default shell. opts.cwd defaults to the editor’s working directory.

Defined in btv.lua.

btv.test

ctx:feed(keys, opts)

t:feed(keys[, opts]) — type vim key-notation, then settle one tick. opts.remap (default true, so the plugin’s own mappings fire), opts.insert (default false), opts.settle (extra ticks to wait, for chained async). Returns self for chaining.

Defined in test.lua.

ctx:cmd(excmd)

t:cmd(excmd) — run an ex-command, then settle a tick.

Defined in test.lua.

ctx:exec(fn)

t:exec(fn) — run fn now (mirrors already fresh) and return its value. fn may itself await (it runs in the test coroutine).

Defined in test.lua.

ctx:mouse(button, action, row, col, modifier)

t:mouse(button, action, row, col[, modifier]) — send one mouse gesture at a global SCREEN cell, then settle a tick. The vocabulary is the wire’s: button is "left" / "right" / "middle" / "wheel" / "move", action is "press" / "drag" / "release" for a button and "up" / "down" / "left" / "right" for the wheel, and modifier is "", "s", "c" or "a". Rows and columns are 0-based, as the wire carries them.

The server owns every hit-test — cell to window to buffer position — so this is the same call a client makes, and a spec drives a click, a drag-select, a multi-click, a wheel scroll or a divider drag exactly as a user would. Multi-click detection compares arrival times against 'mousetime', so a double-click is two press/release pairs in quick succession, not a special action.

t:mouse("left", "press", 4, 12)
t:mouse("left", "release", 4, 12)
btv.test.expect(t:cursor()[1]).to_be(5)

Defined in test.lua.

ctx:wait_for(predicate, opts)

t:wait_for(predicate[, opts]) — await until predicate returns truthy (polling between ticks), returning that value. Rejects (raising in the test) on timeout. Use for async UI: a debounced popup, a watch-driven refresh. opts = { tries=, interval=, message= } (see btv.wait_for).

Defined in test.lua.

ctx:idle()

t:idle() — the pause. A key that is a live prefix of a mapping is WITHHELD until the next keystroke; the client arms a 'timeoutlen' timer and, on idle, nudges the server to resolve it. There is no such timer server-side, so a spec that just waits waits forever — t:idle() is how it says “the user stopped typing here”, and it is the only way a genuinely-ambiguous mapped prefix resolves (:set notimeout makes it a no-op, exactly as it does for a client).

t:feed("gg")        -- a live prefix of the `ggx` mapping: held
t:idle()            -- …and now it resolves to the `gg` built-in

Defined in test.lua.

ctx:sleep(ms)

t:sleep(ms) — await a wall-clock delay (for genuinely time-based behavior).

Defined in test.lua.

ctx:lines(first, last)

No documentation comment in the prelude.

Defined in test.lua.

ctx:line(n)

No documentation comment in the prelude.

Defined in test.lua.

ctx:current_line()

No documentation comment in the prelude.

Defined in test.lua.

ctx:cursor()

No documentation comment in the prelude.

Defined in test.lua.

ctx:mode()

The short mode string (“n”, “i”, “v”, …). btv.mode() returns the nvim_get_mode table; tests almost always want the code, so unwrap it. Use t:mode_info() for the full { mode, blocking } table.

Defined in test.lua.

ctx:mode_info()

No documentation comment in the prelude.

Defined in test.lua.

ctx:buf()

No documentation comment in the prelude.

Defined in test.lua.

ctx:keymaps(mode)

No documentation comment in the prelude.

Defined in test.lua.

ctx:float()

t:float() — the content float (which-key popup, hover, …), or nil when closed. { text = joined lines, lines = { raw chunk rows }, title = string|nil }.

Defined in test.lua.

ctx:message()

t:message() — the latest echo / message line (the bottom-of-screen text).

Defined in test.lua.

ctx:showcmd()

t:showcmd() — the 'showcmd' corner: the partly-typed command as the client would paint it in the last line’s right corner, truncated to vim’s 10 columns.

It is the one view that can see a withheld key run. A mapped prefix (<Space>f of a <Space>fs map) is held by the keymap matcher and never reaches the editor, so no buffer, cursor or mode state moves while it waits — the corner is the only place it exists. The editor’s own pending run (a count, an armed register, an operator waiting for its motion, a Visual selection’s size) shows here too.

t:feed("2d")
btv.test.expect(t:showcmd()).to_be("2d")

Empty when nothing is pending — and always empty with 'showcmd' off, which is what the option means.

Defined in test.lua.

ctx:scroll()

t:scroll() — the scroll-animation gesture the last frame started, as { from_row, to_row, duration_ms } (buffer lines, and the milliseconds the client will take to slide between them), or nil when that frame started none.

A one-shot: only the frame that begins a slide carries it. It describes what the client is about to animate rather than any state the editor keeps, so no other view can see it — t:view() reports the destination the scroll already settled on, animated or not. Always nil under 'noscrollanim' (and at 'scrollanimduration' 0), which is exactly what those options mean.

t:feed("<C-d>")
btv.test.expect(t:scroll().to_row > t:scroll().from_row).to_be(true)

Defined in test.lua.

ctx:matches(row)

t:matches([row]) — the SEARCH-match highlight over the focused window’s rows: a list of { first, last, current } per row (1-based screen rows, matching t:screen(); columns are 0-based display cells, end-exclusive). current marks the one match being walked onto while a / or :s command line is open (vim’s IncSearch, as against the plain Search on the rest). With row, just that row’s matches.

The match highlight rides its own wire layer — the server sends column spans and the client paints the group itself — so no highlight span is ever emitted for it and t:highlights() cannot see a match at all. This is the only view of 'hlsearch', of 'incsearch', and of the plain pattern preview a half-typed :s/pat shows before its replacement opens.

t:feed("/needle<CR>")
btv.test.expect(#t:matches(3)).to_be(1)

Defined in test.lua.

ctx:cmdline()

t:cmdline() — the open command line as drawn: the : / / prefix, or an btv.ui.input / btv.ui.confirm prompt LABEL, followed by the editable text. nil when no command line is open.

The three are separate on the wire (the client draws the prefix and label itself), so the label — what the prompt actually ASKED — is in none of the other views.

t:feed(":ene")
btv.test.expect(t:cmdline()).to_be(":ene")

Defined in test.lua.

ctx:statusline()

t:statusline() — the rendered status line text, when the mirror carries it.

Defined in test.lua.

ctx:tabline()

t:tabline() — a custom 'tabline' as rendered: the one styled row the %-format engine produced, with the %#Group# / %nT items already resolved away. nil when 'tabline' is unset, or when no tabline is drawn this frame.

A wholly different payload from t:tabs(), which reads the STRUCTURED per-region tab cells the client formats itself. Only one of the two is ever drawn — setting 'tabline' replaces the cells — so a suite for a %!-built tabline can see nothing in t:tabs() but the built-in labels it was meant to replace.

Defined in test.lua.

ctx:screen()

t:screen() — the focused window’s painted rows, top to bottom, as a list of strings.

The sibling of t:lines(), and the difference matters: t:lines() is buffer text, while this is what the client would actually draw. Anything the editor renders instead of a buffer line is only visible here — a closed fold’s placeholder, a ~ filler past the end. So a test for 'foldtext' or 'listchars' asserts on this; a test for an edit asserts on t:lines(). Virtual text and virtual lines are not here — they ride their own layers (a virtual line takes a row, but the row reads blank and its text is in the layer), which is what t:decor() reads.

t:feed("zM")
btv.test.expect(t:screen()[4]).to_contain("5 lines: fn alpha")

The text is display-scrubbed the same way the wire’s rows are (an unprintable byte reads as ^X / <xx>), so it is character-for-character what is painted. Only the focused window; splits and docks are not included.

Defined in test.lua.

ctx:highlights(row)

t:highlights([row]) — the highlight spans painted over the focused window’s rows: a list of { first, last, group } per row (1-based screen rows, matching t:screen(); columns are 0-based, end-exclusive display cells). With row, just that row’s spans.

One of the two views that can see a decoration (t:decor() is the other): t:lines() is buffer text, t:screen() is the glyphs drawn, and a highlight — a btv.decor provider’s mark, a btv.decor.expr paint, a treesitter capture — changes neither. Groups are the names as painted, so a test asserts on the group it asked for rather than on a colour.

btv.decor.expr([[ local s, e = line:find("TODO") if s then return { { s, e, "Todo" } } end return {} ]])
t:feed("iTODO<Esc>")
btv.test.expect(t:highlights(1)[1][3]).to_be("Todo")

Only the focused window, like t:screen().

Defined in test.lua.

ctx:tabs(region)

t:tabs([region]) — a region’s own tab pages: { labels = { "…" }, current = <1-based index> }, or a map of every region when region is omitted. region is "main" or a dock side ("left" / "right" / "top" / "bottom").

Tab pages here are PER REGION — the main area and each open dock carry their own independent set — but nvim_list_tabpages reports one global list whichever region is focused, so from Lua the stacks are indistinguishable and a spec cannot tell “a tab was added to the dock” from “a tab was added”.

A region reports the tabline it DRAWS, so a region whose 'showtabline' hides it (the default 1, with a single tab) reports no labels.

btv.test.expect(#t:tabs("left").labels).to_be(2)
btv.test.expect(#t:tabs("main").labels).to_be(1)

Defined in test.lua.

ctx:view()

t:view() — where the focused window is SCROLLED to: { leftcol = <columns>, topline = <buffer line>, numbers = { <line per row> } }.

t:screen() carries each painted row’s full text; the CLIENT is what clips it to the window and slides it left by leftcol. So sideways scrolling (nowrap) is visible in no row view at all, and the vertical position had to be guessed from the text. numbers is the buffer line each painted row shows — the top line, and which lines are visible at all, since a closed fold takes its rows out of the list.

t:feed("$")
btv.test.expect(t:view().leftcol > 0).to_be(true)

Defined in test.lua.

ctx:gutter()

t:gutter() — the focused window’s reserved gutter, as { number_width = <cells>, sign_width = <cells>, gutter_text_width = <cells>, total = <cells> }. gutter_text_width is the column gutter_text marks fill, as wide as the widest of them in the buffer but never wider than the window has left beside its other gutters.

The CLIENT draws the number and sign columns from the widths the server reserves, so the gutter is in none of the row views — t:screen() is the text area alone. Without this a spec could read 'numberwidth' / 'signcolumn' back and nothing more, which reports what was ASKED for rather than what was reserved: 'numberwidth' is a minimum that grows to fit the largest line number, 'signcolumn=auto' collapses when no sign is placed, and 'nonumber' removes the column whatever its width says.

t:cmd("set number numberwidth=4 signcolumn=yes:2")
btv.test.expect(t:gutter().total).to_be(8)

Defined in test.lua.

ctx:menu()

t:menu() — the open float-list menu, or nil when none is up: { items = { "…" }, selected = <1-based index|nil> }.

One widget serves four features — the insert-mode completion popup, the :-line wildmenu, btv.picker, and btv.ui.select — and it is in none of the other views: not buffer text, not a painted row of the focused window (it floats over them), and not the content float t:float() reads. Without this a suite for any of those could only assert on what happened AFTER an accept, never on what was offered or which row led.

selected is nil while nothing is highlighted — the popup opens noselect, so <CR> runs the typed line until you actually pick a row. row/col/width/ height describe the box, but in the placement’s own coordinate space (see below) — to CLICK a row, probe for the cell rather than deriving it.

kinds is the parallel list of per-row kind labels the completion popup right-aligns ("Snippet", "Function", …), "" for a row that carries none (a plain buffer word). It is the whole of what the kind column shows, and it is empty for every other user of the widget.

t:feed(":ene<Tab>")
btv.test.expect(t:menu().items[1]).to_be("enew")
btv.test.expect(t:menu().selected).to_be_nil()

Defined in test.lua.

ctx:rulers()

t:rulers() — the focused window’s 'colorcolumn' rulers, as the 1-based text columns the client is told to paint with the ColorColumn group.

The third view onto a layer that is not buffer text: t:lines() cannot see a ruler (it is not text), t:screen() cannot (it is a background behind cells that may hold nothing), and t:highlights() cannot either — the server sends the column list and the client draws it, so no span is ever emitted. This is also the only place the +N rule is visible: a 'textwidth'-relative entry is accepted but resolves to nothing, while 'colorcolumn' itself still reads "+1".

t:cmd("set colorcolumn=80,120")
btv.test.expect(t:rulers()).to_equal({ 80, 120 })

Only the focused window, like t:screen().

Defined in test.lua.

ctx:decor(row)

t:decor([row]) — the decoration drawn beside the focused window’s rows: virtual text, virtual lines, gutter signs, the gutter text column, the full-width row tint, and the end-of-line diagnostic message. A list of { virt_text = "…", virt_pos = "eol", virt_col = 0, virt_lines = "…", sign = "▶", gutter = " 12 13", line_bg = true, diagnostic = "…", severity = 1 } per row (1-based screen rows, matching t:screen()); with row, just that row’s. Each key is absent on a row that carries nothing of that kind.

The companion of t:highlights(), and the split is what each layer is: a highlight colours the buffer’s own cells, while these draw glyphs that are not in the buffer at all — a btv.decor.expr badge or sign, a provider’s inline blame, a diagnostic’s message. t:screen() cannot see them either: it is the buffer’s rows as painted, and each of these rides its own layer beside them. (Which is also why diagnostic is its own key rather than part of virt_text: they are separate layers on the wire, and one row can carry both.)

btv.decor.expr([[ return { { 1, 1, "Todo", virt_text = " <- here", sign_text = ">>" } } ]])
btv.test.expect(t:decor(1).virt_text).to_be(" <- here")
btv.test.expect(t:decor(1).sign).to_be(">>")

virt_text is the row’s placements joined, since one row can carry several; virt_pos/virt_col describe the first. virt_lines is set on a row that is a virtual line — a whole extra screen row, so it reads blank in t:screen() and carries its text here instead. gutter is the row’s gutter_text chunks joined, as sent: the client pads it to the column’s width (t:gutter().gutter_text_width). line_bg is true on a row a line_hl_group tints full width. Highlight groups are absent on purpose: the wire carries a per-frame palette id for these layers, not a name, so t:highlights() stays the group-level view.

Only the focused window, like t:screen().

Defined in test.lua.

btv.textobject

btv.textobject.map(lhs, capture)

btv.textobject.map(lhs, capture) binds one sequence; btv.textobject.map(tbl) binds many from an lhs -> capture table, e.g. btv.textobject.map({ il = "@loop.inner", al = "@loop.outer" }).

Defined in btv.lua.

btv.textobject.unmap(lhs)

btv.textobject.unmap(lhs) removes a binding; a previously-overridden built-in key reverts to its built-in behavior.

Defined in btv.lua.

btv.treesitter

btv.treesitter.foldexpr(_lnum)

No documentation comment in the prelude.

Defined in btv.lua.

btv.treesitter.highlight(lang, text)

btv.treesitter.highlight(lang, text) -> promise of the tree-sitter highlight spans for the off-buffer snippet text in language lang — the same stateless highlighter (injections included) the picker preview uses, exposed so a plugin can token-colour an arbitrary snippet without opening a buffer (the help window’s >lua code blocks are the motivating case). Resolves with an array of { line = <0-based row>, col_start = <byte>, col_end = <byte>, group = <capture> } (col_end exclusive); the columns are byte offsets within each snippet line, which a caller maps to extmark columns. Resolve-only: a language with no installed grammar (and the wasm serverless build, whose highlighter is JS-side) settles with an empty array, so the caller simply paints nothing.

Defined in btv.lua.

btv.treesitter.reload(lang)

btv.treesitter.reload(lang): look for lang’s grammar again, and repaint what’s open in it. An editor remembers that a language has no grammar, and :TSInstall only tells the editor that ran it (TSInstalled), so this is for another editor that installed one: an app embedding several hears of it from the one, and tells the rest. Native only: the browser has one highlighter, outside the editor, and reports that this isn’t there.

Defined in btv.lua.

btv.treesitter.installed()

btv.treesitter.installed() -> promise of the list of languages with a grammar that can highlight: on native, a parser in bemtvi’s data dir or a borrowed neovim site/ with a highlights query (beside the parser, on the runtimepath, or from btv.treesitter.set_query); in the browser, what its highlighter has. :TSInstall adds to it. So a plugin can tell what’s missing before asking for it:

btv.treesitter.installed():next(function(langs)
  if not btv.tbl.contains(langs, "rust") then
    btv.cmd("TSInstall rust")
  end
end)

Defined in btv.lua.

btv.treesitter.set_query(lang, name, text)

btv.treesitter.set_query(lang, name, text): use text as lang’s name query, in place of its query files, in every buffer. name is one of highlights, indents, injections, folds or textobjects. nil for text goes back to the files. A query that won’t compile says so on the message line and changes nothing.

Not in the browser, whose highlighter is JS-side: it reports that on the message line.

Defined in btv.lua.

btv.treesitter.fragment_context(lang, templates)

btv.treesitter.fragment_context(lang, templates) — teach the fragment highlighter how to make sense of a code block that is not a whole program.

The code blocks inside LSP documentation (hover, completion docs) are usually fragments: a struct field, a bare statement, a body-less signature. Parsed as a whole file they land in tree-sitter’s error recovery, which doesn’t merely under-highlight them — it names constructs that aren’t in the text (Vec in field: Vec<String> comes out @constructor). bemtvi’s fragment highlighter drops those guesses; a framing is how it gets the real structure back instead.

templates is an ordered list of framings, each a string with one %s marking where the snippet goes. A snippet that doesn’t parse on its own is tried inside each in turn, and the first framing that parses cleanly wins — its highlight spans are mapped back onto the snippet’s own lines and columns:

btv.treesitter.fragment_context("rust", {
  "struct __btv {\n%s\n}",   -- a field hover: `field: Vec<String>`
  "fn __btv() {\n%s\n}",     -- a statement or expression
})

Only a clean parse is accepted, so a framing that doesn’t fit costs one throwaway parse and nothing else — the snippet falls through to the conservative repaint (keywords, strings, numbers, comments, punctuation; no guessed constructs), which is also where an annotation dialect ends up: lua_ls writes function f(t: table) into a ```lua fence, and that is not a fragment of any Lua program.

Indentation-sensitive languages. When a template’s %s follows only whitespace on its line, that whitespace is the block level the fragment sits at, not just an opener the first line continues — so every line of the fragment is indented to match:

btv.treesitter.fragment_context("python", { "class __btv:\n    %s" })

Without that a multi-line python fragment would be framed as a header, one indented line, and then a dedent — a syntax error rather than a block. The spans come back with the indent taken off every line, so the caller still sees the fragment’s own columns.

A same-line framing works too ("fn __btv() { return %s }") — there the prefix’s width comes off the first line only. The wrapped text always ends in a newline, because some grammars (go) treat a missing final terminator as a parse defect.

Two shapes the ladder is run for you on. A hover often carries the server’s own display label in front of the code — pyright sends (method) def join(self, x: str) -> str, tsserver (property) Foo.bar: number — and that label is what stops an otherwise framable signature from framing. It is peeled off, the ladder runs on the rest, and the label itself is painted as a comment. And a block that is a list rather than one fragment — ty sends every overload of a function as its own signature line — is resolved line by line, each through its own ladder. Both are all-or-nothing: a peel or a split that doesn’t end in a clean parse leaves no trace, and the block falls to the repaint whole.

Calling this replaces the language’s list; passing {} turns the ladder off for it. bemtvi ships defaults for rust, python, go, lua, javascript, typescript, tsx, c, cpp, java, c_sharp, kotlin, swift, scala, dart, zig, ruby, php, elixir, css, scss and json.

Defined in btv.lua.

btv.ui

btv.ui.select(items, opts, on_choice)

btv.ui.select(items, opts) -> a PROMISE that resolves to the chosen item, or to nil on cancel (<Esc> / q). Promise-only: there is no on_choice argument (passing one is the old callback shape and errors loudly). The 1-based index is dropped — recover it from the item, or use the vim.ui.select alias, which keeps it.

opts:

  • prompt — the label drawn above the list (default none).
  • format_item — an item -> display string mapper (default tostring); the original item round-trips to the resolved value regardless.

Defined in ui.lua.

btv.ui.menu(entries, opts)

btv.ui.menu(entries, opts) -> a PROMISE that resolves to the chosen entry, or to nil when the menu is dismissed (<Esc>, q, a click off it). A context menu: the same floating list as btv.ui.select, with rows that say more than a label. Each entry is a table:

{
  label = "Copy",      -- the row's text (required unless `separator`)
  detail = "Ctrl+C",   -- shown in an aligned column on the right, e.g. a shortcut
  enabled = false,     -- greyed out; can't be chosen (default true)
  separator = true,    -- a rule between groups of rows; can't be chosen
}

Keys and the mouse skip the rows that can’t be chosen. Any other field rides along on the entry the promise resolves to (an action to run, say).

opts:

  • anchor — "cursor" (default) opens the list under the text cursor; "mouse" opens it at the last mouse cell, the shape for a MenuPopup handler (under the text cursor instead when a key came after that click). At the pointer it behaves like a GUI menu: one click chooses a row, the pointer hovering a row highlights it, and a click or right-click off it dismisses it.
  • prompt — as for btv.ui.select.

Greyed-out rows paint with BtvMenuDisabled (default: linked to Comment); separators are drawn in the menu’s border color.

Defined in ui.lua.

btv.ui.input(opts, on_confirm)

btv.ui.input(opts) -> a PROMISE that resolves to the entered string on <CR>, or to nil on <Esc> (cancel). Promise-only: there is no on_confirm argument (passing one is the old callback shape and errors loudly).

opts:

  • prompt — the label drawn ahead of the editable line (default "").
  • default — text prefilled into the line, cursor at its end (default "").
  • history — a namespace string enabling readline-style recall: <Up>/<Down> (and <C-p>/<C-n>) browse the prompts submitted under this namespace, and each non-empty submission is recorded into it. Each namespace is an independent ring, so one plugin’s REPL history is separate from another’s. Session-only for now. Absent ⇒ no history.
  • complete — a function (line, col) -> candidates driving <Tab> autocomplete (the inline wildmenu above the prompt line — <Tab>/<S-Tab> cycle, <CR> accepts). candidates is a { {label, insert?, doc?, start?, length?}, … } list (insert defaults to label), OR a PROMISE of one — so an async source (e.g. a DAP completions request) works. The token completed is the trailing identifier run before the cursor, UNLESS a candidate supplies start (0-based char offset into the line) + length (chars), an explicit replace span that overrides the token for that row. col is the cursor’s 0-based char offset.
  • complete_docs — show the side docs pane rendering each candidate’s doc beside the list (default true when complete is set; false suppresses it).
  • complete_debounce — ms to coalesce refresh queries (narrowing an open menu as you type) so an async source isn’t a wire round-trip per keystroke; the initial <Tab> is always immediate (default 100; 0 disables it).

The server owns the prompt: it opens the editor’s command line as a labelled Prompt (Editor::open_prompt), and delivers the result to btv._cb_fns[id] through the shared prompt_results channel. Non-blocking (ADR 0002 rule 3): the call returns at once and the promise settles on a later tick. Note an empty submission (<CR> on an empty line) resolves to "" (not nil) — only <Esc> cancels, matching neovim’s vim.ui.input.

Defined in ui.lua.

btv.ui.confirm(message, opts, on_choice)

btv.ui.confirm(message, opts) -> a PROMISE that resolves to a boolean — true on Yes, false on No or cancel (<Esc>). Promise-only: there is no on_choice argument (the old callback forms — a third arg, or opts-as-function — error loudly).

opts (optional):

  • default — true | false, which button <CR> selects (default true = Yes).

btv-native (no vim.ui twin): neovim spells this blocking vim.fn.confirm, which the btv model omits (rule 3). For an arbitrary multi-choice menu use btv.ui.select instead — confirm is deliberately just yes/no. The server opens a single-keypress Confirm dialog (Editor::open_confirm) sharing the prompt_results channel with btv.ui.input (one prompt open at a time); the chosen 1-based button index arrives as a string, which the wrapper folds to the boolean (1 = Yes; 2 = No; 0 = cancel).

Defined in ui.lua.

float:update(contents, opts)

No documentation comment in the prelude.

Defined in ui.lua.

float:close()

No documentation comment in the prelude.

Defined in ui.lua.

float:is_open()

No documentation comment in the prelude.

Defined in ui.lua.

btv.ui.float(contents, opts)

btv.ui.float(contents, opts): open the list-less content float — the sibling of the selectable-list widget (docs/specs/2026-06-14-btv-ui-float-widget.md, “What stays out of this widget”) — rendering content with no list / selection. contents is a string (split on newlines), a list of line strings, or — for a styled float (the “pretty” which-key) — a list where a row may be a CHUNK LIST { {text, hl_group?}, … } (neovim’s virt_text shape): each chunk paints its text in hl_group, so a row can colour its key one group and its description another, or dim a whole row with a Comment/dim group. Plain and chunk rows mix freely; a plain row is just one un-grouped chunk.

opts:

  • border — "none"|"single"|"rounded"|"double"|"solid" (default "rounded").
  • title — a string drawn on the top border (optional).
  • relative — "cursor" (default, anchors at the cursor) | "editor" (centered) | "bottom" (pinned to the editor’s bottom-right corner — the which-key shape).
  • persist — when truthy, the float survives keystrokes (it is not dismissed by the next key) and btv.ui.float returns a HANDLE with :update(contents, opts) / :close() / :is_open(). This is the surface a key-observer plugin (e.g. which-key) renders through, refreshing it as keys arrive. Without persist it is fire-and-forget: the server owns the float, its geometry, and its dismissal (the next key closes it); returns nil. Empty contents open nothing. LSP hover and signature help use the non-persistent form.

Defined in ui.lua.

btv.ui.open(uri)

btv.ui.open(uri) -> a PROMISE of the opener’s exit result { code, stdout, stderr } (the btv.run shape). Hands uri — a file path or a URL — to the OS opener chosen by platform (btv._ui_opener: open on macOS, explorer on Windows, xdg-open elsewhere) and runs it off-tick. Like btv.run it RESOLVES rather than rejects: a missing opener surfaces as code = -1 and a non-zero opener exit as that code — the caller decides what to do with it. Promise-only (ADR 0002): no callback arg.

Defined in ui.lua.

btv.ui.caps()

btv.ui.caps() -> a fresh table of the attached client’s terminal capabilities:

{
  client            = "",    -- "tui", "gui" or "web"; "" when the client didn't say
  platform          = "",    -- "macos", "linux", "windows"; "" when it didn't say
  keyboard_protocol = false, -- the kitty keyboard protocol is on
  truecolor         = false, -- the terminal can show 24-bit color
  osc52             = false, -- the terminal accepts OSC 52 clipboard writes
  key_labels        = {},    -- chords this client can only deliver via another chord
}

keyboard_protocol is the one a keymap cares about. Without it the terminal cannot tell <C-i> / <C-m> / <C-[> / <C-h> apart from <Tab> / <CR> / <Esc> / <BS>, and bemtvi folds each onto the named key — on BOTH sides, so mapping <C-h> there really maps <BS>. A plugin that wants one of those four chords should install it only when this is true:

btv.on("UIEnter", {}, function()
  if btv.ui.caps().keyboard_protocol then
    btv.keymap.set("i", "<C-h>", my_action)
  end
end)

key_labels maps a chord the editor sees onto the chord the user must actually PRESS to send it, for the clients that cannot deliver it directly. The browser is the case that forced it: Chrome and Edge handle <C-w> / <C-t> / <C-n> / <C-1>..<C-9> themselves on Windows and Linux, so the page never sees them and the web client substitutes Alt — a keypress of Alt+w arrives as <C-w>. Anything that DISPLAYS a key (a which-key popup, a cheat sheet, a desc listing) should render through this so it names a chord the user can press; anything that MATCHES on keys must not, since the editor still sees the canonical notation:

local labels = btv.ui.caps().key_labels
local shown = labels["<C-w>"] or "<C-w>" -- "<A-w>" in a browser, "<C-w>" elsewhere

It is empty on a client with nothing to substitute (every terminal, and a browser on macOS, where those shortcuts hang off Cmd and Ctrl arrives untouched).

Every field is false/empty before a client attaches, so check it from UIEnter rather than at config time: the config is sourced (and VimEnter fires) before the first client attaches.

Defined in ui.lua.

btv.ui.font(fonts, opts)

btv.ui.font(fonts, opts): set 'guifont' from a font list, tried in order for every character (wezterm’s font_with_fallback). Each entry is a family name, or a table with the name first and options of that font’s own:

btv.ui.font({
  { "JetBrains Mono", weight = 500, features = { "+ss01", "-calt" } },
  { "Symbols Nerd Font", scale = 0.9 },
  "Noto Color Emoji",
}, { size = 14 })

Per-font options:

weight    1-1000, or "thin" … "normal" … "bold" … "black" (its regular text)
scale     a factor on its glyphs' size, in (0, 4]
features  OpenType features: "+tag" / "tag" on, "-tag" off, "tag=N" an alternate

opts.size is the point size. Returns the 'guifont' value it set, which the editor checks like any other (a bad weight or feature tag is E474). The GUI honors every option; the browser takes the list and the size, and says so when a font has options it can’t apply there.

Defined in ui.lua.

btv.undotree

btv.undotree.get(bufnr)

No documentation comment in the prelude.

Defined in vimfn.lua.

btv.user_command

btv.user_command.create(name, command, opts)

btv.user_command.create(name, command, opts) [alias nvim_create_user_command]: register a global :Name. command is a function or an ex-command string. A function is called with { name, args, fargs, bang, line1, line2, range }: the 1-based line1/line2 of an addressed invocation (:2,5Name, :'<,'>Name) and range its address count, or the cursor line twice and range = 0 without one. opts.desc (a one-line summary) is stored alongside the body — get() surfaces it and the command-line completion catalog shows it as the command’s docs. opts.usage (a string) is the command’s ARGUMENT signature — the part after the name, in vim help notation ({arg} required, [arg] optional), e.g. usage = "[config]". The completion docs pane heads with :Name <usage> exactly as a built-in’s synopsis does, so a plugin command’s parameters are discoverable in the same place. Omit it for a command that takes no arguments. opts.complete makes <Tab> in the command’s argument offer completion:

  • "dir" / "file" — path completion via the picker the built-in :cd/:edit use;
  • a function fn(args) — generate candidates dynamically. args is the list of whitespace-separated argument words typed so far, the last being the partial word under the cursor (:Cmd <Tab> → {}, :Cmd a<Tab> → {"a"}, :Cmd a b<Tab> → {"a","b"}). It returns a list of candidates — each a string, or a table { label =, insert =, doc = }. A SYNC function (returns a list) shows inline in the wildmenu and is re-run as you type; an ASYNC one (returns a promise, e.g. an btv.async function) lists in the picker. A throw is reported and yields no candidates.

Defined in autocmd.lua.

btv.user_command.buf_create(buffer, name, command, opts)

btv.user_command.buf_create(buffer, name, command, opts) [alias nvim_buf_create_user_command]: register a buffer-local command (buffer 0 = current). It dispatches only while that buffer is current and shadows a global command of the same name there — everywhere else it’s unknown. Lives in its own per-bufnr table so the global registry stays clean; btv._resolve_user_command consults both at dispatch.

Defined in autocmd.lua.

btv.user_command.get(_opts)

No documentation comment in the prelude.

Defined in autocmd.lua.

btv.user_command.buf_get(buf, _opts)

No documentation comment in the prelude.

Defined in autocmd.lua.

btv.utils

btv.utils.path_style()

btv.utils.path_style(): the path convention the editor’s paths follow — "windows" or "unix" (the same spellings btv.glob’s style option takes).

Under "windows", / and \ both separate when a path is read, a path can be rooted at a drive (C:\, C:/) or a UNC share (\\server\share\), and paths the editor produces use \. Under "unix", / is the only separator and \ is an ordinary character.

This is the single place the path helpers (btv.utils.path_sep, path_root, is_absolute, dirname, and btv.fname.modify built on them) decide which platform’s rules apply. It follows the session’s path syntax, which the server publishes as btv._path_style ("posix" or "windows") alongside btv._cwd: the daemon’s in a daemon session — a Windows client editing a unix host still sees unix paths — and the host’s otherwise. Before the first publish it answers for the host (btv.utils.is_windows).

Defined in utils.lua.

btv.utils.path_sep()

btv.utils.path_sep(): the separator the editor writes paths with — "\" under the "windows" path style, "/" otherwise. Use it to build a path that will be shown or compared against one the editor reports; when reading a path, accept either separator on Windows (btv.utils.path_root, dirname and basename already do).

Defined in utils.lua.

btv.utils.path_join(...)

btv.utils.path_join(...): join path segments into ONE filesystem path spelled with the session’s separator (btv.utils.path_sep), so a path built from a directory the editor reported (C:\Users\me\data) never comes out half \, half /. Under the "windows" style a / inside a segment is respelled \ too, so path_join(cfg, "lua/plugins.lua") is one native path; under "unix" a \ is an ordinary character and left alone. Separators already at a seam are not doubled, and nil / "" segments are skipped.

btv.utils.path_join("/home/me", "plugins", "foo")   -- "/home/me/plugins/foo"
btv.utils.path_join([[C:\data]], "lua/x.lua")        -- [[C:\data\lua\x.lua]]

It follows the session’s path style — in a daemon session, the daemon’s — so it is for paths on the session filesystem (btv.fs, buffer names, getcwd()). A path on THIS machine (btv.fs_local, stdpath, package.path) is joined with btv.utils.local_path_join. This is for paths handed to the filesystem or shown as files: btv.utils.joinpath stays neovim’s vim.fs.joinpath (always /), and a glob keeps its own /.

Defined in utils.lua.

btv.utils.local_path_join(...)

btv.utils.local_path_join(...): btv.utils.path_join, but by THIS machine’s rules (btv.utils.is_windows) whatever the session’s — the twin for paths on the local filesystem (btv.fs_local, stdpath, the plugin manager’s directories, package.path), as btv.fs_local is the twin of btv.fs. Identical to path_join outside a daemon session.

Defined in utils.lua.

btv.utils.path_root(path)

btv.utils.path_root(path): the root prefix of path, exactly as spelled in it, or "" for a relative path. path:sub(#root + 1) is then the part below the root.

unix:     "/a/b"                -> "/"
windows:  "C:\a" / "C:/a"       -> "C:\" / "C:/"
          "\\srv\share\a"       -> "\\srv\share\"
          "\a"                  -> "\"   (rooted on the current drive)
both:     "a/b", ""             -> ""

A drive-relative C:a has no root (it is relative to that drive’s own cwd).

Defined in utils.lua.

btv.utils.is_absolute(path)

btv.utils.is_absolute(path): is path rooted — /… on Unix; a drive (C:\…), UNC share (\\server\share\…) or bare separator (\…) on Windows? A relative path is the one a cwd has to be prepended to (see btv.fname.modify(path, ":p")).

Defined in utils.lua.

btv.utils.dirname(path)

btv.utils.dirname(path): the directory part of path — everything before the last separator ("/a/b/c.txt" → "/a/b"). "" for an entry directly under the root ("/a" → "", and on Windows "C:\a" → ""); a path with no separator comes back unchanged (there is nothing to strip), which callers walking upward detect as “no parent”. On Windows both / and \ separate.

Defined in utils.lua.

btv.utils.basename(path)

btv.utils.basename(path): the last /- or \-separated component of path, ignoring trailing separators ("/a/b/" → "b"). nil when nothing remains (the root "/", an empty string).

Defined in utils.lua.

btv.utils.expanduser(path)

btv.utils.expanduser(path): expand a leading ~ / ~/ to $HOME, so a config value can point at a home-relative path ("~/work/foo"). Only the leading tilde is touched — a mid-path ~ is a literal path component, and the ~user form is not resolved (returned unchanged, like vim with an unknown user). With no $HOME in the environment the path is returned unchanged rather than mangled.

Defined in utils.lua.

btv.utils.ancestors(path)

btv.utils.ancestors(path): iterate the ancestor directories of path, nearest first — dirname(path), then its parent, and so on. Stops before the empty string, so the filesystem root itself is never produced. A relative path is first resolved against the editor’s working directory (btv.fname.modify(path, ":p"), which also collapses . / ..) — the ancestry of a relative path is only meaningful against the cwd, and the paths callers walk are buffer names (btv.buf.name, an autocmd’s ev.file), which are whatever the user typed and so routinely relative (bemtvi src/main.rs). Without that, the walk would stop at the first typed component and never reach the project root. The upward-walk loop behind .editorconfig discovery and LSP root-marker search:

for dir in btv.utils.ancestors("/a/b/c.txt") do
  -- "/a/b", then "/a"
end

Defined in utils.lua.

btv.utils.joinpath(...)

btv.utils.joinpath(...): join path components with a single /, collapsing the separators at each seam so joinpath("/a/", "/b/", "c") is "/a/b/c". An empty or nil component is skipped rather than producing a doubled slash, so a conditionally-absent middle segment doesn’t corrupt the result. A leading / on the FIRST component is preserved (the only place an absolute path is decided); everything after it is treated as relative, so a later "/b" appends rather than restarting at the root. Pure string math — nothing is resolved against the filesystem or the cwd.

btv.utils.joinpath(root, "node_modules/.bin", cmd)

Defined in utils.lua.

btv.utils.normalize(path)

btv.utils.normalize(path): canonicalize a path as pure string math — expand a leading ~, fold \ to /, collapse repeated separators, drop . components, and resolve .. against the component before it. Trailing separators are stripped (except on the root itself). Nothing touches the filesystem, so this does NOT resolve symlinks — a .. after a symlinked directory resolves lexically, which is what a config comparing two configured paths wants (for the real thing, btv.fs.realpath is the async op).

A .. that would escape a relative path is KEPT ("a/../../b" → "../b"), since there is no cwd here to resolve it against; on an absolute path it is dropped at the root ("/../a" → "/a"), matching every OS.

Defined in utils.lua.

btv.utils.relpath(base, target)

btv.utils.relpath(base, target): target expressed relative to base, or nil when target is not inside base. Both are normalized first, and the comparison is on whole path COMPONENTS — so relpath("/a/b", "/a/bc") is nil, not "c", which a plain prefix-match would get wrong. target == base yields ".".

The “is this file under that directory?” test, which is how a config decides a buffer belongs to a dependency tree (a cargo registry checkout, a Go module cache) rather than the project:

if btv.utils.relpath(cargo_registry, file) then --[[ it's a library file ]] end

Defined in utils.lua.

btv.utils.is_windows()

btv.utils.is_windows(): is this a Windows host? Answered from package.config’s directory separator, which Lua fills in at build time — so it is correct in every build (native, daemon-side, wasm) without a host round-trip or an env-var guess.

The check behind “which name does this program have here?” — a tool installed as foo on Unix is foo.exe or foo.bat on Windows, and a plugin resolving an executable has to know which to look for. Prefer btv.fs.which, which searches for you; reach for this when the name itself differs.

Defined in utils.lua.

btv.utils.uri_from_path(path)

btv.utils.uri_from_path(path): the file:// URI naming path.

Percent-encodes everything outside the URI unreserved set, / excepted — a path holding a space, a # or a non-ASCII character is otherwise a malformed URI the server silently misreads (it truncates at the #, or fails to match the document it already has open under the encoded spelling). The path is normalized first, so the same file always produces the same URI and a server’s document map hits, and a RELATIVE path (a buffer name as typed) is resolved against the editor’s cwd, since a file:// URI can only name an absolute path.

A Windows path takes the shape every LSP server (and the url crate’s Url::from_file_path) expects: C:\Users\x\a.py → file:///C:/Users/x/a.py, the drive’s : left unescaped; a UNC path names its server as the URI’s host (\\srv\share\a → file://srv/share/a).

Defined in utils.lua.

btv.utils.uri_from_buf(bufnr)

btv.utils.uri_from_buf(bufnr): buffer bufnr’s file:// URI (0/nil = the current buffer), or "" for a buffer with no file — which is what a server should be told, rather than a file:// naming nothing.

Defined in utils.lua.

btv.utils.uri_to_path(uri)

btv.utils.uri_to_path(uri): the filesystem path a file:// URI names, with its percent-escapes decoded, or nil for any other scheme.

nil rather than the raw string: a deno: / jdt: / untitled: URI names a document that has no path at all, and treating one as a path creates a buffer for a file that will never exist. Callers branch on the nil.

Under the Windows path style (btv.utils.path_style) the result is a native path: file:///C:/x/a.py → C:\x\a.py, and a URI with a host is a UNC path (file://srv/share/a → \\srv\share\a).

Defined in utils.lua.

btv.utils.argv(spec)

btv.utils.argv(spec): build a flat argv list from a run-family spec — spec.cmd is the program (a string, or an argv list whose first element is the program), spec.args an optional list appended after it, so { cmd = "git", args = { "log" } } and { cmd = { "git", "log" } } produce the same argv. The normalizer behind btv.run / btv.run_stream / btv.run_local, public so a plugin composing specs for those APIs (a task runner, a DAP adapter) shares the exact same shape.

Defined in utils.lua.

btv.utils.str_list(spec, what)

btv.utils.str_list(spec, what): normalize a config value that may be one string or a list of strings — a bare string becomes a one-element list, a list of strings passes through, nil becomes {} (the caller’s default applies). Anything else raises, with what naming the option in the message (e.g. "btv.snippet.setup: jump_next"). The shared normalizer behind key-spec options (btv.complete.setup / btv.snippet.setup), public for any config surface that accepts string | string[].

Defined in utils.lua.

btv.utils.caller_source()

btv.utils.caller_source(): the source path of the file whose code called into the current function — walks up the Lua stack to the nearest real-file frame. bemtvi sources every config / plugin / required file with an @<path> chunk name, while the embedded prelude chunks are named bemtvi:prelude/* (no @) and C frames carry =[C] — so the first @-prefixed source above the caller is the file whose code is running. nil when nothing on the stack is @-named. Behind vim’s <sfile> / <script> (btv.expand) and the plugin-persistence namespace attribution (btv._resolve_namespace).

Defined in utils.lua.

btv.utils.debounce(fn, ms)

btv.utils.debounce(fn, ms): wrap fn into a trailing-edge debounce over btv.timer — the returned value runs fn once, ms after the LAST call, so a burst of rapid calls collapses to a single invocation with the most recent arguments. A timing/control-flow helper (which-key’s show-delay, on-change handlers, resize / scroll reactions); it runs nothing on the input path.

It is callback-shaped, NOT promise-shaped: debounce coalesces a stream of many calls, whereas a promise models one eventual value — different jobs. They compose, though: pass an btv.async function as fn to kick awaitable work after the quiet period, and reach for btv.promise.delay when you want an await-able one-shot sleep instead.

The result is callable AND carries:

:cancel()  drop a pending invocation (the next call re-arms)
:flush()   run a pending invocation now (no-op when idle)

Each call (re)arms the timer; nothing fires until the calls stop for ms.

Defined in utils.lua.

debounced:cancel()

No documentation comment in the prelude.

Defined in utils.lua.

debounced:flush()

No documentation comment in the prelude.

Defined in utils.lua.

btv.view

btv.view.component(def)

btv.view.component(def) — the view-backed component (a focus-taking btv.view buffer): the common case (file tree, list, modal dialog). Sugar over btv.component with the view backend; mount(opts) takes the view surface options (name / filetype / dock / split / float), plus persist = "<id>" (+ optional namespace) to make the view survive a restart — see the Persistence note on btv.component above.

Defined in component.lua.

btv.view.actions.confirm()

No documentation comment in the prelude.

Defined in view.lua.

btv.view.create(opts)

btv.view.create{ name?, filetype?, persist?, namespace? } -> handle. Mints the backing read-only buffer (off-screen until mounted) and returns the handle. filetype drives treesitter / decoration on the view buffer.

persist opts the view into cross-session restore: it is a stable, plugin-chosen string id (instance-unique within the plugin) that core round-trips through the workspace session — recording only (namespace, id) + the view’s slot, never its content. On restore core reserves the slot and hands the id back via btv.view.on_restore, and the plugin (which keyed its own btv.shada.plugin() store by the same id) rebuilds the content. Absent ⇒ the view is ephemeral (today’s behavior — not persisted). The owning namespace is auto-derived from the calling plugin’s location (same resolver as btv.shada.plugin()); opts.namespace is the escape hatch for a context that attributes to no runtimepath entry (a bare :lua / RPC / test) and is an error from a real plugin file — exactly the btv.shada.plugin(dev_namespace) contract.

Defined in view.lua.

btv.view.pending_restores()

btv.view.pending_restores() -> the views a session restore reserved a slot for but that no plugin has adopted yet, each { namespace=, id=, win= } (win is the reserved window). The pull primitive behind btv.view.on_restore and the black-box test hook. Refreshed each tick from core’s btv._view_pending mirror.

Defined in view.lua.

btv.view.on_restore(fn, namespace)

btv.view.on_restore(fn[, namespace]) — register THIS plugin’s restorer for persisted views, AND immediately claim any slot already reserved for it. After a session restore, core reserves a slot (a placeholder window) for every persisted view the plugin had open; each reserved slot whose owning namespace matches is dispatched to fn(id, place):

  • id is the plugin-chosen persist string (the same one passed to create{ persist=}),
  • place(view) drops a freshly-created view into the reserved window. The plugin keyed its own btv.shada.plugin() store by id, so it reads its saved state back and rebuilds the view’s content before calling place. The owning namespace is resolved from the caller’s location; the optional namespace arg is the escape hatch for a no-attribution context (a bare :lua / RPC / test), the same contract as create’s.

Call it whenever the plugin has finished loading — it is safe to register LATE. A plugin loaded via btv.plugins({ config = … }) runs its config (and this call) on a tick after the server’s boot restore dispatch, so that pass never sees its handler; the drain-now loop below adopts the still-reserved slot at registration time instead. (Slots are reserved at shada_load, before any plugin code runs, so they are already present in the live btv._view_pending mirror whenever this is called.) Orphan slots are not reaped until every eager plugin load has settled (see btv._maybe_collapse_view_restores), so a late handler reliably gets its chance.

Defined in view.lua.

view:set_lines(lines)

:set_lines(lines) — replace the view’s content wholesale.

Defined in view.lua.

view:set_userdata(list)

:set_userdata(list) — opaque per-line data, parallel to the lines (1-based). The entry for the selected line is handed to on_select. Pure Lua state.

Defined in view.lua.

view:on_select(fn)

:on_select(fn) — fn(line, userdata) fires on <CR> / confirm / double-click, with the 1-based cursor line and that line’s userdata entry.

Defined in view.lua.

view:on_close(fn)

:on_close(fn) — fn() fires when the USER closes the view’s window (:q / :close / <C-w>c on the view buffer), letting the owner tear down a group of related views (e.g. close every diff pane when one is :q’d). It does NOT fire on a programmatic :unmount() / :close() — only the user close path records it — so the handler can freely close other views without recursion. Pure Lua state.

Defined in view.lua.

view:bufnr()

:bufnr() — the backing buffer number (from the mirror), or false before the view’s buffer exists: it is created after the chunk that creates the view. false, not nil, so it can’t pass for an absent option: { buffer = view:bufnr() } would otherwise read as “no buffer” and bind globally. The buffer APIs refuse false loudly. Wait for the number with btv.wait_for(function() return view:bufnr() end), or give btv.keymap.set the view itself ({ buffer = view }), which binds once the buffer exists.

Defined in view.lua.

view:winid()

:winid() — the window currently showing the view (from the mirror), or nil while the view is unmounted. The target for window-local options (vim.wo[winid]).

Defined in view.lua.

view:set_decor(ns, marks)

:set_decor(ns, marks) — replace namespace ns’s decoration on the view buffer with marks. Each mark is { line, col, <extmark opts> } (0-based line/col, then any nvim_buf_set_extmark opt: hl_group, end_col, virt_text, sign_text, gutter_text, priority, …). A no-op (warned) before the buffer exists.

Defined in view.lua.

view:mount(opts)

:mount(opts) — show the view. opts.dock = "left"|"right"|"top"|"bottom" mounts it in that dock (opts.size columns/rows); opts.tab = true mounts it as the sole window of a fresh tab page (no split — the view fills the tab; closing it closes the tab); opts.split = "vsplit"|"split" mounts it in a split of the main editor area; opts.here = true mounts it in the current window, in place of what that shows (no new window or tab: :q of a sole window quits the editor); opts.float = { … } mounts it in a floating window. Mounting focuses the view.

The float table takes width / height (inner size; required) — cells (a number) or a viewport fraction ("50vw" / "30vh" / "50%"), which reflows on resize — relative ("editor"|"win"|"cursor", default "editor"; "editor" is the WHOLE screen, dock bands included, so a centered float centers on the screen rather than on whatever region happens to be focused), and either the high-level align ("top-left"|"top"|"top-right"|"left"|"center"|"right"| "bottom-left"|"bottom"|"bottom-right") + margin (a gap from the edges: a number — the vertical gap, the horizontal sides getting 2x to look even since cells are ~2x taller than wide — or an explicit {vertical, horizontal} / {top, right, bottom, left} / {top=, …}), or the low-level anchor ("NW"|"NE"|"SW"|"SE", default "NW") + row / col (offset, default 0). Plus border (default "rounded"), title, zindex, focusable, and grab. grab (default true) hard-locks focus to the float like the bottom panel — <C-w> can’t leave it and unmount restores the prior window — which is what a modal dialog (a checkbox list, a confirm) wants; pass grab = false for a non-modal floating panel that focus can leave.

Defined in view.lua.

view:place_in(win)

:place_in(win) — retarget window win to this view’s backing buffer, instead of opening a fresh window like :mount: a session restore’s reserved slot (a placeholder window for a persisted view), or any other. This is what the place argument handed to an btv.view.on_restore handler calls (place = function(view) view:place_in(win) end), so a plugin rarely calls it directly.

Defined in view.lua.

view:unmount()

:unmount() — remove the view from view, keeping it (and its content) alive for a later :mount.

Defined in view.lua.

view:focus()

:focus() — move focus to the window showing the view.

Defined in view.lua.

view:line()

:line() — the view’s 1-based cursor line (from the mirror), valid while the view is focused. nil before the buffer exists.

Defined in view.lua.

view:cursor()

:cursor() — the view’s cursor as (line, col). col is always 0 in v1 (a view’s cursor rests at column 0); the line is :line().

Defined in view.lua.

view:set_cursor(line)

:set_cursor(line) — focus the view and move its cursor to 1-based line (clamped to the content; column 0). The reveal / find-file primitive — the one sanctioned cursor write; ordinary navigation is plain normal-mode motion.

Defined in view.lua.

view:redraw()

:redraw() — request a repaint. The editor already repaints at the end of every input batch / drained chunk, so this is a no-op kept for API completeness (and so a plugin can express intent at the call site).

Defined in view.lua.

view:close()

:close() — unmount the view and drop its backing buffer and registry entry.

Defined in view.lua.

btv.win

btv.win.current()

No documentation comment in the prelude.

Defined in api.lua.

btv.win.list()

No documentation comment in the prelude.

Defined in api.lua.

btv.win.set_current(win)

btv.win.set_current(win): focus win (make it the current window) [alias nvim_set_current_win]. win is a window id (0 / nil = the current window, a no-op). The switch is queued and applied after the Lua chunk like the other window ops; the mirror is updated write-through so an immediate btv.win.current() / current-buffer read in the same chunk reflects the new focus.

Defined in api.lua.

btv.win.buf(win)

No documentation comment in the prelude.

Defined in api.lua.

btv.win.width(win)

No documentation comment in the prelude.

Defined in api.lua.

btv.win.height(win)

No documentation comment in the prelude.

Defined in api.lua.

btv.win.textoff(win)

btv.win.textoff(win) -> the number of cells the window’s left gutter takes before its text: the fold column, the sign column and the number column together (what neovim reports as getwininfo()[1].textoff). btv.win.width(win) includes the gutter, so the text area is btv.win.width(win) - btv.win.textoff(win) wide, the width to centre content in. 0 for an unknown window.

Defined in api.lua.

btv.win.call(win, fn)

nvim_win_call(win, fn) / nvim_buf_call(buf, fn): run fn as if win/buf were current, returning fn’s result. In neovim these temporarily switch the editor’s current window/buffer for the duration of the callback; in bemtvi the callback runs synchronously in-VM, where “current” is the mirror the server pushed (btv._cur_win / btv._cur_buf / btv._cur_cursor). So these swap that mirror context for the call, run fn, and restore it — which makes every read inside the callback (nvim_win_get_cursor, nvim_get_current_buf, vim.fn.line/col/winnr, …) resolve against the requested window/buffer, and every explicit-handle write that bemtvi does expose (vim.bo[buf] option sets, nvim_buf_set_extmark(buf, …)) resolves the swapped mirror at call time and queues that concrete handle — so it, too, targets the right place.

What bemtvi CAN’T do is retarget a mutation that binds to “current” only at DRAIN time — an ex-command (vim.cmd), feedkeys, or an LSP buf request — since the queued-ops model applies those against the editor’s real current buffer/window after the chunk, which this call never actually switched. Rather than silently mutate the wrong context, btv._call_ctx_lock is set for the duration of a call whose target differs from the real current, and those funnels raise while it is set (see btv._assert_call_ctx). Plugins use these calls to read a window’s view/dimensions, which is fully faithful.

Defined in api.lua.

btv.win.config(win)

nvim_win_get_config(win): the float placement of win as neovim’s config map, or { relative = "" } for a tiled window. Reads the btv._wins mirror (the server pushes each float’s config into w.float; nvim_open_win / nvim_win_set_config write through it so a read within the same chunk agrees).

Defined in api.lua.

btv.win.saveview()

btv.win.saveview() [alias vim.fn.winsaveview]: the current window’s view — cursor position, scroll (topline/leftcol), and the cursor-restore fields neovim returns. bemtvi has no separate curswant/coladd/skipcol state, so those mirror col / are 0.

Defined in api.lua.

btv.win.set_topline(win, topline)

btv.win.set_topline(win, topline): scroll win so its first visible buffer line is topline (1-based, neovim/winsaveview convention; clamped to the last line).

Defined in api.lua.

btv.win.set_leftcol(win, leftcol)

btv.win.set_leftcol(win, leftcol): horizontally scroll win so its first visible screen column is leftcol (0-based). Only meaningful under 'nowrap'.

Defined in api.lua.

btv.win.set_cursor(win, line, col)

btv.win.set_cursor(win, line[, col]): move win’s cursor to line (1-based) / col (0-based byte column, default 0). The explicit-win counterpart of the (intentionally-absent) nvim_win_set_cursor, and the sanctioned caret primitive a pure-Lua editor feature drives with. It works mid-insert (a col one past the last char is legal there), so a snippet engine owning its own tabstop session jumps between tabstops by calling this from its insert-mode jump key — the caret moves and further typing lands at the new spot.

Defined in api.lua.

btv.win.selection(win)

btv.win.selection([win]) -> { s_row, s_col, e_row, e_col } or nil: the live selection in the current window (a Visual or Select selection, a modeless Shift selection), as the 0-based, end-exclusive byte range btv.win.select_range takes and btv.buf.text reads. Both ends fall on grapheme boundaries (a combining sequence or a ZWJ emoji is selected whole), so the text between them is never a cut character. A linewise selection runs from column 0 of its first line to column 0 of the line after its last. nil when nothing is selected. Only the current window has a live selection, so win must be 0, nil or its id.

Defined in api.lua.

btv.win.select_range(win, s_row, s_col, e_row, e_col, opts)

btv.win.select_range(win, s_row, s_col, e_row, e_col[, opts]): enter Select mode over the 0-based, end-exclusive byte range (s_row, s_col) .. (e_row, e_col) in win. The range is highlighted like a charwise Visual selection, but the next printable key / <CR> / <BS> replaces it (deletes the range and enters Insert with that input) — vim’s v_CTRL-G behavior. This is the Select-mode sibling of btv.win.set_cursor and the P6 snippet-engine primitive: a consumer selecting a non-empty placeholder ${1:default} calls this (so typing replaces the default); an empty range degrades to caret-plus-Insert at the start (an empty tabstop uses btv.win.set_cursor instead).

opts.on_escape controls where <Esc> (with nothing typed) leaves the kept selection:

"normal"   -- (default) keep the text, drop to Normal on the selection — vim's v_CTRL-G
"insert"   -- keep the text, park the caret in Insert past it (a snippet engine wants this,
           --   so it can keep editing the placeholder)

Defined in api.lua.

btv.win.restview(win, view)

btv.win.restview(win, view): restore win’s view from a winsaveview-shaped table (topline 1-based, leftcol 0-based, optional lnum/col cursor) — the explicit-win winrestview analogue. Only the present fields are applied.

Defined in api.lua.

btv.win.nr(arg)

btv.win.nr([arg]) [alias vim.fn.winnr]: the current window’s 1-based number (its index in the layout order), or with "$" the number of windows. (vim’s "#" previous-window form needs window history the mirror doesn’t keep, so it errors.)

Defined in api.lua.

btv.win.width_nr(nr)

No documentation comment in the prelude.

Defined in api.lua.

btv.win.height_nr(nr)

No documentation comment in the prelude.

Defined in api.lua.

btv.win.is_valid(win)

nvim_win_is_valid(win): whether win names a window the mirror knows about (0/nil is the current window, always valid while one exists). The window analogue of nvim_buf_is_valid — picker teardown/resize guards call it constantly.

Defined in api.lua.

btv.win.position(win)

nvim_win_get_position(win): the window’s top-left as 0-based {row, col} screen coordinates. Exact for a float (its placement); a tiled window’s screen origin isn’t carried in the mirror, so it reports {0, 0} — a documented approximation (a float-positioning plugin positions its own floats and reads their config directly, so the value it cares about is exact). 0/nil is the current window.

Defined in api.lua.

btv.win.getid(winnr, _tabnr)

btv.win.getid([winnr[, tabnr]]) [alias vim.fn.win_getid]: the window id for a 1-based window number (default: the current window). tabnr is accepted but only the current tab’s layout order is consulted (the global window mirror carries it).

Defined in api.lua.

btv.win.findbuf(bufnr)

btv.win.findbuf(bufnr) [alias vim.fn.win_findbuf]: the ids of every window currently displaying bufnr, in any tab (vim spans tabpages here).

Defined in api.lua.

btv.win.gettype(winid)

btv.win.gettype([winid]) [alias vim.fn.win_gettype]: "popup" for a float, "" for a normal window — the distinction a plugin draws to know whether a window is one of its own floats.

Defined in api.lua.

btv.win.screenpos(winnr)

btv.win.screenpos(winnr) [alias vim.fn.win_screenpos]: the 1-based (row, col) screen position of a window’s top-left text cell. Known exactly for a float (its placement); a tiled window’s screen origin isn’t carried in the mirror, so it reports {1, 1} (top-left) — a documented approximation float-positioning plugins tolerate (they position their own floats and read their config directly).

Defined in api.lua.

btv.wininfo

btv.wininfo.get(winid)

No documentation comment in the prelude.

Defined in api.lua.

btv.workspace

btv.workspace.dir()

btv.workspace.dir() -> the absolute workspace root (a string), or nil when this session has no workspace. Read-only — bemtvi chooses the workspace, not Lua. Natively that means a --workspace launch; for a daemon session it is the daemon’s directory. In the browser every session is a workspace (see btv.workspace.active()) and this is the session root — the OPFS root serverless, the daemon’s directory in a daemon session.

Defined in btv.lua.

btv.workspace.active()

btv.workspace.active() -> true when this session is workspace-scoped, false otherwise. Natively that means a --workspace directory launch. In the browser it is always true: the page’s ORIGIN is the workspace, since the shada blob lives in that origin’s OPFS and there is exactly one session per origin — which is what --workspace names natively. So a plugin keying per-workspace state off this gets a real workspace in every build rather than silently skipping persistence in a browser.

Defined in btv.lua.

bemtvi architecture

bemtvi is a modal, vim-style editor written in Rust: vim’s editing language (keystrokes, modes, ex-commands) on an idiomatic, rust-native, fully-async, client-server design. It is neovim-inspired: editing behavior tracks vim/neovim’s observable behavior, but every API is bemtvi’s own — configuration and extensibility live in the btv.* Lua namespace (the btv API; ADR 0002) — and the vim.* that remains is small and closed: a whitelist of muscle-memory aliases over btv (vim.g, vim.o, …, plus the highlight-registration helpers a colorscheme touches), so config and colorschemes can be written in familiar terms. They are aliases, not a separate API.

A pristine copy of neovim is vendored at vendor/neovim (a shallow git submodule) and used purely as a behavioral and source-layout reference. bemtvi does not link against or embed any neovim code.


Guiding principles

  1. Editing compatibility first. Keystrokes, modes, ex-commands, and options should match vim/neovim’s observable behavior. When in doubt, the reference in vendor/neovim is the source of truth. Note: bemtvi does not aim for neovim UI/client wire-compatibility — there is no ext_linegrid protocol and external neovim GUIs are not a target. The client↔server protocol is bemtvi’s own.
  2. A native plugin system; btv.* is the only API. Extensibility is bemtvi’s own provider-based plugin API (the btv design, ADR 0002): the server owns every UI surface and the frame; plugins supply data and behavior, asynchronously. Configuration is the same namespace: init.lua is written against btv.*. A closed whitelist of muscle-memory aliases (vim.g, vim.o/vim.opt, vim.cmd, vim.keymap.set, autocmd / user-command / highlight registration, vim.notify, the pure vim.tbl_*-style helpers, … — the canonical list is ADR 0002) maps 1:1 onto the same btv objects, so config can be written in familiar muscle-memory terms — aliases, not an API; beyond them there is no vim.* API. bemtvi does not host neovim plugins — they are imperative programs written against neovim’s runtime model (synchronous re-entrant state, blocking reads, libuv as a public API, frame-time render hooks), which this snapshot + effect-queue, client-server design deliberately is not. Colorschemes are bemtvi’s own: a colorscheme is Lua that registers highlight groups through the btv highlight API (and its vim.* aliases), nothing more. Supporting legacy Vimscript (.vim plugins, the eval.c language) is likewise an explicit non-goal.
  3. Dogfood the plugin API: first-party features are btv plugins. Everything that can reasonably be built as an btv.* Lua plugin is built as one. bemtvi’s own surfaces — the fuzzy picker, completion, statusline segments, snippets, tree docks, and the features layered on top of them — are the plugin API’s first and most demanding consumer, shipped as bundled btv plugins rather than as bespoke Rust. If a feature can’t be expressed against btv.*, that is a gap in the API to close, not a reason to reach behind it — so the API is proven by the editor that depends on it. The line is the one the architecture already draws: Rust owns the pure core, the frame / renderer, and the native engines (treesitter, LSP, regex) — a Lua plugin can’t be the renderer or the synchronous core; the orchestration, UX, and behavior composed on top of those primitives are Lua. “Makes sense” is the only escape hatch, and it means a genuine engine/frame/perf constraint, not convenience.
  4. Client-server, always. The editor is a headless server; every UI is a client. There is no “embedded-only” code path.
  5. Async and responsive. The UI never blocks on the editor and the editor never blocks on the UI. Slow work on one side cannot freeze the other.
  6. Rust-native, not a transliteration. We mirror neovim’s organization and behavior, not its C. We use a rope, ownership, enums, async tasks, and crates instead of globals, longjmp, and libuv callbacks.

Crate layout

The workspace is split into crates that map onto neovim’s src/nvim/ subsystems:

bemtvi crateneovim counterpartresponsibility
bemtvi-corebuffer.c, normal.c, ops.c, edit.c, ex_docmd.c, undo.c, option.cThe editor model: buffers, modes, motions, operators, ex-commands, undo, and the renderable View. Pure & synchronous.
bemtvi-rpcmsgpack_rpc/Async msgpack-RPC transport (bemtvi’s own protocol; msgpack is just the framing).
bemtvi-servermain.c, event/, api/The headless server: owns the core + Lua, hosts the nvim_* API, runs the async main loop. A library, embedded on its own thread by the bemtvi / bemtvi-gui binaries; the --daemon role reuses it as the remote fs/process half of the edit-host split.
bemtvi-lualua/Embedded Lua runtime (vendored PUC Lua 5.4, the single backend) and the btv.* Lua prelude (plus its bounded vim.* alias layer).
bemtvi-tuitui/The terminal UI client. A thin RPC client; owns no editor state.
bemtvi-tstree_sitter/The in-process treesitter engine: an ordinary library that loads installable grammars and parses incrementally, implementing bemtvi-core’s SyntaxEngine trait. Heavy C deps (tree-sitter, libloading) live here only.
bemtvi-lsplsp/The native LSP client: protocol, transport, manager — bemtvi’s own stdio spawning, driving the in-core editing features. bemtvi-server/src/lsp/ is the editing-loop glue on top.
bemtvi-regexregexp.c, regexp_nfa.cThe vendored vim regexp engine compiled as C (engine-global mutex): Lua’s vim.regex / vim.fn.substitute, and / search + :s under :set regexsyntax=vim.
bemtvi-view(UI layer)Frontend-neutral decode/input layer (View, Style, Key, notation, paste encoding) shared by the native clients.
bemtvi-gpu(a GUI frontend)The native GUI client with no toolkit: the wgpu + glyphon renderer, the editor sessions, the RPC IO thread, and the Client a host drives through effects and deadlines; the GUI sibling of bemtvi-tui, consuming the same View.
bemtvi-mitsuami—The client as a mitsuami view an app embeds (Editor, EditorHandle, SessionOptions): mitsuami’s input in, the client’s effects carried out on mitsuami.
bemtvi-gui—The GUI binary: a mitsuami window whose whole content is bemtvi-mitsuami’s view, plus the CLI, process signals and packaging.
bemtvi-edithost—The fully client-side WebAssembly build of the whole editor (core + Lua + server tick) in a Web Worker; excluded from the Cargo workspace. See The web build.
bemtvi-width—unicode-segmentation + unicode-width as a bare ~70 KB wasm module, so the web client’s DOM renderer measures grapheme clusters with the same crates and pinned versions the server’s unicode::virtcol does. That renderer runs on the main thread while the edit-host runs in a Worker, so it cannot call the editor; before this it mirrored the width tables in JS, which could drift on a dependency bump. Loaded by web/width.js, built by bemtvi-edithost/build.sh.
bemtvi-test-harness—The shared black-box integration-test harness (a publish = false dev-dependency of bemtvi and bemtvi-server): spawns a real server over an in-process RPC pipe and drives it as a UI client would. See Testing philosophy.
bemtvithe nvim entry pointWires an embedded server + the TUI client together over RPC.

Dependency direction is strictly one-way:

        bemtvi (bin)
        /         \
 bemtvi-server   bemtvi-tui
  / | | \         /    \
core rpc lua ts  rpc  view
      \_______/
       tree-sitter

The diagram shows the principal spine; the same one-way graph also carries the edges it elides — bemtvi and bemtvi-server both onto bemtvi-lsp, bemtvi-lua onto bemtvi-ts (the treesitter query/control bridge), bemtvi-gpu onto bemtvi-server + bemtvi-view (and bemtvi-mitsuami onto bemtvi-gpu, bemtvi-gui onto both), and bemtvi-lua onto bemtvi-regex (with bemtvi-core’s optional vim-regex feature — enabled by bemtvi-server — putting the same engine behind :set regexsyntax=vim).

The treesitter engine is a normal crate dependency now: bemtvi-server constructs it and installs it on the editor (which owns a Box<dyn SyntaxEngine> defined in bemtvi-core), then queries it synchronously at redraw. See Syntax highlighting below and the design at docs/specs/2026-06-06-in-process-treesitter-and-indentation-design.md.

bemtvi-core has no async, no I/O beyond file read/write, and no transport dependencies. That keeps the hard part — vim semantics — testable and portable, and lets every front end share identical behavior.


Client-server model

┌──────────────────────────┐         msgpack-RPC          ┌──────────────────────────┐
│  Client (bemtvi-tui)      │  ───── btv_input ─────────▶   │  Server (bemtvi-server)   │
│  • crossterm input       │  ◀──── redraw events ─────── │  • bemtvi-core (model)    │
│  • paints the grid       │  ───── btv_command ───────▶   │  • bemtvi-lua (btv.*)      │
│  • owns NO editor state  │  ◀──── responses ──────────  │  • nvim_* API surface    │
└──────────────────────────┘                              └──────────────────────────┘
        main thread                                              its own thread

The server is authoritative. The client sends input as vim key-notation ("i", "<Esc>", "<C-w>", …) and renders whatever grid the server pushes. A client could be terminated and reconnected, or several clients could attach to one server, without the server caring — exactly like neovim.

Embedded vs. remote

The default bemtvi invocation runs an embedded server: a headless editor on its own OS thread, and the TUI client on the main thread, connected by an in-process tokio::io::duplex pipe. The boundary is the same RPC every client speaks, so the embedded and edit-host-split cases are one code path. Putting the server on a separate thread (with its own single-threaded runtime) means UI rendering can never stall editor processing, and vice versa.

There is deliberately no “whole editor runs remote, thin client local” role. That topology — every keystroke round-tripping to a server on the far side of the wire — is structurally laggy, and bemtvi has a better answer for editing on another machine: open an SSH session and run bemtvi there (the editor is then fully local to that host), or use the edit-host split below.

The inverse remote topology — the edit-host / daemon split, where the editor + Lua run locally and only an bemtvi --daemon serving fs/process lives on the remote — moves the network boundary below the editor, so typing, motions, operators, and undo are all local (zero round-trips) and only fs/process/watch/LSP traffic crosses the wire. The local half (bemtvi --connect-daemon [file], or a bemtvi://… URI for the QUIC transport) is the same embedded editor as the default role, with its host seams (bemtvi_core::HostFs, bemtvi_server::HostProc, the async HostFsAsync, the LSP transport, and the Lua-facing bemtvi_lua::LuaFs) pointed at the daemon instead of the local disk. The GUI also reaches this split at runtime via :connect — :connect [user@]host[:port][/file] spawns ssh … bemtvi --daemon over stdio (with interactive prompts routed to a native dialog via SSH_ASKPASS, so auth works from a windowed launch with no tty), and :connect bemtvi://… dials the QUIC listener. The editor stays local; :connect swaps onto a fresh local server whose seams point at the daemon and re-attaches the same window (the GUI client’s IO-thread session loop, bemtvi_gpu::spawn_io), since the editor transport is always the in-process duplex. See the edit-host plan. It forces a split-brain filesystem rule for the Lua bridge, decided up front: the project-facing fs API — the async btv.fs.* (there are no synchronous Lua fs builtins) — routes through the LuaFs seam — local disk by default, the remote daemon in a split — so file-picker previewers, root detection, and VCS-status providers see the project; while raw Lua io.*/os.*, require/package.path, the runtimepath (nvim_get_runtime_file), and stdpath stay local by default — plugins and their caches live on the local machine (the divergence from VS Code’s remote-extension-host model). A session opts into the daemon’s config + plugins (and shada) with --remote-config — then the runtimepath is fetched over the wire and materialized into a local per-process cache; the browser, which has no local disk, is always remote-config. See the edit-host split.

Because the editor is local, a dropped connection must not tear the session down — the buffers, undo, cursor, windows, and Lua state all live on the local side and would be lost with it. So an ssh/stdio daemon link is reconnectable: a supervisor on a dedicated link thread (bemtvi_server::connect_daemon_reconnecting) keeps the seam handles the editor holds fixed and swaps the connection beneath them (a LinkRpc cell per leg group). On EOF (a laptop sleeping past QUIC’s idle timeout, an ssh hop dropping) it auto-retries with bounded backoff (ReconnectPolicy: 5 attempts over 0.5 → 8 s), re-spawning ssh … bemtvi --daemon; on success the seams rebind and editing continues. If the budget is spent it parks and tells the user to run :reconnect (:disconnect drops it on demand). The link’s DaemonStatus (Connected / Reconnecting{attempt,max} / Disconnected) rides a watch channel into the run loop, which mirrors it to btv.daemon.status() and fires a User DaemonStatusChanged autocmd so a statusline component can render it (green / yellow / red). A re-dialed daemon is a fresh process that knows nothing of the prior session, so on a genuine reconnect the editor re-syncs the seams off the tick: it re-opens LSP servers against the new connection (from each server’s cached spawn), re-arms every file watch carrying the buffer’s disk baseline so the daemon detects a file changed during the outage and pushes it (the unmodified buffer autoreloads; a locally-edited one is a conflict and is left alone), and surfaces that remote terminals/jobs were lost (their PTYs died with the link). Daemon-side session survival (persisting terminals/jobs across a drop) is explicitly out of scope. See the daemon-reconnect plan. (QUIC and the web/wasm edit-host stay one-shot for now — the ssh path covers the reported sleep/wake case; their reconnect is a later phase.)

Async design

Both sides run on single-threaded tokio runtimes (the editor core, like neovim’s, is single-threaded; concurrency comes from async I/O, not parallel mutation):

  • bemtvi-rpc::connect spawns independent reader and writer tasks, so encoding, decoding, and socket back-pressure never block the consumer.
  • The client multiplexes terminal input and incoming redraws with tokio::select!. Keystrokes are sent the instant they arrive; redraws are painted as they come.
  • The server processes one RPC message at a time against the (non-Send) editor and Lua state, while the RPC tasks keep the wire moving underneath it.

The editor and Lua state are intentionally !Send and live on a single thread, which is why the server gets its own thread/runtime rather than being spawned onto a shared pool.

Multi-source scheduling & event ordering

The server’s tokio::select! loop (bemtvi-server::run) multiplexes ten event sources against the single-threaded editor: RPC input from the UI, the LSP manager, the async-runtime actor (evloop.rs — timers and child processes), terminal child output, off-tick file opens, write-acks, and :cd completions from the daemon fs seam, :TSInstall grammar-build completions, daemon file-watch events, and daemon link-status changes. The first three are the always-on primary sources; the rest service the terminal, edit-host/daemon, and treesitter-install features. Treesitter highlighting is not among them — the engine runs in-process and is queried synchronously during redraw, so it needs no channel or arm. Each source is an mpsc channel (the link status a watch); the matching async actor (a Send background task) only ever ferries ids / bytes / durations back, never the !Send editor or Lua state. This is bemtvi’s analog of neovim’s main-thread + worker-thread model, where workers hand results to the one editor thread by enqueuing events.

Two ordering properties hold, and one is a deliberate divergence worth recording:

  • Serialization (preserved). Every select! arm body runs to completion before the next loop iteration — the off-tick arms are fully synchronous, and each ends in the settle contract (apply_lua_effects → run_pending → redraw) so a callback’s deferred work converges and repaints at a redraw-followed point, never “too early”. Two events can never interleave their mutations: neovim’s “editor logic never runs concurrently with itself” guarantee, enforced here by the crate boundary (bemtvi-core is pure/sync) and the !Send VM rather than by neovim’s runtime recursive-abort.
  • Per-source order (FIFO). Each channel delivers in arrival order, and the per-arm coalescing (while try_recv()) drains a burst in order before one repaint.
  • Cross-source order (divergence). When events from different sources are ready in the same poll, plain tokio::select! picks a ready branch pseudo-randomly. Neovim’s parent/child MultiQueue instead imposes a deterministic relative order by enqueue time. We accept the weaker guarantee: the random pick buys anti-starvation fairness, and because every arm fully settles, the nondeterminism is limited to which independent source lands first — never to interleaving or corruption. A timer-vs-keystroke wall-clock race is inherently timing-dependent in neovim too.

The biased; option (possible future change). tokio::select! accepts a leading biased; that switches branch selection from random to top-to-bottom in declaration order. Adding it would make cross-source scheduling deterministic — the closest analog to neovim’s multiqueue ordering — and turn the arm declaration order into an explicit priority (e.g. input first, so keystrokes are never delayed behind background timers/LSP). It is intentionally not enabled today because:

  • the current random selection is the simpler default and provides fairness for free, and no observed workload depends on cross-source ordering (each arm settles independently, so the relative order of background sources carries no correctness dependency);
  • biased; makes the developer responsible for starvation — a branch that is perpetually ready (e.g. a sustained input flood, or a tight self-re-arming timer) would starve every lower-priority branch, whereas random selection cannot. Our per-arm burst-coalescing bounds this in practice, but it is a real footgun the default avoids.

Adopt biased; if a future need arises: a reproducibility requirement (a test or behavior that must see input drained before a same-tick timer), or a responsiveness bug where background work visibly preempts input. The change is one line plus a deliberate arm ordering — recommended order input → LSP → loop (timers/processes) (user-facing first) — and must be paired with a starvation review of the now-highest-priority arm.


Protocols

RPC framing (bemtvi-rpc)

A standard msgpack-RPC framing — chosen because it’s a good async binary protocol, not for neovim interop. Messages are msgpack arrays:

  • Request: [0, msgid, method, params]
  • Response: [1, msgid, error, result]
  • Notification: [2, method, params]

The client-protocol verbs — the ones a UI actually speaks (input, attach, resize) — are btv_*: btv_input, btv_input_mouse, btv_ui_attach, btv_ui_try_resize, btv_command. A paste rides btv_input too, as one batch wrapped in <PasteStart> … <PasteEnd> (what bemtvi_view::encode_paste emits). The brackets mark the payload as text the user already had rather than keys they typed, and the server collects what they enclose instead of dispatching it: in ordinary Insert mode the whole payload goes into core as a single edit (Editor::paste_literal), so it is one rope insert and one settle, and — never having become keys — it cannot trip a mapping, a snippet jump or a completion confirm on the way in. A paste is clipboard text anyone can shape, so it never runs as commands: in Normal and Visual mode core puts it (charwise after the cursor, or over the selection — Editor::paste_put, neovim’s vim.paste). Modes whose behavior is inherently per-character (Select, which types over the selection and goes on in Insert, the command line, a terminal job, a grabbing menu, Replace’s overtype) replay the payload key by key inside the span, where the insert-mode helpers — auto-indent, soft tabs, auto-pairs — still stand down; that replay carries only text keys (a control byte in the clipboard cannot leave the mode), and on the command line or in a menu only the first line, so a pasted line break cannot submit it. Either way the span is recorded around the payload, so a . of a change containing a paste re-enters paste mode rather than re-indenting what it replays. Because it is only notation, every transport (local, daemon, browser) gets all of this for free. The editing-API methods keep the familiar neovim spelling (nvim_buf_get_lines, nvim_open_win, nvim_win_set_cursor, …) as muscle-memory names, but they are bemtvi’s own methods with bemtvi’s own semantics (a read-heavy subset is also aliased into Lua as vim.api.*; the window/tab mutation verbs are RPC-only).

View protocol (UI)

The core projects editor state into a View: a list of windows plus the global chrome. Each WindowView carries one window’s rect, focus flag, visible text rows, cursor, selection/search spans, gutter numbers, and status-line data (file name, modified flag, ruler); the View adds the inter-split separators and the global fields one editor has (mode label, command line, message, the 'showcmd' corner string — the pending run, or the selection size while one is up — and the list-overlay menu). The server sends it as a single redraw notification carrying one msgpack map (a windows array + a separators array + the global keys). With one window the list has a single entry spanning the whole text area, so the frame is identical to the pre-windows view. (See Windows.)

The View also carries the editor’s styled regions: selection, a per-row array of half-open screen-column spans [start, end) marking the visual-mode selection (None for unselected rows). The core resolves the selection to screen columns (so wide chars and tabs are already accounted for); end may run one cell past a line’s text to mark a selected newline, or to the viewport edge for a linewise selection. The core owns which cells are in it.

Color ownership lives on the server. Originally the client owned how every group looked (a hardcoded ANSI theme). A colorscheme (catppuccin) moves that decision into the editor: a Lua theme defines the concrete color of every highlight group via nvim_set_hl (see Lua). So the server now resolves each group to a concrete style and the redraw carries styles, not bare group names — matching real neovim, where highlight groups + termguicolors live in the editor and the UI just paints attributes. Concretely the redraw map carries:

  • a per-frame styles palette — an array of resolved styles { fg, bg, sp, bold, italic, … } with colors as 24-bit 0xRRGGBB ints, deduped so identical styles cost one entry;
  • the per-row highlights array (aligned with lines) of screen-column spans [start, end, group, style_id], where group is the treesitter capture name and style_id indexes styles (or is nil when no colorscheme resolved it);
  • a chrome map of editor-region → style_id for the text background (Normal), the number gutter (LineNr, CursorLineNr), the cursor line (CursorLine), the visual selection (Visual), search (Search, IncSearch), the status line (StatusLine), errors (ErrorMsg), the end-of-buffer filler (EndOfBuffer), and float chrome (FloatBorder, NormalFloat, FloatTitle).

The server still owns which cells are in a group (byte offsets resolved to screen columns via the same tab/wide-char virtcol the selection uses); it now also resolves group → style. The client is a dumb truecolor renderer: it paints the Normal background across the text area, themes the gutter/selection/status from chrome, and colors each span from its style_id. When a span carries no resolved style (no colorscheme loaded), the client falls back to a small built-in theme, so default startup looks exactly as before. (See Syntax highlighting.)

The same split governs the number column: each WindowView carries the per-row 1-based buffer line numbers (numbers, None for ~ filler rows), the number/relativenumber option flags, and the gutter width (number_width, sized like vim’s numberwidth). number/relativenumber are window-local — a WindowOptions lives on each window, set via :set/:setlocal/vim.wo, and a split inherits them from the window it splits off — so two windows onto the same buffer can show different gutters. The core owns what each line’s number is; the client renders the gutter as its own ratatui widget — a horizontal split off the left of the text area — and decides how it looks, computing the relative offsets and the hybrid absolute-on-cursor-line formatting from that data. Text, selection, and cursor columns are all measured from the text sub-area, so they stay gutter-agnostic.

The client owns chrome layout, but the window rects come from the core. The client paints each WindowView at its rect — splitting off that window’s gutter, drawing its text/selection/search, and a status line on its bottom row — then draws the separators between splits and reserves the bottom row for the global command/message line (the panel, being an ordinary bottom-split window, is just another WindowView). The terminal cursor is drawn only in the focused window. Because the core lays out the windows (vertical splits divide width), the client reports both dimensions of the windows area on btv_ui_attach/btv_ui_try_resize. There is still no grid, no cell encoding, and no ext_linegrid.

Folds collapse in the projection, not the buffer. A fold is a per-window property — vim’s model, so the same buffer folds independently in two windows — but the fold structure (a nested set of inclusive line ranges) derives from buffer content. The core keeps the two separate: a per-window FoldState (which folds exist and which are closed) plus, for the computed sources, a structure cache keyed on (changedtick, method, …). A closed fold simply drops its hidden lines from the per-row vectors (lines/numbers/highlights/…) and emits one placeholder row with the fold’s text, so the client renders it as a single line with no protocol change — the collapsed-fold shape the parallel-array projection already had. Every caller that walks visible lines (motion, scroll, the cursor, redraw, and the linewise operators that act on a whole closed fold) goes through the same fold_line_start/fold_line_end helpers, so none hand-rolls fold skipping. The fold structure comes from one of five sources behind a shared spine (fold.rs): manual (zf/:fold), foldmethod=indent, foldmethod=marker (folds bounded by the literal 'foldmarker' strings, default {{{/}}}), foldmethod=expr (the native tree-sitter foldexpr bemtvi recognizes as a marker and computes from folds.scm, or a generic Lua 'foldexpr' the server evaluates per line with v:lnum and pushes in), and LSP textDocument/foldingRange (selected by the btv.lsp.foldexpr marker, requested per buffer and pushed in). The generic-expr and LSP are external sources: bemtvi-core can’t run Lua or talk to a language server, so the server computes them out-of-band and pushes the result into a per-buffer store the core builds the same nested structure from — and manual folds (with their closed state) round-trip through shada, restored into the window that reopens the file. The foldcolumn gutter is a client-rendered column (like the number gutter) carrying the +/-/│ markers. See docs/plans/2026-06-25-tree-sitter-folds.md.

Floating windows are a second, on-top layer. Each WindowView carries floating, border (none/single/rounded/double/solid), and title. The list is ordered tiled-windows-first, then the floats bottom-to-top by (zindex, id) — the same order nvim_list_wins reports — so the client renders in two passes: it tiles the non-floating windows and their separators, then overlays the floats in list order. Each float is made opaque (Clears the cells it covers), draws its border + title, and paints its own gutter/text/status one cell inside the border; a focused float owns the terminal cursor. The completion pmenu stays the highest layer, above the floats. The core sizes a bordered float’s content lines to the inset (rect minus one cell each side) so the projection and the painted box agree; the float’s outer rect is what the client draws the border around. (See Windows.)


Text model

Buffers are backed by a ropey 2.0 rope (bemtvi-core’s Buffer). Indices are byte offsets — ropey 2.0’s native metric, and the same column model vim uses — with lines tracked via ropey’s LineType::LF_CR (so both Unix \n and DOS \r\n files split correctly). Editing operations snap byte ranges to char boundaries (floor/ceil_char_boundary) so a multi-byte character can never be split; for ASCII this is all a no-op. The key invariant: the rope always ends with a trailing \n, so an empty buffer is "\n" (one empty line) and the editable line count is rope.len_lines() - 1. The phantom final line is never displayed or edited.

That phantom \n is bemtvi’s spelling of the implicit newline vim’s line model puts after every line, including the last — the same model, not a different one. It follows that the rope cannot say whether the file on disk really ended with one, so that fact lives in two buffer-local options instead: 'endofline' (set from the bytes on read) and 'fixendofline' (on by default, as in vim — a write supplies a missing terminator unless you opt out). Buffer::document_text() derives the bytes the buffer represents from the rope plus 'endofline', and it is the single definition every consumer uses: to_save_bytes writes it (so a 'nofixendofline' file round-trips byte for byte, and a 0-byte file stays 0 bytes), and the LSP layer sends it as the document text and reads server edit ranges against it. See docs/plans/2026-07-26-endofline.md.

The rope is always UTF-8 (mirroring neovim, whose internal encoding is UTF-8); the on-disk byte form is a separate concern named by the buffer’s 'fileencoding'. All charset conversion lives at the byte↔rope seam (bemtvi-core’s encoding module): on read, decode_to_rope tries 'fileencodings' in order — BOM sniff, strict UTF-8, then a latin1 (windows-1252) fallback that, being a total, bijective single-byte codec, opens any byte stream (latin1, utf-16, even invalid-UTF-8) and round-trips it exactly; on write, encode_from_str converts the rope back to 'fileencoding' (re-emitting the BOM when 'bomb' is set) and fails loud (E513) on a character the target can’t represent rather than corrupting the file. Every read path (local Buffer::from_file, the daemon replica, …) funnels through the one decoder and every write through the one encoder, so a file behaves identically however it’s reached.

Motion steps by grapheme cluster and the cursor’s display column is computed as a virtual column (wide characters via unicode-width, tabs expanded to the buffer’s tabstop), carried in the View as cursor_screen_col. cursor.col remains a byte offset (what nvim_win_get_cursor returns); the TUI expands tabs when painting so glyphs line up with that virtual column.

Undo is a branching undo tree of full-rope snapshots (cheap thanks to ropey’s structural sharing): undoing then making a new edit forks a branch rather than discarding the old future, so every past state stays reachable. u / <C-r> walk parent / newest-child, while g- / g+ (and :earlier / :later, by count, by time, or by file write) walk the states in the order they were made, across branches — reaching states no u can. :undo {N} jumps to any seq, :undolist lists the branch tips, and vim.fn.undotree() projects the tree in neovim’s dict shape — closer to neovim’s undo.c than the original two-stack model. Each node holds a full snapshot, so 'undolevels' (default 1000) bounds the history: the oldest states are pruned by re-rooting the tree, which discards the branches that forked below them. Node timestamps are monotonic seconds since the editor’s time base, not wall clock, so ages are rendered relative ("3 minutes ago") rather than as the clock time vim prints for older entries.

The steps are not vim’s: an insert session commits a node at each word end, line break, typing/deleting switch, pause, or cursor move (Editor::before_typed_edit in editor/undo.rs), while a paste, a mapping’s output and a replay stay one node. The server brackets mapping output with begin_undo_atomic/end_undo_atomic, since only it knows which keys a mapping produced. See Undo steps.

A modeless session ('modeless', GUI and web only) adds no Mode. Every buffer rests in Insert instead of Normal (a live terminal in Terminal), and one settle point keeps it there: Editor::settle_modeless (editor/modeless.rs) moves a clean Normal into Insert. A read-only buffer rests there too; one guard ahead of the Insert/Select dispatch (Editor::refuses_modeless_key) refuses every key that would edit it with E21, so its own keys must be maps in the modeless layer. The server calls settle_modeless after every applied key and command, before it diffs the mode for InsertEnter/ModeChanged, and the core calls it when a mapping or fed span closes. Any path that drops into Normal is corrected there rather than taught about the setting. The selection is Select mode with a caret anchor and head of its own (CaretSel), and the key layout is a keymap layer the server matches in tries built as the Insert (or Select) maps with the modeless maps laid over them. The option is gated on btv_ui_attach’s client capability, so a terminal can’t turn it on. See Modeless editing.


Buffers

The editor holds multiple open buffers and switches the one window between them. bemtvi-core’s Editor separates the two concerns vim keeps apart:

  • Buffer state (the “file”): the rope text, path, modified, changedtick, the edit journal, and per-buffer undo/redo history. These live in an OpenBuffer (the text Buffer plus its branching undo tree and the cursor/scroll position saved while the buffer is not current), stored in a BufferStore keyed by a monotonic, 1-based BufferId that is never reused.

  • Window state (the “view”): the live cursor, scroll top, mode, and pending-input state stay on Editor, alongside current (the shown buffer) and alternate (vim’s #). The register and the search options are still global, but options come in three scopes, mirroring vim:

    • The indentation options (tabstop / shiftwidth / softtabstop / expandtab) are buffer-local — a BufferOptions lives on each Buffer, set via :set/:setlocal/vim.bo, so two buffers can indent differently. bemtvi’s defaults differ from vim’s: tabstop is 4, and shiftwidth/softtabstop follow it via their 0/-1 sentinels (softtabstop → shiftwidth → tabstop), so one knob sets the indent width.
    • The number-gutter options (number / relativenumber) are window-local — a WindowOptions lives on each window, set via :set/:setlocal/vim.wo (and nvim_win_{get,set}_option / scoped nvim_{get,set}_option_value), and a split inherits them from the window it splits off, so two windows onto the same buffer can show different gutters.

    Both non-global scopes are global-local, as in vim: alongside each instance’s local value there is a global one (Editor::buf_opts_global / win_opts_global) that :setglobal / vim.go / vim.opt_global read and write, :set / vim.opt write together with the local, and :setlocal / vim.bo / vim.wo leave alone. For buffers the global value is a seed — Editor::add_buffer (the crate’s sole buffer-creation funnel) is what makes a config’s :set tabstop=3 reach files opened later rather than only the buffer that was current while init.lua ran. For windows it is not a seed (a split copies the window it came from); it is what seeds a window minted with no source to copy — a dock, the quickfix tab. A handful of buffer options carry no global value at all — the four the read decides (fileencoding, bomb, fileformat, endofline), the modifiable marker, and the two nouns derived per buffer (filetype, ts_highlight) — and :setglobal on one fails loud (E5100) rather than storing a value nothing reads. options::has_global_tier is the single place that question is answered: the ex path rejects by it, and the Lua surfaces derive their routing tables from it through the injected option catalog. See docs/plans/2026-08-01-global-local-options.md.

Editor::buffer() / buffer_mut() resolve the current buffer through the store, so the editing code is oblivious to how many buffers are open. There is always at least one buffer; deleting the last leaves a fresh [No Name].

The surface is the usual vim set: :e (open-or-switch, reusing the throwaway [No Name]), :enew, :ls/:buffers, :b{N|name|#}, :bnext/:bprev/ :bfirst/:blast, :bdelete/:bwipeout, <C-^>, and multi-buffer :wall/:qall. The RPC layer mirrors neovim’s nvim_list_bufs, nvim_get_current_buf, nvim_set_current_buf, nvim_create_buf, nvim_buf_get_name, and a buffer-addressed nvim_buf_get_lines.

:q is window-aware (see Windows): with more than one window open it closes the current window; only on the last window is it a real editor quit, which — like :qa — refuses when a modified buffer would be lost, switching the window to that buffer and reporting E37 (so you see what’s blocking), matching neovim’s last-window behavior with hidden buffers. :q! / :qa! exit unconditionally.

A buffer’s filetype is derived from the path, not set at read. :setf / btv.bo.filetype pin an override; absent one, language_of_path consults three tables, most specific first — the exact basename (Makefile, .bashrc, go.mod, the .*ignore family, the dotfile configs whose syntax is a general-purpose format), a deliberately small set of globs on bemtvi’s own glob engine (.env.*, Dockerfile.*, and the names that only mean something in one directory, like config under .ssh/), and the extension. A path no rule claims falls back to the interpreter named by a leading #! line — never an override, so a .lua file starting #!/bin/sh is still lua, and only an unclaimed path pays for the single-line read. Because it is a derive rather than vim’s set-at-read, a rename retypes the buffer immediately and a buffer that never went through a read chain — a picker preview, a fresh scratch — is typed like one that did.

The treesitter engine tracks each buffer independently: it keeps a parse tree + shadow text per BufferId (the editor owns the engine), the server memoizes the projected spans per (BufferId, changedtick, viewport), and a :bdelete forgets both — so switching back to a buffer paints from its live parse instead of re-opening. (See Syntax highlighting.)


Windows

A window is a viewport onto a buffer; splitting creates more of them, tiled by a layout tree. bemtvi mirrors the buffer split: just as buffer state was factored out of Editor, window state is now multiplied. Editor holds a WindowTree — a BTreeMap<WindowId, Window> keyed by a monotonic, never-reused WindowId, plus a Node tree (Leaf(WindowId) | Split { dir, children, sizes }) arranging them and a current (focused) id. Each Window binds a BufferId and, while not focused, stashes its saved_cursor/saved_top; the focused window’s live cursor/scroll stay on Editor (so the whole motion/ operator state machine is untouched). The current buffer is derived from the current window — :b/:e rebind the focused window’s buffer.

  • The core owns layout. WindowTree::layout(total) divides the area: an HSplit stacks children (dividing height, a ─ separator row between each), a VSplit places them side by side (dividing width, a │ column between). sizes are normalized to cells on every layout, so resizing is plain cell arithmetic and a terminal resize rescales proportionally. Each leaf’s text height is rect.height - 1 (its own status line).
  • Surface. Splits: :split/:vsplit/:new/:vnew and <C-w>s/<C-w>v. Focus: <C-w>h/j/k/l (spatial), <C-w>w/<C-w>W (cyclic). Move: <C-w>H/J/K/L swaps the focused window’s buffer (and its view) with the spatial neighbor on that edge, focus following the buffer (swap_window_dir). Close: <C-w>c/ :close, <C-w>o/:only, :hide, <C-w>q/:q. Sizing: <C-w>=, <C-w>+/-/</> (with counts), <C-w>_/<C-w>|, :resize/:vertical resize. focus_window is the window analogue of the buffer switch: it stashes the outgoing view, restores the incoming one (cursor re-clamped), and clears transient state.
  • RPC / Lua. nvim_list_wins, nvim_get_current_win/nvim_set_current_win, nvim_win_get_buf/set_buf, nvim_win_get_cursor/set_cursor (window-handled, 0 = current), nvim_win_get_width/height + setters, nvim_win_close, nvim_win_get_config/nvim_win_get_position, and nvim_open_win (both the split form and the float form). Window reads (plus nvim_set_current_win) are aliased into Lua and resolve against the btv._wins mirror the server pushes before each Lua entry; the window mutation verbs are RPC-only — internally they queue a WindowOp drained into the core (the same queue the private btv._open_win/btv._win_* bridges feed for btv.view and the UI primitives), but no public Lua nvim_open_win/nvim_win_set_* binding exists.
  • Floating windows. A float is a Window the layout tree does not own: it lives in WindowTree.floats (ids kept sorted by (zindex, id)), carries a FloatConfig (relative editor/win/cursor, anchor, row/col, width/ height, zindex, focusable, border, title), and is positioned absolutely by a second layout() pass after the tiled rects are known — so it steals no space from its siblings and paints on top. nvim_open_win with a non-empty relative opens one (an RPC verb, via WindowOp::OpenFloat; from Lua, floats come from btv.view:mount{ float = … } / btv.ui.float); the client draws it as an opaque, bordered, titled overlay above the tiled layout (see View protocol). Focus, the window list, and close already span floats because they key off WindowId. nvim_win_set_config/get_config move, resize, restyle, and convert a window between float and split. Unsupported config values (relative="mouse", an unknown border) fail loud rather than silently falling back to a tiled split. Edge semantics (matching neovim): :q on a focused float closes only the float and never quits — the “last window” quit rule counts tiled windows only, so closing the last tiled window quits even with floats open, and a tiled window can’t be closed down to floats-only. :only/<C-w>o close every float too. <C-w>w/<C-w>W cyclic focus includes focusable floats (in z-order, after the tiled windows) and skips non-focusable ones, though nvim_set_current_win can focus either explicitly; the spatial <C-w>h/j/k/l moves stay within the tiled grid. Closing a window also closes every float anchored to it (relative="win", transitively). A terminal resize re-runs the float pass, re-clamping editor-relative floats back on-screen. The lifecycle diff fires WinNew/WinEnter/WinClosed for floats and WinResized when set_config changes a float’s size.
  • Permanent docks. A dock is a global (cross-tab) editable window region pinned to a screen edge — bemtvi’s VSCode-style side bars and bottom tray. It reuses the tab-swap trick along a second axis: Layer::{Main, Dock(Left | Right | Top | Bottom)}, with the focused layer’s tree always live on Editor::windows (the rest park per Tab pages above), so split/close/focus/editing/redraw act on whatever layer holds focus with zero retargeting. <C-w><C-w> is the layer-switch prefix: <C-w><C-w>{h,j,k,l} crosses focus spatially between the regions — full-width top/bottom bands around a left|main|right middle band — stepping to the open region in that direction and wrapping past the far edge (cross_dir_candidates/cross_dir_target; a no-op when nothing in that direction is open), <C-w><C-w>{H,J,K,L} moves the focused buffer to the layer on that edge (the dock on that side — a no-op if it is closed — or back to main from a dock; the source window falls back to a sibling/empty buffer of its own layer via move_buffer_to_layer), and <C-w><C-w>{cmd} crosses then runs the command, while a single <C-w> stays in the focused layer. The layer-switch chord is mode-independent — a small state machine (Editor::dock_chord_intercept, run ahead of the per-mode dispatch in input) reaches the docks from insert, visual, command and terminal mode too, leaving the source mode cleanly before it crosses; only Normal/MultiCursor keep the grammar’s own <C-w> path (which also owns the single-<C-w> window commands). A lone <C-w> in those modes is held one key and replayed on a non-chord follow-up, so its original meaning is preserved. The round trip is mode-transparent: every cross parks the source window’s mode (Window::resume) and the target window resumes its own parked mode (execute_window_layer → enter_window → reestablish_mode), so popping over to a dock and back lands you in the same insert/visual/terminal state you left, the cursor where it was — while ordinary window/tab/mouse focus changes still land in Normal (the resume is gated on a one-shot the chord sets). relayout carves the four edge bands (each reserving a separator cell toward the main area, clamped so the main rect keeps ≥1 col/row) and lays every layer’s tree out at origin (0,0) in its own region; View carries the band sizes and tags each window with its WindowRegion, and each client maps region → absolute origin (the core owns which cells, the client owns where). Surface: the btv.dock.* Lua table (open{side,size?,buf?,title?,showtabline?,autohide?} / close / focus, plus the per-dock option scope btv.dock.opt(side)) and the :DockOpen/:DockClose/ :DockFocus ex-commands, queued as a DockOp drained into the core. Mouse: hit_test resolves a click across every region (the focused layer plus each parked dock tree, via region_geoms), so a left-click in any dock focuses it and places the cursor — set_current_window crosses to that layer first. A dock can also be hidden (toggle / auto-hide): dock_hidden[side] collapses it from view while keeping its whole TabStack parked, so its splits/tabs/cursor/text all return when shown again — distinct from closed (which drops the trees). dock_is_open is the visibility predicate (= present and not hidden) that every layout / render / mouse / enumeration site reads, while the tree-resolution helpers read dock_tabs directly so a hidden dock’s content stays addressable. btv.dock.toggle/hide/show (and :DockToggle/:DockHide/:DockShow) drive it; the per-dock autohide option hides a dock the moment focus leaves it (a hook in switch_layer, the one chokepoint for every focus cross). A hidden dock isn’t invisible: View.hidden_docks carries a label per collapsed dock, which each client paints as a clickable ▸{label} chip on the idle command-line row (hidden_chip_at maps a click back to show_dock). The buffer list is per-layer: each buffer carries the window layer it was last shown in (OpenBuffer::layer, set by set_cur_buffer/set_window_buffer), so :ls and :bnext/:bprev list only the focused region’s buffers, closing a document falls back to a sibling in the same layer (never pulling a dock’s buffer into the main area), and btv.buf.list{focused=true} exposes the focused-layer list to Lua (nvim_list_bufs stays global). (Design: docs/plans/2026-06-14-permanent-docked-panels.md, docs/plans/2026-06-14-dock-toggle-autohide.md.)
  • Autocmds. WinNew/WinEnter/WinLeave/WinClosed/WinResized fire from the same server-side lifecycle diff as the buffer events, ordered WinLeave → BufLeave/BufEnter → WinEnter around a focus change.
  • Shared per buffer. Two windows onto one buffer share its SyntaxState, diagnostics, and undo — each just projects a different (top, height) slice. The register, command line, and message line stay global; the number-gutter options (number / relativenumber) are window-local (a WindowOptions per window, set via :set/:setlocal/vim.wo).

Horizontal scrolling rides WindowOptions too: each window tracks a leftcol (the first visible screen column, the horizontal analog of the vertical top) and, under nowrap, scrolls sideways to keep the cursor visible — governed by the window-local sidescroll / sidescrolloff. The core decides leftcol (ensure_visible_horizontal, called on the same beat as the vertical ensure_visible); the client paints from that offset, leaving the number gutter fixed. (Design: docs/plans/2026-06-07-horizontal-scrolling-and-wrap.md.)

Tab pages multiply the window layout the same way BufferStore multiplied the buffer — and they do it per region: the main area and each open dock (see Permanent docks below) carry their own independent tab stack. Each layer owns a TabStack { tabs: Vec<TabSlot>, current }, so Editor holds main_tabs plus dock_tabs: [Option<TabStack>; 4]. The focused layer’s active tab is the live WindowTree on Editor::windows; every other (layer, tab) tree parks in its TabSlot, so across the whole editor exactly one slot is None (the live one) — the same invariant tabs always kept, now spanning two axes. A switch (gt/gT/:tabnext/nvim_set_current_tabpage) is a mem::swap of the live tree with the target’s stash, then re-enters the incoming tab’s focused window — the tab analogue of focus_window, so the entire editing machine is untouched. :tabnew/:tabedit/<C-w>T create one (window ids minted globally off Editor::next_win_id so they never collide across any layer/tab); :tabclose / :tabonly refuse main’s final tab, :q on a tab’s last window closes the tab, and closing a dock’s last tab closes the dock. Tab ops act on the focused region — gt in a focused dock cycles only that dock’s tabs — while the nvim_tabpage_* API stays main-only (it crosses to main first). Reads resolve against a btv._tabs mirror and nvim_set_current_tabpage queues a TabOp, the same “Lua queues, core mutates” flow as windows; the lifecycle diff fires TabNew/TabLeave/TabEnter/TabClosed, bracketing the window events (TabLeave → WinLeave → … → WinEnter → TabEnter).

Tablines are per region too. Each region draws its own tabline: the main area’s is the editor’s top bar (below a top dock, if any); each dock’s is the first row of its band, with the tree laid out below it. The View carries a RegionTablines { main, docks[4] } — each a Vec<TabView> (focused buffer name + modified flag + window count), an active index, and the dock’s title — gated by each region’s own showtabline (a dock may override the global option, or force its strip on with a non-empty title), and the server’s relayout reserves tabline_rows_for(layer) per region. A left-click on any region’s tabline cell switches that region to the clicked tab and focuses it: Editor::region_tabline_at reconstructs the same per-region band geometry the clients paint, then maps the cell (past a dock’s title prefix) to a (layer, tab). (Design: docs/plans/2026-06-07-tab-pages.md, docs/plans/2026-06-14-per-region-tablines.md.)

Already on WindowOptions: the number gutter (number/relativenumber/ numberwidth), the cursor-line highlight (cursorline), soft word-wrap (wrap, with its gj/gk display motions, linebreak/breakindent/showbreak), the horizontal-scroll options, and the later arrivals — scrolloff, signcolumn, colorcolumn, foldcolumn, fillchars, winhighlight, list/listchars. Still pending: the long tail of vim’s window-local options beyond those. Floating windows are otherwise complete (model, paint, dynamic config, edge semantics); the remaining float fidelity knobs (style="minimal", footer, bufpos, relative="mouse") grow as a consumer demands them. All four laststatus modes ship (0 never, 1 only with ≥2 windows, 2 per-window default, 3 a single global status line).


The panel (transient bottom overlay)

Multi-line, browsable output — :messages, :ls/:buffers, :registers, :marks, :jumps, :changes, and scripted listings — lives in a panel: a transient, focus-locked overlay shown in a bottom split. A panel is not a bespoke widget with its own content model; it is an ordinary nomodifiable buffer in a botright split, with two properties layered on top. It is bemtvi-native, closest in spirit to neovim’s quickfix window.

  • It’s an ordinary buffer; the core adds only displace + focus-lock. Editor holds an Option<PanelState> — just the panel’s window, the prev_window to refocus on close, and an edge margin — and no content/cursor/scroll state (that all lives in the buffer and its WindowView). open_panel mounts a buffer in a bottom split (reusing open_bottom_window / remove_window, which displace the main window into the rows above) and hard-locks focus to it: a guard in Editor::focus_window refuses to move focus anywhere else, so <C-w> navigation, nvim_set_current_win, and mouse focus are all inert until close_panel dismisses it. Opening a second listing over an open panel re-targets the one window rather than stacking overlays.
  • Everything inside is plain buffer behavior. Motions navigate, search works, and long lines wrap (:messages just sets 'wrap' on the panel window) — because the panel is a real window onto a real buffer, not because the input loop special-cases it. The activation and dismiss keys are buffer-local keymaps installed by a FileType autocmd, never hard-coded: the prelude’s FileType btvlisting/btvbuffers/btvpanels/btvmessages/btvpanel maps bind q/<Esc> to btv.panel.close, and a per-listing <CR> action (e.g. btv.buffers.actions.open reads the bufnr off the cursor row and switches) is an ordinary default map that a user map overrides — rebindable the standard way.
  • Built-in listings mount here. :registers, :marks, :jumps, :changes go through Editor::open_scratch_listing(name, lines, cursor) (filetype btvlisting); :ls/:buffers through Editor::open_buffer_listing (filetype btvbuffers, whose <CR> switches buffer); the named-panel list through btvpanels; :messages through btvmessages, whose C map (btv.messages.actions.clear → :messages clear) empties the log and re-renders the panel in place. Each opens scrolled to a chosen cursor line — :messages to the newest line, :ls to the current buffer.
  • A message history feeds it. Editor::echo is the one place a user-facing message is set; it records each line in a messages history (the backing store for :messages) as well as showing it on the message line. A message spanning several lines is fenced above and below with a MESSAGE_SEPARATOR row, so a block (a := table dump, a traceback) stays visibly one entry in the flat history; adjacent blocks share their divider. The server routes its own messages (errors, captured print/nvim_echo) through the same call.
  • It’s scriptable. A plugin mounts its own panel with btv.panel.open{ name?, lines, filetype?, height?, margin? } and dismisses it with btv.panel.close() — queued as a PanelOp drained by the server (the same “Lua queues, core mutates” flow as vim.cmd/nvim_set_hl). name (default [Panel]) makes the panel unique, so re-opening replaces its content; filetype (default btvpanel, whose ftplugin maps q/<Esc> to close) lets a plugin pass its own filetype and wire its own keys. The only RPC method is the read-only bemtvi_panel_is_open (clients use it for chrome); there is no separate panel content/cursor RPC because the panel is a window — its text, cursor, and scroll ride the ordinary WindowView, and the redraw carries no special panel map.
  • Mouse. A left-click in the panel window focuses its line like any window (focus-lock keeps you inside the panel). It is one gesture in the editor’s broader, server-owned mouse support (click, drag-select, multi-click, shift-extend, wheel scroll, divider drag, the 'mousemodel' right-click menu, middle-click paste, and tabline clicks), forwarded by both clients as btv_input_mouse with the server owning the hit-test.

The same core hit-test also drives the floating list overlays — the insert completion popup, the fuzzy picker, the promptless btv.ui.select, and the command-line wildmenu. Their box geometry lives in one place, Editor::menu_geom (editor/menu.rs, shared with the server’s redraw menu projection), and Editor::menu_hit inverts it: it offsets the box by the focused window’s screen origin (or, for the wildmenu, the command-line frame) and the client border convention to map a clicked global cell back to the list row painted there. A click highlights a row, a click on the already-highlighted row accepts/confirms it (a click off a picker box cancels it), and the wheel moves the highlight or scrolls a picker preview — so every front end (TUI, GUI, web) gets overlay mouse for free by forwarding the same raw btv_input_mouse cell, with no client-side geometry. (Command-line-mode mouse needs c in 'mouse'; the default nvi omits it.)


Lua

bemtvi embeds vendored PUC Lua 5.4 via mlua — the single backend. Scripts run inside the server, exactly as in neovim, and influence the editor through the same mechanisms RPC clients use. The VM loads the full safe stdlib plus debug (real plugins call debug.getinfo to locate their own install dir, and neovim exposes it). There is no backend toggle: lua54 is baked into the shared [workspace.dependencies].mlua (vendored), so every crate that links mlua — and the wasm edit-host — shares one backend. LuaJIT was dropped (it never compiled to the wasm target); the prelude ships a bit library shim since PUC has no bit table (5.4’s native bitwise operators notwithstanding). 5.4 over the old 5.1 baseline brings real 64-bit integers, native bitwise operators, the utf8 library, yieldable pcall (so pcall can wrap a coroutine-yielding btv.await), and a generational GC; the one stdlib removal that touched the prelude — loadstring (folded into load in 5.2) — is restored by a one-line loadstring = loadstring or load shim at VM creation in runtime.rs.

Effects flow through queues. vim.cmd(...) / vim.api.nvim_command(...) queue ex-commands; print(...) / vim.api.nvim_echo(...) capture output; vim.api.nvim_set_hl(...) queues highlight-group definitions. After each chunk runs, the server drains those queues into the (pure, synchronous) core — Lua never mutates the editor directly. The end-state is for vim.api.nvim_* to call the very same API functions remote clients invoke (Lua → API → core).

A config runtime. bemtvi resolves a config dir and runtimepath the way neovim does ($BEMTVI_CONFIG / $XDG_CONFIG_HOME/bemtvi / the platform default — ~/.config/bemtvi, or %LOCALAPPDATA%\bemtvi on Windows; bemtvi_core::stdpath is the one place that policy lives, shared by every crate that resolves a std dir — plus pack/*/start/* discovery and $BEMTVI_RUNTIMEPATH for tests), seeds package.path from it so require resolves config and colorscheme modules, and sources <config>/init.lua at startup — before the first frame. The Lua surface is provided as a bundled Lua prelude (the bemtvi-lua/src/prelude/ modules, the analogue of neovim’s runtime/lua/vim/): vim.tbl_*, vim.split, vim.inspect, vim.g/vim.o/vim.opt/vim.env, vim.notify, vim.log, user commands, and autocmds; env-touching helpers (vim.fn.stdpath, …) are Rust-backed, and there are no synchronous fs builtins — filesystem access is the async btv.fs. :colorscheme <name> sources colors/<name>.lua off the runtimepath and fires the ColorScheme autocmd. A colorscheme is just Lua: its setup() compiles a highlight table (typically cached as Lua bytecode under stdpath("cache")), and load() populates the highlight registry via the btv highlight API (its nvim_set_hl alias). See the README to set one up.

Plugins persist isolated state. A plugin opts into cross-session storage with btv.shada.plugin(): a key/value handle (JSON values) that lives in a dedicated table of the active shada store, walled off from the core registers/marks/history. The namespace is assigned, not chosen — derived from the calling code’s runtimepath/plugin directory (via debug.getinfo), so a plugin reaches only its own slice and can’t name another’s. It rides the ordinary shada flush cadence; see the btv.shada.plugin section and examples/plugin-shada/.

The editor’s scripting namespace is its own: config files and plugins target the btv.* API (ADR 0002; the btv design). The lasting vim.* is exactly one thing: a closed whitelist of muscle-memory aliases — variables / options / env (vim.g, vim.o/vim.opt, scoped variants), vim.cmd / vim.keymap.set, the declarative registrations (autocmds, user commands, the nvim_set_hl highlight helper colorschemes use), the pure helpers (vim.tbl_*, vim.split, vim.inspect, …), and the callback-shaped async (vim.notify, vim.schedule/vim.defer_fn, vim.ui.*) — process spawning is the promise-based btv.run, not a vim.system alias — mapping 1:1 onto the btv equivalents so config can be written in familiar muscle-memory terms (the canonical list: ADR 0002). The prelude’s broader vim-shaped surface is donor code for the btv build-out: what serves bemtvi’s objectives is refactored under btv.*, the rest is deleted (see the roadmap).


Syntax highlighting (treesitter)

bemtvi is treesitter-native only — there is no regex/syntax.vim highlighter. All highlighting comes from tree-sitter grammars and their highlights.scm queries, parsed in-process:

  • In-process, synchronous. The editor owns the parser (a Box<dyn SyntaxEngine> whose trait lives in bemtvi-core, implemented by bemtvi-ts) and queries it during redraw, so spans are correct in the same frame as the keypress — no worker process, no RPC, no async catch-up frame. This is neovim’s posture: a buggy grammar (compiled C) can segfault the editor, a risk accepted because grammars are user-installed and stable, bounded by a parse deadline (a per-parse wall-clock budget; on expiry the last good tree is kept, costing one frame of stale highlights rather than a hang). It also drives treesitter indentation and injections, both of which need a synchronously-queryable tree.
  • Installable grammars. Grammars are not bundled; they load dynamically by filetype from a data directory using tree-sitter’s standard on-disk layout (<data>/parser/<lang>.so, <data>/queries/<lang>/highlights.scm), so any standard tree-sitter grammar + query set is drop-in usable.
  • Incremental parsing. The engine keeps a shadow buffer and a persistent parse tree per buffer; it applies only edit deltas (InputEdit) drained from the Buffer edit journal in bemtvi-core (changedtick + BufferEdits), so per-edit cost scales with the edit, not the file — huge files stay responsive.

The View/redraw carries the result as a per-row highlights array (see the View protocol above): screen-column spans tagged with a capture-group name and a resolved style_id. The server owns which cells are which group and resolves group → concrete style (a colorscheme’s nvim_set_hl table, or the capture-fallback chain); the client paints the truecolor it is handed, falling back to a small built-in theme only when no colorscheme resolved a span. Full designs: in-process treesitter (superseding the original worker-based design) and the catppuccin colorscheme.

Treesitter control — declarative buffer state, not a parser API

The native engine above is bemtvi’s treesitter. There is no Lua parser/AST platform: per ADR 0002 the vendored neovim vim.treesitter Lua (the LanguageTree / get_parser / TSNode machinery and the Rust primitives that backed it) was deleted — it existed only to host third-party neovim plugins, a non-goal. There is no parser-facing btv.treesitter API: the btv.treesitter table holds only the foldexpr marker for foldmethod=expr (with vim.treesitter.foldexpr as its one muscle-memory alias — the rest of vim.treesitter.* is absent). What remains is a tiny control surface over the engine, and it is declarative buffer state, not a verb API:

  • Highlight control is the two-noun model: btv.bo.filetype chooses the language, btv.bo.ts_highlight chooses whether the engine paints it. There is no start/stop verb — setting the filetype and flipping ts_highlight is how you start/stop highlighting. :set filetype / :setf write the same per-buffer override, so there is a no-Lua path too, straight from the ex line.
  • Query customization is the native bridge btv._btv_set_ts_query(lang, name, text|nil) — it queues a TsOp::SetQuery the server pushes straight onto the engine, installing a highlights / injections / indents override (a replace, nil to drop). Underneath it, the server resolves each query the neovim way before handing it over: the language’s bundled base, its ; inherits: chain (so javascript picks up ecma/jsx), and the runtimepath queries/ + after/queries/ overlay, where a file carrying ;; extends is added and one without it replaces. The two remaining deviations — extension ordering and the parenthesized ; inherits: (lang) form — are in Known approximations.

Injections are engine-native: the engine runs the resolved injections query over the live tree and parses each region with its child grammar, per-edit and synchronous (see injections).

The boundary this section embodies — native engine for editor behavior, a Lua scripting layer on top — is the same one LSP follows (a native async server under a Lua control surface), recorded in ADR 0001 and carried forward by ADR 0002 (which retires the vendored vim-named spelling in favor of btv.*). ADR 0001 also names the bridge pattern (the treesitter query bridge, LSP semantic tokens) by which a scripting API is wired to the native engine underneath, projecting into the extmark highlight layer rather than into core’s synchronous path.


Cross-platform & the GUI

bemtvi targets all major OSes (Linux, macOS, Windows). The dependency choices are deliberately portable: crossterm for the terminal, ropey, tokio, and rmpv are all cross-platform, and the in-process transport uses no OS-specific IPC.

The terminal client is built on ratatui (over crossterm). Because every front end is just a client of bemtvi’s own RPC, a native GUI — notably a non-terminal GUI on Windows — is just another client crate consuming the same View protocol, with zero changes to the server or core.

That claim is now load-bearing: bemtvi-gui is a native GUI client on mitsuami + wgpu + glyphon, in three crates. bemtvi-gpu is the client with no toolkit: the renderer, the sessions, the IO thread, and a Client state machine its host feeds input, wake-ups and timer ticks, and that asks the host for what it needs as effects (repaint, the pointer’s shape, the IME caret, a file dialog, the clipboard, exit). bemtvi-mitsuami shows it as a mitsuami view an app embeds, and bemtvi-gui is the binary whose window is that view. It is the GUI sibling of bemtvi-tui and reuses the same frontend-neutral bemtvi-view decode/input layer (View, Style, Key, notation, encode_text, encode_paste) — the seam the view crate was extracted for. The bemtvi-gui binary embeds a server on its own thread exactly like the default bemtvi binary, joined by the same in-process duplex RPC; the only difference from the TUI is the client. mitsuami shows the window with each platform’s own toolkit (AppKit, WinUI 3, GTK 4 or Kirigami), its whole content a GpuSurface that wgpu presents to; the surface reports keys (where they are, what they typed through the platform’s text input — layouts, dead keys, AltGr, input methods), the pointer, and takes the cursor and the IME caret. Input methods are on unless the relayed 'imdisable' is set; a config turns them off from a keymap or a ModeChanged autocmd (examples/input-method). An input method’s composition is spliced in at the cursor like inline virtual text (its own cells, the rest of the line pushed right), painted with bemtvi’s BtvImePreedit group, which falls back to Visual. mitsuami owns the main thread, so the RPC runs on a separate IO thread that decodes each redraw into a View, queues it for the client and wakes the UI thread, which pumps the client; input goes the other way on a cloned Rpc handle (notify is synchronous, no runtime). Rendering is a monospace cell grid: a tiny solid-quad wgpu pipeline paints the backgrounds, selection, search, status bars and cursor, with a glyphon text layer (syntax-colored from the server’s resolved styles) on top; the cell size is measured once from the font.

Scope. It now paints essentially the whole View the TUI does — the tiled windows (text, number/relativenumber gutter with CursorLineNr, the diagnostic sign column), floats with borders + titles, the split separators, the tabline (built-in or custom), per-window and global (laststatus=3) status lines, the completion pmenu (with its doc preview), the :messages/:ls panel, the command/message line, visual + secondary (multi-cursor) selections, search / incsearch, LSP diagnostic underlines + signs + inline virtual text, inline LSP inlay hints (spliced into the row’s shaped text so following glyphs — and the column-keyed selection / search / diagnostic / cursor overlays — shift right by the inserted width, the GUI analogue of the TUI’s inline splice), the secondary multi-cursors, and the text style attributes (bold/italic via bold and italic faces, underline/strikethrough/reverse as quads) — plus pixel-smooth scrolling: the focused window slides the server’s scroll-gesture band at a fractional (sub-pixel) line offset driven by the client clock, repainted every frame by the client’s paint loop while it runs (where the TUI animates at whole-row granularity, the GPU client interpolates top without rounding). Input reaches parity too: vim-notation keys, system-clipboard paste, native open/save dialogs (the editor decides which : lines ask for one as it runs them, bemtvi_core::dialog, and asks a client that attached with file_dialogs through a btv_file_dialog notification), and mouse — left click / drag-select / release, wheel scroll, right-click ('mousemodel'), middle-click paste, and the floating-overlay gestures (the completion popup, picker, select, wildmenu, and panel), sent as the same btv_input_mouse the TUI uses (the server owns the hit-test, so the GUI forwards a raw cell and carries no overlay geometry of its own). Wide-char + emoji rendering landed too (CJK cell-snapping, an emoji mask/scale pass that keeps the grid). Still deferred: undercurl is drawn as a plain underline. The rendered frame needs a GPU, so it’s validated by running it; everything around it is tested. The Client runs headless against a real server (crates/bemtvi-gpu/tests/client.rs: keys and frames, the dialogs and the clipboard as effects answered by the test, the timeoutlen flush, :workspace switching), next to the pure layers: key→notation (crates/bemtvi-gpu/tests/input.rs, and through mitsuami’s events in crates/bemtvi-mitsuami/tests/keys.rs), the pointer math (crates/bemtvi-mitsuami/tests/mouse.rs), the :connect target / bemtvi:// URI / SSH askpass parsing (crates/bemtvi-gpu/tests/remote.rs), and the renderer’s geometry. The mitsuami view mounts, unmounts and drives the editor in crates/bemtvi-mitsuami/tests/embed.rs.

The web build — a fully client-side WebAssembly editor

bemtvi-edithost runs the editor in the browser, with no server (crates/bemtvi-edithost). Where the native clients move only the UI off the editor, this moves the whole thing into a browser tab — and not just the core: bemtvi-core + the PUC Lua 5.4 VM + the full server tick (the reusable synchronous EditHost, autocmds, mirrors, redraw projection) compile to wasm32-unknown-emscripten and run client-side. It’s the edit-host split (§Embedded vs. remote) taken to its limit: the local half is a browser tab, the fs/process half is OPFS or a remote daemon over WebTransport.

  • The editor lives in a Web Worker. web/worker.mjs is the single !Send thread that owns core + Lua. It loads the wasm module (dist/eh.mjs), constructs the real EditHost behind a wasm HostEffects (WasmEffects, src/lib.rs), and runs the production tick. The UI thread (web/index.html) is the renderer + input layer, and the two talk over postMessage / a shared ring.
  • Interop is emscripten ccall/cwrap, not wasm-bindgen. src/lib.rs exposes #[no_mangle] extern "C" exports — eh_new / eh_input / eh_input_mouse / eh_source_lua / eh_exec_lua / eh_redraw_json / eh_lines / the fs + shada legs / eh_free* — and the redraw comes back as a JSON return value. Because it links the C-heavy lua54 backend + vim-regex, the final link is emcc, not wasm-bindgen.
  • The renderer consumes the same redraw the native clients do. web/index.html paints the server redraw frame as HTML/CSS (a per-cell-span DOM renderer — windows/gutter/status/tabline/panel/pmenu, selection + cursor-shape classes, smooth scroll), the browser analogue of bemtvi-tui’s layout, and translates a browser KeyboardEvent to vim key-notation + mouse gestures to eh_input_mouse. It exposes a window.__bemtvi hook (feed / mouse / execLua / lines / frame / …) so a headless browser can drive it.
  • The run loop parks on Atomics.wait, and timers fire without Asyncify. When the page is cross-origin isolated the Worker runs a blocking loop parked on a SharedArrayBuffer input ring, waking on a keystroke or the next timer deadline; the same wait that blocks on input fires Worker-side timers (vim.defer_fn / btv.timer) via eh_set_clock / eh_next_deadline / eh_tick_timers — one mechanism. Without cross-origin isolation it falls back to a postMessage-driven loop (input works, timers don’t fire).
  • The browser is the filesystem — three legs. Open/save persist to OPFS (serverless; shada is one JSON blob in OPFS), to real local files via the File System Access API (:eo / :wo / bare :w on a bound path), or to a real bemtvi --daemon over WebTransport (a JS msgpack-RPC client, web/rpc.mjs, reached with ?daemon=bemtvi://…, which connects only after the user approves that HOST:PORT + cert, and whose session keeps its own per-daemon shada blob — see web/daemon-trust.mjs). All three ride the same off-tick HostEffects fs seam the native edit-host split uses; the Worker fulfills fs requests between ticks.
  • Lua config runs — plugins included. Unlike the old core-only web build, init.lua is sourced at startup: options / keymaps / autocmds / user commands / highlights apply. There is no on-disk runtimepath, so multi-file plugins ship amalgamated: web/amalgamate-plugins.mjs bundles each plugin tree into package.preload under a per-plugin synthetic runtimepath root, so require and shada attribution behave as they do natively. LSP works over the daemon wire (the sync SyncLspClient replaces the async manager on wasm — web/verify-lsp.mjs); only the native treesitter engine is off the wasm build: syntax highlighting is done JS-side via web-tree-sitter (web/highlight.js + the generated web/vendor/ grammars, including injections — a markdown buffer reaches the markdown_inline grammar and its fences’ languages), and :TSInstall fetches a prebuilt .wasm grammar. The one exception to “generated”: no markdown .wasm is published anywhere, so its two parsers are compiled from the pinned npm sources and committed under treesitter/prebuilt/.
  • Excluded from the workspace. It targets wasm32-unknown-emscripten and links C via emcc, so it is in the root Cargo.toml’s [workspace] exclude (the host cargo build/test/clippy --workspace never touches it) and pins its own dependencies. Built via crates/bemtvi-edithost/build.sh (cargo → emcc link → dist/eh.{mjs,wasm}, plus the tree-sitter highlighter assets generated once in the crate’s treesitter/ tooling dir and copied into web/vendor/). Deployed as static files (see netlify.toml); the one hard requirement is cross-origin isolation (COOP/COEP) for the SharedArrayBuffer.

Testing philosophy

bemtvi does not use unit tests. We test functionality — what the editor does for a user — not internal code structure. Coverage is layered cheap → faithful, so the broad, fast tiers localize most failures and the slow PTY tier stays thin:

  • RPC / View integration (crates/bemtvi-server/tests/editing.rs) start a real server, connect over real RPC, send vim key-notation via btv_input, and assert on observable results: buffer contents (nvim_buf_get_lines), cursor, bytes written to disk, and the semantic redraw View. They treat the editor as a black box and exercise the whole stack (RPC → server → core → Lua) end to end.
  • Tier 1 — client paint & key translation (crates/bemtvi-tui/tests/) render a known View into a cell grid via ratatui’s TestBackend (bemtvi_tui::paint) and assert on the painted cells, and test the crossterm-KeyEvent→key-notation translation (bemtvi_tui::encode_key) directly. Fast and fully deterministic — no process, no timing.
  • Tier 2 — full-stack screen (crates/bemtvi/tests/screen.rs) drive the real server in-process, capture the real redraw, paint it with the real client, and assert on the cell grid — the deterministic “what the user sees” workhorse. Also asserts the non-blocking guarantee (a UI that never drains redraws can’t stall the editor).
  • Tier 3 — PTY smoke (crates/bemtvi/tests/e2e.rs) drive the actual bemtvi binary through a pseudo-terminal (portable-pty), send real key bytes, and assert on the parsed terminal screen (vt100) a user would really see — proving real crossterm decode, real terminal escapes, and process startup/args. Deliberately small; the slow/flaky surface. Includes a responsiveness check that input typed during a slow editor op (:sleep) is buffered and applied once the editor wakes.

A bug should be reproducible as “these keystrokes produced the wrong text or screen,” and that is exactly the shape of these tests.


Compared to neovim

Similarities (by design):

  • Headless, authoritative editor server with thin UI clients.
  • Single-threaded editor core; concurrency via async I/O.
  • Lua 5.4 scripting running inside the server.
  • Source organization mirroring neovim’s subsystems (one crate per area).
  • Vim modes, motions, operators, counts, registers, and ex-commands.

Differences (intentional, rust-native):

  • Rust crates and ownership instead of C translation units and globals; no libuv (tokio), no longjmp error handling (Result/enums).
  • Not a neovim UI host: no ext_linegrid, no grid protocol, no goal of attaching external neovim GUIs. The client gets a semantic View and lays out ratatui widgets per region itself.
  • Rope-backed (ropey 2.0), byte-indexed buffers with a strict trailing-newline invariant — closer to vim’s own byte-column model.
  • A branching undo tree of full-rope snapshots (cheap via ropey’s structural sharing) rather than neovim’s diff-based undo.c change records — same branching semantics (:undo {N}, g-/g+, :earlier/:later, :undolist, vim.fn.undotree()), different storage — with 'undolevels' bounding what the snapshots retain.
  • In-process treesitter with installable grammars and incremental parsing — like neovim, but kept off bemtvi-core behind a SyntaxEngine trait (so the pure core never links tree-sitter) and bounded by a parse deadline (see Syntax highlighting).

Not yet implemented (roadmap). The big-ticket items below; the granular vim.* gaps and the silent approximations live in Known approximations & missing features.

  • The native plugin system (btv.*) — the headline extensibility item: server-owned UI surfaces (completion engine, fuzzy picker, statusline segments, snippet engine, tree docks) with plugins as async, declarative providers registered through manifest-declared contributions, plus the built-in package manager. Design: the native plugin API; decision: ADR 0002. Suggested build order is in the spec (picker → completion → statusline/snippets/tree). Landed so far: the fuzzy picker (btv.picker, with a preview pane), the completion engine (btv.complete, buffer + lsp + snippets sources), the snippet engine (btv.snippet — LSP snippet-syntax parsing, the tabstop session with <Tab>/<S-Tab> navigation and mirrored placeholders; see the snippet plan), the statusline segment registry (btv.statusline — the lualine-shaped surface: built-in segments resolved natively each frame plus custom btv.statusline.segment{} providers re-rendered only on declared events / invalidate, composed through the shared %-format layout so clients paint it unchanged; see the segment plan), viewport decorations (btv.decor — off-tick providers woken once per visible-range change that publish generation-gated extmarks; a published mark takes the same option vocabulary btv.buf.set_extmark takes and is validated + lowered by the same code, so a provider draws highlights, gutter signs, virtual text/lines and line backgrounds alike — what publish adds is the lifecycle (the generation gate and the wholesale namespace republish), not a mark shape of its own; see the decor plan), the floating-widget UI layer (btv.ui.input/select/confirm/float, promise-based; see the content-float plan), and the tree docks (btv.dock — VSCode-style permanent edge panels with per-region tablines; see the docked-panels plan). Every widget’s keys are rebindable through the real keymap engine (configurable widget keys). The built-in package manager has landed too: btv.plugins — declarative specs, async git install, eager + lazy (cmd/event/ft/keys) activation, :PluginSync, and the :Plugins dashboard.

  • Treesitter control. :TSInstall / :TSUpdate / :TSInstallInfo have landed: the native arm fetches + compiles each grammar into the data dir off the editor thread (bemtvi_ts::install, with a pinned checksum-verified Zig fetched on demand — never a compiler from PATH — unless $BEMTVI_CC is set), and the browser arm fetches a prebuilt .wasm grammar instead. The :set-driven highlight toggle has landed too. (Residual :TSInstall edges — grammars needing tree-sitter generate, no install-from-HEAD — are tracked in Known approximations.) Treesitter injections have landed (engine-native, see Syntax highlighting). Treesitter is the native engine; control is declarative buffer state (see Treesitter control): highlight on/off + language are buffer nouns (btv.bo.filetype / btv.bo.ts_highlight, also reachable from :set), query customization is the native bridge btv._btv_set_ts_query, and injections are engine-native. There is no Lua parser/AST platform (the vendored vim.treesitter Lua was deleted — ADR 0002); Lua-driven indent has landed too, through the bounded compute sandbox rather than a parser API: btv.indent.expr sits below the tree-sitter verdict and above smartindent, and is one of the six expression surfaces.

  • Window-local options. Multiple windows (splits, the layout tree, per-window view state, the <C-w> family, and the nvim_win_* RPC + Lua-read surface), floating windows (nvim_open_win with relative, the z-ordered overlay layer, nvim_win_set_config, and the :q/:only/focus/autocmd edge semantics), and tab pages (a Vec<TabSlot> deriving the active WindowTree, the tabline, gt/:tab*/<C-w>T, the Tab* autocmds, the nvim_tabpage_* Lua surface, and showtabline) are all implemented — see Windows. What remains on this axis is the long tail of vim’s window-local options (colorcolumn, scrolloff, signcolumn, foldcolumn, fillchars, list/listchars, and winhighlight are already wired).

  • The btv.* config surface — init.lua targets bemtvi’s own API (ADR 0002); the prelude’s current vim-shaped spelling is donor code, refactored under btv where it serves bemtvi’s objectives and deleted where it doesn’t, with the muscle-memory aliases as the only lasting vim.*. What the runtime already does: the runtimepath, require, init.lua, nvim_set_hl, :colorscheme, and vim.keymap.set/vim.api.nvim_set_keymap (a per-mode withhold/replay matcher in bemtvi-server/src/keymap.rs; multi-key built-ins fire instantly even under a colliding user prefix, via the shared command grammar bemtvi_core::command_status the matcher consults) are in place — enough to load a full colorscheme end to end (see Lua). The LSP and diagnostics surface is native too: the bemtvi-lsp crate (client, protocol, manager, transport) does bemtvi’s own stdio spawning and drives the in-core editing features, and btv.lsp is its Lua control surface (server registration, enable, on-attach) — per ADR 0002. (Design background: the LSP support design and the completion plan.)

    What does not work yet is tracked canonically — both the silent approximations (a feature that looks whole but isn’t) and the loud gaps (functions that raise not implemented rather than fake a value, per the no-silent-stubs rule) — in Known approximations & missing features. That doc explains how to enumerate them straight from the code (grep -rn 'INCOMPLETE:' for approximations, the btv._notimpl raises / runtime btv._notimpl_hits scoreboard for loud gaps) and lists the absent subsystems that have no call site to tag — the bulk of vim’s options beyond the subset bemtvi honors (the registry is the OPTIONS catalog — and canonical() over it — in crates/bemtvi-core/src/options.rs: the window-local gutter / wrap / scroll options, the buffer-local indentation options, and a steadily growing set besides; many others are not wired) and richer diagnostic surfaces. (Blocking reads — vim.fn.input / vim.fn.confirm / vim.fn.getcharstr / vim.wait and the coroutine pump that hosted them — are not part of the btv model: nothing in it blocks the editor, so the only prompt surface is the promise-based btv.ui.input / btv.ui.select, with callback-shaped vim.ui.* aliases.) Legacy Vimscript (eval.c) is not on the roadmap — see guiding principle 2.

  • A broad options surface. :set exists and honors the search booleans, the window-local number-gutter options number / relativenumber, the cursor-line highlight cursorline, and soft word-wrap wrap (linebreak/breakindent/showbreak) (also via :setlocal / vim.wo / nvim_win_{get,set}_option) and the window-local horizontal-scroll options sidescroll / sidescrolloff (via :set), and the buffer-local indentation options tabstop / shiftwidth / softtabstop / expandtab and commentstring — the comment template the gc/gcc operator reads, set per buffer or defaulted from the filetype for the ~20 most common languages (all also via :setlocal / vim.bo); scoped nvim_{set,get}_option_value routes to the right scope. The bulk of vim’s options are still missing. Named/numbered/special registers (:registers, setreg/getreg, the system clipboard "+/"*) and marks (buffer-local a–z, global file marks A–Z, the automatic special marks, :marks, and '{mark} ex-ranges) are both done, as are keyboard macros — on <F2>/<F3> rather than vim’s q/@ (see docs/features/macros.md), recorded as key notation into an ordinary register and replayed through the keymap matcher, so a macro over a Lua keymap re-fires it. What remains here is the :map-family ex-commands (intentionally postponed — keymaps are set via vim.keymap.set / nvim_set_keymap). Code folding is done across all four sources — manual (zf/the z family), foldmethod=indent, foldmethod=expr (the native tree-sitter foldexpr and a generic per-line Lua 'foldexpr'), and LSP textDocument/foldingRange — sharing one per-window fold model (see Folds under the view section below). (The interactive / / ? cursor search — n/N, hlsearch/incsearch, the search options — and :s substitution, which shares search’s canonical-regex engine, are both done; see the search design and docs/plans/2026-06-07-substitute-command.md.)

  • Per-buffer user commands. Done. nvim_buf_create_user_command(buffer, …) stores into a per-bufnr registry (btv._buf_user_commands[bufnr][name], the command analogue of the buffer-local btv._keymaps): btv._resolve_user_command gives a buffer-local command precedence over a global of the same name and hides it from other buffers, dispatch routes through the editor’s authoritative current bufnr, nvim_buf_get_commands returns a buffer’s locals, and a wiped buffer’s locals (commands and keymaps) are purged via btv._cleanup_buffer so a reused bufnr can’t inherit them.

  • An async Lua runtime (event loop). Landed (see the async-runtime plan). A Send background actor (crates/bemtvi-server/src/evloop.rs, modeled on LspManager) owns timers and child processes; on completion it sends a typed LoopEvent back to the single server thread, which runs the matching Lua callback by id (the btv._cb_fns registry, the keymap-callback shape applied to async work). vim.schedule defers to convergence, vim.defer_fn fires on wall-clock time, and process completions settle their promises off-tick (there is no vim.system — spawning is the promise-based btv.run). neovim’s libuv-as-public-API surface — vim.uv / vim.loop, both the handle primitives (new_timer/new_check/new_fs_event/spawn) and the synchronous fs_* / scalars — is not part of the btv model and is absent entirely. The btv async primitives (btv.run / btv.timer / btv.fs) are built on this machinery (ADR 0002).

  • The vim.* glue, kept only as far as colorschemes need (ADR 0002). (The backend is vendored PUC Lua 5.4, the single baked-in backend — see Lua.)

Known approximations & missing features

bemtvi tracks vim/neovim’s observable editing behavior, but it is a fresh Rust implementation, not a port — so some surfaces are only approximate, and some subsystems simply aren’t built yet. This page maps where bemtvi diverges from neovim, so a config or plugin you bring over holds no surprises.

Two principles shape what follows:

  • No silent stubs. Anything unimplemented fails loud — it raises bemtvi: not implemented: <name> rather than quietly returning a fake value, so a half-working feature never masquerades as a whole one. You find out at runtime exactly what tripped, instead of chasing mysterious wrong behavior.
  • Honest approximations. Where bemtvi does something plausible but not fully faithful, that is flagged in the source right beside the code, and the larger ones are listed below.

Seeing what your own config hits

Every loud gap a running config trips is collected in btv._notimpl_hits, so you can see precisely which gaps you actually hit — not just which ones exist:

:lua print(vim.inspect(btv._notimpl_hits))

The precise, always-current per-function detail lives in the code: greppable INCOMPLETE: comments mark the silent approximations (each with its “why” and the fix), and btv._notimpl raises mark the loud gaps. The sections below cover the larger divergences and the whole subsystems that have no single line to point at. If this page and the code ever disagree, the code is right — treat this as a guide, not the registry.

Missing or partial subsystems

These are whole areas where the subsystem itself is absent or only partly built, so a config that leans on one meets a nil value or a generic error rather than a named gap — worth knowing up front. (Subsystems that are now fully built — the native tree-sitter engine with its btv.bo.filetype / btv.bo.ts_highlight nouns, on-disk and customized queries, injections, incremental updates, and :TSInstall fetch/compile — aren’t listed; only the edges that still diverge are.)

  • Treesitter — a native engine, not neovim’s Lua platform. The vendored vim.treesitter Lua platform (get_parser, LanguageTree, highlighter.*, query.get) was deleted (see the deletion plan and ADR 0001) — a plugin reaching for it hits a loud nil index. Highlighting is driven by the in-core Rust engine through the declarative buffer nouns btv.bo.filetype / btv.bo.ts_highlight (the one vim.treesitter survivor is the foldexpr marker), and query customization rides the runtimepath queries/ / after/queries/ overlay (next bullet). Lua-driven indent is wired, through the bounded compute sandbox rather than the main VM: btv.indent.expr is a Lua expression handed the line, the previous non-blank line and the effective 'shiftwidth', sitting below the tree-sitter verdict and above smartindent (see Expressions). Passing the line in is what avoids the snapshot bridge — the expression never reads the buffer.

  • Treesitter query resolution. The query bridge (design) merges a language’s bundled base with runtimepath queries/ + after/queries/ and the ; inherits: chain — and :TSInstall fetches the inherited query sets too (javascript → ecma,jsx), so base js/ts highlighting carries the ecma patterns. ;; extends carries its upstream meaning: a runtimepath file with it is added, one without it replaces that language’s bundled query (first in runtimepath order wins). Two deliberate deviations remain: (1) extension ordering — every extension lands after every base, including the bases of inherited languages, so an after/queries customization wins a tie against what it customizes; upstream interleaves per language, which lets a bundled pattern of the outer language beat a user’s extension of an inherited one. (2) ; inherits: (lang) — upstream’s parenthesized form means “inherit only when this query is loaded for an injected language”; bemtvi doesn’t model the condition, and such a name simply doesn’t resolve, so the language is not inherited. No shipped nvim-treesitter query uses it. (Two former edges are closed: the bundled ; inherits: chain is now resolved by the engine’s own query reader, so every grammar it loads gets it — injected children included, where javascript inside a markdown fence used to paint only its non-inherited captures; and resolution is no longer additive-only.)

  • No vim.uv / vim.loop. neovim exposes libuv as a public Lua API; bemtvi does not — the vim.uv / vim.loop table does not exist, so a plugin reaching for it hits a loud nil index. Both the libuv handle surface (new_timer / new_check / new_fs_event / spawn / new_pipe, the plugin event-loop primitives) and the synchronous fs_* / scalar primitives (fs_realpath, cwd, os_homedir, os_uname, hrtime, now) are gone. There are no synchronous filesystem vim.fn builtins either (no blocking I/O ever runs on the editor thread): async lives in the btv API (btv.run / btv.timer / btv.fs), and the LSP-config root search runs on the same async btv.fs seam.

  • Broad options surface. The set of honored options has grown well past the indentation knobs (the authoritative list is crates/bemtvi-core/src/options.rs). :set (and :setlocal / :setglobal / vim.bo / vim.go / nvim_{set,get}_option_value) honors: the search booleans (ignorecase / smartcase / wrapscan / hlsearch / incsearch); the window-local rendering options (number / relativenumber / cursorline / numberwidth / signcolumn / wrap / linebreak / breakindent / showbreak / sidescroll / sidescrolloff / winhighlight / fillchars / list / listchars); the fold options (foldmethod / foldenable / foldcolumn / foldlevel); the buffer-local indentation options (tabstop / shiftwidth / softtabstop / expandtab / commentstring); the editing-feedback pair showcmd / report and the undo bound undolevels; and a set of bemtvi-native options (scrollanim / scrollanimduration, qfdock, imagepreview, history / persisthistory, regexsyntax, switchbuf, laststatus / showtabline, guiglyphoverflow, guiopacity / guihlopacity, …). Buffer- and window-local options are global-local, as in vim: each carries a global value (:setglobal / vim.go / vim.opt_global) alongside the per-instance one, :set / vim.opt write both, and a new buffer is born from the global value — which is what makes a config’s vim.opt.tabstop = 3 reach files opened later. A few buffer options have no global value (the read decides them) and :setglobal on one fails loud with E5100. One deliberate departure inside that model: commentstring, foldexpr and foldmarker live in a per-buffer map that already spells “unset” as absence, so their global value resolves as a read-time fallback rather than a creation seed — a :setglobal of one reaches buffers that are already open and carry no value of their own, where vim (which always holds a local value per buffer) would reach only buffers created afterwards. A :setlocal still pins a buffer against any later global write. bemtvi breaks with vim’s defaults on indentation: tabstop defaults to 4, with shiftwidth=0 (“follow tabstop”) and softtabstop=-1 (“follow shiftwidth”) so the one tabstop knob drives the whole indent width. tabstop, softtabstop, and expandtab drive rendering, <Tab> and the <BS>-over-blanks unit delete (bemtvi always has an effective soft-tab unit, so it has no smarttab and applies that unit wherever vim’s softtabstop would); shiftwidth drives the >>/<< shift operators and the LSP indent width. commentstring backs the gc/gcc comment operator and defaults from the filetype (the ~20 most common languages) when unset. Still, the bulk of vim’s hundreds of options are missing — a write to an unmodeled option is recorded but inert.

  • 'listchars' departures. list (also spelled showwhitespace) draws every key but conceal: tab, space, multispace, lead, leadmultispace, trail, nbsp, eol, and under nowrap the extends / precedes edge markers. 'listchars' is also spelled whitespacechars. Where bemtvi differs:

    • The default is tab:→ ,space:·,trail:•,nbsp:◦,eol:¬, not neovim’s tab:> ,trail:-,nbsp:+. neovim’s draws nothing for an ordinary space or a line end, so :set list changed nothing on a space-indented file, and its > / - / + can’t be told from typed text. The glyphs are all in the common monospace fonts (Noto Sans Mono has no ␣ or ↵).
    • An edge marker never covers the cursor’s cell. vim draws it there, which hides the character under the cursor whenever it rides an edge (after $, or stepping right along a long line).
    • With list on and no tab: entry, vim draws a tab as the 2-cell ^I; bemtvi keeps it a plain tab. Drawing ^I would make a tab’s width depend on the window’s 'listchars' everywhere a column is computed (cursor, spans, wrap, scrolling, mouse, LSP overlays), for a value neither default uses.
    • conceal is accepted but not drawn: bemtvi renders no syntax conceal.

    :set listchars+=… (and += / -= / ^= for any option) is not supported and fails with E518; vim.opt.listchars:append(…) works. (Plan: docs/plans/2026-10-27-list-and-listchars.md.)

  • Keyboard macros are on <F2> / <F3>, not q / @, and hold key notation. Recording and playback are complete (<F2>{reg} … <F2> records, {count}<F3>{reg} plays, <F3><F3> repeats the last, <F3>: re-runs the last ex command, a failed keystroke aborts the run) — but two deliberate departures. (1) The keys: q stays free for the user and for the q-to-close convention of bemtvi’s read-only surfaces; btv.keymap.set("n", "q", "<F2>") and the same for @ → <F3> restore the vim spelling, which works because a recording captures a mapping’s LHS and replays through the keymap matcher. (2) The storage: a macro register holds bemtvi key notation (ciwfoo<Esc>), not vim’s raw bytes — so it pastes, lists, persists, and can be hand-authored as readable text, at the cost that playing a register that holds ordinary yanked text parses any <...> in it as a key rather than as literal characters.

  • Filetype detection is a small, deliberate rule set — not vim’s filetype.vim. Detection is a derive from the path (see architecture.md → Buffers): the exact basename, ~7 globs, the extension, and a #! line as the last resort. vim ships thousands of rules; bemtvi ships the ones that pay for themselves, and the glob tier is kept small on purpose — a pattern that guesses (*.conf, *rc) is worse than no detection, because a wrong filetype is harder to notice than a missing one. A language bemtvi doesn’t recognize is named with :setf {lang} (or btv.bo.filetype) — from a config, that means a BufReadPost/BufNewFile autocmd, since there is no vim.filetype.add-style API for extending the tables themselves.

  • Legacy Vimscript (eval.c). Deliberately not on the roadmap (guiding principle 2). vim.fn.* is a hand-written set of helper aliases, not an interpreter — unimplemented vim.fn.* entries are loud gaps, not a TODO to build an evaluator.

  • :TSInstall approximations. The command fetches/compiles grammars (bemtvi_ts::install), with a pinned, checksum-verified Zig fetched on demand (never a compiler from PATH; $BEMTVI_CC overrides) — on macOS, Linux, and Windows alike. Remaining: (1) grammars needing tree-sitter generate (no committed src/parser.c) fail loud rather than generating; (2) the nvim-treesitter ref is pinned in source — no :TSInstall-from-HEAD; (3) in the browser, markdown can’t be installed at all — no npm package publishes a markdown .wasm — so its two parsers (block + inline) ship as committed wasm in the offline bundle (crates/bemtvi-edithost/treesitter/prebuilt/) and :TSInstall markdown reports that instead of fetching.

  • Tree-sitter query-directive approximations. (#trim! @fold) is ignored by both engines, so markdown’s (section) fold runs to the blank line before the next section. The browser’s injection runner (web/highlight.js) additionally parses each @injection.content range on its own — an approximation of (#set! injection.combined), which asks for one parse over their union — and applies no (#offset! …), so a --- metadata fence reaches yaml/toml with its delimiter lines attached.

  • LSP semantic tokens approximations. Painted over the treesitter floor (crates/bemtvi-server/src/lsp/semantic.rs): one resolvable group per cell (the merge picks the most-specific @lsp.* winner, it doesn’t blend neovim’s @lsp.type.<t> + per-modifier stack); theme-gated (an undefined group is dropped so the floor shows); no range (only full/full/delta); no highlight_token hook (neovim’s per-token Lua callback on the decode hot path — the name is absent, a loud nil index); get_at_pos reads the cached mirror even for a stopped buffer; no per-client granularity (one cache per buffer); repaints mid-insert (update_in_insert always on). See docs/plans/2026-06-08-lsp-semantic-tokens.md.

  • LSP inlay hints approximations. Painted inline, opt-in (crates/bemtvi-server/src/lsp/inlay.rs). inlayHint/resolve (lazy per-hint label fill) and vim.lsp.inlay_hint.get (with a line-range filter) have landed. What still diverges: one LspInlayHint group for all kinds (no Type/Parameter split); the fetch is whole-document — the viewport-scoped range request is deferred; per-buffer enable only (no per-client granularity); horizontal-scroll (leftcol>0) + inline hints is best-effort; repaints mid-insert. See docs/plans/2026-06-08-lsp-inlay-hints.md.

  • No synchronous prompts. The blocking vim.fn.input / vim.fn.confirm are omitted by design (loud gaps like the rest of unimplemented vim.fn.*) — prompting is promise-only: btv.ui.input / btv.ui.select / btv.ui.confirm, awaited inside btv.async. A config expecting an inline return must be reshaped onto the promise. See examples/ui-prompt/.

  • btv.statusline segment registry — v1 deferrals. The lualine-shaped surface is built (btv.statusline.setup/segment/invalidate; built-ins composed in bemtvi_core::statusline::compose_segments, custom segments rendered per window and cached server-side; see docs/plans/2026-06-15-btv-statusline-segments.md). Custom segments are now per-window: each is rendered once per window against that window’s { buf, win, focused }, cached by (window, name), and re-rendered when the segment is invalidated or the window layout changes (split/close, focus move, or a window swapping its buffer) — so ctx.focused and ctx.buf are correct in every window. The server orchestrates the re-render from run_pending with a fresh window mirror (EditHost::refresh_statusline_segments), so an invalidate fired from an autocmd that ran before the transition still renders against the settled layout. Layouts are also per-window / setlocal-able: btv.statusline.setup{win=…} sets a window-local layout that overrides the global one, setup{win=…, format=true} opts a window back to the 'statusline' %-format even under a global segment layout (the per-region mix), and btv.statusline.reset(win) drops the override (EditHost::resolve_window_layout). Mouse-click segment regions have landed: a segment can carry an on_click handler, lowered to the %@func@…%X statusline syntax (with %nT tabline labels and laststatus=3), so clicks dispatch back to Lua. What it does not do yet: (1) The custom segment ctx carries { buf, win, focused } but no width (the server doesn’t mirror the per-window statusline width to Lua). (2) git / lsp_progress are plugin segments (custom-segment examples), not built-ins. The built-in set is mode / filename / filepath / filetype / encoding / location / modified / readonly / diagnostics.

Shared limitations

A handful of the approximations above trace back to the same underlying limitation, so they tend to surface together. The notable ones:

LimitationWhat it affects
The completion menu is server-owned chrome, not a windowThe completion-doc preview is a single bespoke box with no separate preview-window handle / 'completeopt' matrix. (Most of the vim.lsp.util helper surface — make_position_params, open_floating_preview, make_text_document_params, locations_to_items — is gone with the compat layer: LSP goes through the native btv.lsp.* verbs and hover/signature through the cursor float. Only apply_workspace_edit and show_document survive there, as aliases of btv.lsp.apply_workspace_edit / btv.lsp.show_document; the former loads unopened files into buffers on the spot. Splits, floats, and tab pages themselves are implemented — see architecture.md Windows.)
Core honors a fixed set of buffer-local optionsvim.bo / nvim_set_option_value writes outside the wired set — filetype / ts_highlight / tabstop / shiftwidth / softtabstop / expandtab / autoindent / smartindent / autopairs / indentemptylines / commentstring / regexsyntax / fileencoding / bomb / fileformat / endofline / fixendofline / modifiable / modified / the fold options (foldmethod / foldexpr / foldmarker / foldnestmax / foldminlines) — are recorded but inert (buftype is mirrored read-only)
Diagnostic-display surfaces are approximations, not gaps — all four ship. underline, virtual_text (inline end-of-line message), signs (gutter glyph), and the on-demand float (vim.diagnostic.open_float) are implemented — see docs/plans/2026-06-08-diagnostic-display-surfaces.md. update_in_insert is honored too, and extended: it takes a number of milliseconds as well as neovim’s two booleans, defaulting to 3000 — an update landing while you type is held and applied once typing has been quiet that long (or immediately on InsertLeave), where neovim offers only per-keystroke (true) or nothing-until-InsertLeave (false). Both booleans still mean exactly what they do in neovim. The hold also sits one layer deeper than neovim’s: bemtvi holds at the store every surface reads, so vim.diagnostic.get reports the held set for LSP-published diagnostics instead of staying fresh as it does in neovim. Displayed positions track edits the way neovim’s do — each applied diagnostic is anchored by an extmark in the reserved DIAGNOSTIC_NS, so a squiggle, sign, inline message, ]d target and loclist row follow the text they flag as you type around it, rather than sitting at the coordinates the publish named. vim.diagnostic.get still reports the published lnum/col (as neovim does); it is the rendering and navigation that follow the buffer.vim.diagnostic.config keys other than underline / virtual_text / signs / update_in_insert (virtual_lines, severity_sort, and the config.float pre-style defaults). open_float ignores its opts (scope/severity filters, format/header/prefix/border) — the default cursor-line scope shows, in the bottom panel (plain lines, like hover) not a cursor-anchored bordered popup. The virtual_text table honors prefix and the signs table its text glyph map; their format / severity filters and sign priority/culhl are not applied, the line’s most-severe diagnostic wins the one inline slot / sign cell, and the sign column is client-side only (a fixed 2 cells not subtracted from bemtvi-core’s text width, so a full-width line under nowrap can clip its last two cells).

Verifying downloads

Every released bemtvi binary ships with a SHA-256 checksum and a signed build provenance attestation proving it was built by this repository’s release workflow.

Checksums

Each release (stable and edge) includes a SHA256SUMS file. After downloading an archive into the same directory:

sha256sum --ignore-missing -c SHA256SUMS

Provenance attestation

Requires the GitHub CLI. Verify an archive against the attestation GitHub stores for it:

gh attestation verify bemtvi-0.2.0-x86_64-linux.tar.gz --repo bemtvi/bemtvi

A successful run confirms the artifact was produced by the bemtvi release workflow at a specific commit, and was not tampered with afterwards.

macOS signature & notarization

The macOS binaries are signed with an Apple Developer ID Application certificate, built with the hardened runtime, and notarized by Apple, so they run on any Mac without a Gatekeeper override. Confirm locally:

# Signature, authority chain, hardened runtime (look for flags=...(runtime)):
codesign -dv --verbose=4 bemtvi

# Gatekeeper assessment — "accepted" / "source=Notarized Developer ID" (needs network):
spctl -a -t exec -vv bemtvi

The macOS TUI ships as a Developer ID Installer .pkg (installing to /usr/local/bin) and the GUI as a .app inside a .dmg — both notarized and stapled, so Gatekeeper verifies a download offline. The installed bemtvi binary itself carries the hardened-runtime Developer ID signature the codesign check above confirms.