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.luaand set options throughbtv.*. - 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
Viewprotocols, 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.
| what | holds | Linux / macOS | Windows |
|---|---|---|---|
| config | init.lua, the runtimepath root | ~/.config/bemtvi | %LOCALAPPDATA%\bemtvi |
| data | installed plugins, tree-sitter grammars | ~/.local/share/bemtvi | %LOCALAPPDATA%\bemtvi-data |
| state | shada (marks, registers, session), logs | ~/.local/state/bemtvi | %LOCALAPPDATA%\bemtvi-data |
| cache | discardable 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:
| Key | Mode | Does |
|---|---|---|
<C-c> / <C-S-c> | visual | copy the selection to the system clipboard ("+y) |
<C-v> / <C-S-v> | normal | paste it at the cursor ("+P) |
<C-v> / <C-S-v> | insert | insert it at the caret and keep typing (<C-r>+) |
<C-v> / <C-S-v> | command line | insert 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:
| Command | Action |
|---|---|
:PluginSync | Realize the declared + locked state: install missing, check out what the lockfile pins, fast-forward the rest |
:PluginInstall | Clone any declared plugin not yet on disk |
:PluginUpdate | Fast-forward every unpinned plugin, advancing past the lockfile |
:PluginClean | Remove cloned dirs no spec declares |
:PluginLock | Record every installed plugin’s commit to the lockfile |
:PluginRestore | Check out every plugin at the commit the lockfile records |
:PluginList | Print a one-line status (installed / loaded / missing) per plugin |
:PluginsWelcome | Reopen 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:
commit = "…"in the spec — a hand-written pin is an instruction, and always beats the lockfile, which is only a record.- the lockfile entry — it outranks the floating refs below, because pinning a floating ref is exactly what it is for.
tag/version, thenbranch.- 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:
:PluginSyncreproduces 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 newerbemtvi-lock.jsonand syncing gets you the tree it names. Realizing your declared state never moves a plugin past the recorded revision. (:PluginInstallonly clones what is missing; it never moves an existing checkout, and records only the clones it made.):PluginUpdateadvances 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.copens the checklist: every plugin with its exact source, pre-ticked, untickable with<Space>(atoggles 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:PluginsWelcomereopens the offer whenever you want it, and:Pluginsopens 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>.
| Plugin | What you get | Loaded |
|---|---|---|
| bemtvi-line | A lualine-style statusline | at startup |
| bemtvi-tree | The file-explorer sidebar | on <leader>e |
| bemtvi-keys-helper | A which-key popup of what can follow the keys you typed | at startup |
| bemtvi-help | Vim-style :help, including every plugin’s own docs | at startup |
| bemtvi-lspconfig | Ready-made configs for 407 language servers | at startup |
| bemtvi-efmls-configs | ~150 linters & formatters, via efm-langserver | at startup |
| bemtvi-snippets | A snippet engine + the friendly-snippets collection | at startup |
| bemtvi-editorconfig | A project’s .editorconfig applied to its buffers | at startup |
| bemtvi-diff | Side-by-side diff & merge-conflict viewer | on :DiffGit |
| bemtvi-markdown-preview | Live markdown preview in your browser | at startup |
| bemtvi-dap | A 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-langserverplus 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 managedlua/plugins.luawith 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
:connectthessh://andcontainer://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
| Feature | What 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 mode | Helix/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 macros | vim’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 editing | An 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 menu | A 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 steps | vim’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. |
| Expressions | Hand 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 scrolling | Viewport 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 detection | A 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 previews | Open 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
| Feature | What it is |
|---|---|
| UI primitives | A 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 docks | VSCode-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 picker | btv.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 tabs | The 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 text | A 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
| Feature | What it is |
|---|---|
Native btv.* plugin API | bemtvi’s own Lua API where the server owns every UI surface and plugins provide data and behavior. |
| Workspaces | The 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 editor | The 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 testing | btv.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
| Mode | Status | What motions do |
|---|---|---|
Helix normal (hn) | HELIX | word/find motions move and re-select; plain char/line motions collapse to a point at the target |
Helix select (hs) | HELIX-SEL | every 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.
| Keys | Selection |
|---|---|
w / W | the next word / WORD plus trailing whitespace, stopping before the next word (vim’s w lands on it) |
b / B, e / E | back to the previous word start / forward to the next word end |
h j k l, 0 ^ $, G, arrows | plain 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:
| Key | Effect |
|---|---|
d | delete the selection |
c | delete and enter (multi-cursor) Insert — <Esc> resumes Helix normal |
y | yank (the selection stays highlighted, Helix-style) |
> / < | indent / unindent |
= | reindent |
~ | switch case (keeps the selection) |
r{c} | replace every selected character with {c} |
R | replace the selection with the yank |
J | join the lines the selection spans |
p / P | paste 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:
| Key | Effect |
|---|---|
x / X | extend 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:
| Keys | Effect |
|---|---|
mm | jump 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:
| Key | Effect |
|---|---|
s | select each regex match within the selection (one selection per match) |
S | split the selection on the regex |
K / Alt-K | keep / 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 nexty/d/c/p/P/R(one-shot), reusing vim’s registers. - View menu
z—zt/zz/zbreposition the viewport around the cursor line, leaving the selection put. - Search
/?nN— a match becomes the selection in normal mode; in select mode each search /nadds the match as a new selection (growing a multi-selection). - Undo / redo —
u/U.
Menus (the plugin layer)
The bundled btv.helix plugin binds the keys a bare grammar key can’t reach:
- Insert entry —
iaIAoO(all per-selection). - Goto menu
g—ggfile start,gelast line,gh/glline start/end,gsfirst non-blank, plus LSPgd/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
- Placement — enter with
<A-c>(Alt+c). The status line readsMULTICURSORand 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. - Editing — press
<Esc>to leave placement. The dropped cursors stay, and the status line returns toNORMAL. 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:
| Keys | Effect |
|---|---|
<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) |
c | Toggle 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}cc | Drop 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 —
dwywcw=w,ddyycc, text objects likediw/ci". - The standalone edits —
xXDCsJ~r. - Insert — typing,
Enter, andBackspaceapply at every cursor; so do the insert-entry keysaAiI(each cursor moves to its own target column — line-end forA, first-non-blank forI) ando/O. - Paste —
p/Pwith 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 vimp, 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 ;:
| Option | Value | Does |
|---|---|---|
weight | 1–1000, or thin, extralight, light, normal, medium, semibold, bold, extrabold, black | The weight of its regular text. Bold text uses the font’s bold. |
scale | a 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. |
features | space-separated +tag, -tag, tag or tag=N | OpenType 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.
| Key | Does | Key | Does |
|---|---|---|---|
<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:
| Key | Does | Key | Does |
|---|---|---|---|
<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:
TextChangedandInsertLeavedon’t fire while you type;TextChangedIdoes. A[+]indicator should listen toBufModifiedSet, which fires the same way in either model.btv.diagnostic.config({ update_in_insert = false })(“hold untilInsertLeave”) holds updates until typing goes quiet for 3 seconds instead.btv.cmd("normal! …")and keys fed withbtv._feedkeysstill 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 modelessfails 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" }
| Group | Rows | Modeless session | Vim session |
|---|---|---|---|
undo | Undo, Redo | the modeless verbs | :undo / :redo |
clipboard | Cut, Copy, Paste, Select All | the modeless verbs | "+d, "+y, "+P, ggVG |
lsp | Go to Definition, Find References, Rename Symbol, Format Document, Code Actions | btv.lsp.* | btv.lsp.* |
commands | Command Palette | the palette | the palette |
- Helix’s modes are a vim session here, with Helix’s
%for Select All andxthenyfor 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.
lspshows 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 | |
|---|---|
label | The row’s text. Required. |
action | function(ctx), run when the row is chosen. It may return a promise. Required. |
detail | The right-hand hint: a string, or function(ctx) returning one or nil. |
when | Hide the row unless this is true: a boolean, or function(ctx). |
enabled | Grey 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… | Example | u takes back |
|---|---|---|
| type a separator after a word | foo bar | bar (the space goes with the word after it) |
| type the first character of a new line | foo<CR>bar | bar, then foo and the line break |
| switch between typing and deleting | foo<BS><BS>x | x, then both <BS>s, then foo |
| pause for more than a second | foo, wait, bar | bar |
| move the cursor, then edit | foo<Left>X | X |
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
ccommand types.cwnew<Esc>urestores the old word in oneu. 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
uputs 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.uafter.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
| Where | In scope | Returns |
|---|---|---|
:s/…/\=…/ | m, lnum | the replacement text |
"= / <C-r>= | line, lnum, col | text to insert or paste |
'foldexpr' | line, lnum | a fold level (see Fold levels) |
btv.fold.text | first, lines, lnum | a closed fold’s collapsed row |
btv.indent.expr | prev, line, lnum, sw, previndent | indent columns, or nil |
btv.filetype.detect | name, ext, head | a filetype, or nil |
btv.picker.scorer | label, query, score | a sort key, higher first |
btv.complete.scorer | label, query, score, kind | a sort key, higher first |
btv.decor.expr | line, lnum | spans to draw (a block) |
btv.qf.text | item, idx | one quickfix row’s text |
btv.qf.parse | line, lnum | one 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:
| Value | Meaning |
|---|---|
0, 1, 2, … | this line is at that level |
>N | a fold of level N starts here |
<N | a fold of level N ends here |
aN | N deeper than the previous line |
sN | N shallower than the previous line |
= | same level as the previous line |
-1 | undefined — 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 andppastes it — but"=lnum<CR>pfollowed byj.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:sandd/fooare 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.provider | btv.decor.expr | |
|---|---|---|
| power | full Lua, async, any editor state | line and lnum, nothing else |
| when | off the frame, one frame later | during the frame it belongs to |
| draws | the whole extmark vocabulary, styled per chunk | the 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:
| Surface | Why a counter would lie |
|---|---|
:s | the live preview re-runs it on every keystroke you type |
'foldexpr' | only the rows an edit touched are re-evaluated |
btv.picker.scorer | only the top survivors, re-run on each repaint |
btv.complete.scorer | the same, over the popup’s rows |
btv.decor.expr | only the rows on screen, and only when the viewport moved |
btv.fold.text | memoized, so calls are skipped outright |
btv.filetype.detect | once 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
| Option | Default | Meaning |
|---|---|---|
'scrollanim' | true | Animate viewport scrolls. :set noscrollanim snaps every scroll. |
'scrollanimduration' | 160 | The 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', andshiftwidth=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 its0sentinel. - 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
| Option | Default | Meaning |
|---|---|---|
'indentdetect' ('idt') | true | Let 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=1is 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 for | What it is |
|---|---|---|
| A stateful UI that redraws itself (file tree, dashboard, dialog) | btv.view.component / btv.component | A Vue-shaped reactive component: state + a pure render. |
| Plugin-owned content you update by hand (a list, a report) | btv.view | An 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 / confirm | Async prompt widgets — return a promise. |
| To pop up read-only content (a hint, a tooltip) | btv.ui.float | A bordered content overlay, dismissed by the next key. |
| A real, editable window placed over the layout | nvim_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 thestatevalue handed to render. It runs only after the surface is ready, so everything onctxis 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 field | Purpose |
|---|---|
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.props | The 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 whatbtv.view.componentselects) — a focus-taking, navigablebtv.viewbuffer, mounted in a dock, split, or grabbing float.renderreturns{ lines, decor }. This is the file-tree / list / modal-dialog case;ctxgainskeymap_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;renderreturns{ 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 withbtv.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)
| Method | Does |
|---|---|
: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)—promptlabel +defaultprefill, over the command line.""on an empty<CR>,nilon<Esc>. -
btv.ui.select(items, opts)— a floating list.format_itemmaps an item to its label (defaulttostring); the original item round-trips back, so an arbitrary table survives even though only strings cross the bridge. Its keys are rebindableselect-mode maps (j/k/<C-n>/<C-p>/arrows nav,gg/Gfirst/last,<CR>confirm,<Esc>/qcancel):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 withenabled = falsegreys 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 (anaction) 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 withbtv.contextmenurather 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];falsemakes it decline and shows[y/N]. For a multi-choice menu useselect. -
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 }(likebtv.run, it resolves rather than rejects — a missing opener iscode = -1).
Popup content
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?
| Surface | What it is | Real window? | Grabs input? | Scrolls? |
|---|---|---|---|---|
| Floating window | A free, editable window over the layout — you manage its lifecycle | ✅ | optional (grab) | ✅ |
| Popup content | A lightweight display overlay (btv.ui.float) — not a window | ❌ | never | ❌ |
| Popup window | A transient, read-only real window over a scratch buffer — auto-dismissed but scrollable | ✅ | never | ✅ |
| List widget | The floating selectable list — picker, btv.ui.select, btv.ui.menu, completion | server 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:
| Field | Meaning |
|---|---|
width / height | Cells (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. |
align | A 9-grid word — "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right". |
margin | Inset 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). |
title | A string drawn on the top border. |
grab | true (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/:
| Example | Shows |
|---|---|
btvchecklist | A modal checkbox dialog written with btv.view.component (reactive state + pure render). |
btvview | A dockable btv.view content surface with <CR> → open-in-main. |
which-key | A real which-key as a surface = "float" component over the pending-key oracle. |
ui-prompt | btv.ui.input and btv.ui.confirm prompts. |
ui-select | The floating chooser, including items that carry data. |
ui-float | Popup content (\f / \F), and LSP hover (K) through the popup window. |
window-geometry | The 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.
| Keys | Effect |
|---|---|
<C-w><C-w>h / j / k / l | Cross 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-command | Does |
|---|---|
: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(andbtv.dock.close) drops the dock and its content.:DockToggle/:DockHide/:DockShow(andbtv.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
| Option | Meaning |
|---|---|
size | Width (left/right) or height (top/bottom); settable live to grow/shrink |
title | A fixed strip label, shown ahead of the dock’s tab cells |
showtabline | Per-dock override of the global option (0 never / 1 if >1 tab / 2 always) |
laststatus | Per-dock statusline override (0/1/2/3) |
autohide | Collapse the dock the moment focus leaves it; it pops back when you cross in |
winhighlight | Per-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.
autohideis 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_gitrespect.gitignore(and.ignore/.rgignore) —rg --hidden, so a dotfile like.github/workflows/ci.ymlis still found and only ignored paths drop out. Withoutrgthey fall back togit ls-files/git grepinside a work tree, to plainfind/grepoutside every repository (no.gitat or above the directory, so, as for rg, no.gitignoreapplies), and otherwise (git missing or refusing the repository, or the pure web client) to abtv.fswalk that reads the ignore files itself (btv.fs.walk{ gitignore = true }) — so the ignore rules hold everywhere. Thegit grepleg needs git 2.19 or later (--column); with an older git,live_grep_gitsays so instead of searching. A basicgrep(busybox, stock Alpine: no--null,-Ior--exclude-dir) still works, but a file name holding:<digits>:is cut short there. These are the everyday<leader>ff/<leader>fg.files/live_grepsearch unrestricted — the equivalent ofrg -uu(--no-ignore --hidden), so a.gitignored build artifact is still findable. The one exclusion is.gititself. 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:
| Map | Source |
|---|---|
<leader>ff | files_git — fuzzy file finder, .gitignore respected |
<leader>fF | files — fuzzy file finder over every file, ignored ones too |
<leader>fg | live_grep_git — live grep, .gitignore respected |
<leader>fG | live_grep — live grep over every file, ignored ones too |
<leader>fb | buffers — open buffers (scoped to the focused layer, like :ls) |
<leader>f/ | curbuf — fuzzy find in the current buffer |
<leader>fd | diagnostics — diagnostics |
<leader>fk | keymaps — keymaps |
<leader>fm | marks — marks |
<leader>fj | jumplist — the jumplist |
<leader>fi | pickers — the registered pickers themselves |
<leader>fr | resume — 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):
| Key | Action |
|---|---|
| (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
})
headmakes 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 aroundmatchso a hit 200 columns into a line still shows. A head that doesn’t fit elides tail-first, keepingfile:line— the part you scan for.tagis a short classification prepended to the head and pinned: the elision happens after it, soEsurvives however narrow the column gets. Use it whenever the head’s leading characters say what the row is.hlis 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 ofitem.path."location"— showsitem.pathscrolled toitem.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:
| Option | Meaning |
|---|---|
width / height | A fixed box size: a cell count (100) or a viewport fraction ("80vw" / "60vh" / "50%"). The picker is never content-sized. |
align + margin | Placement, 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). |
query | Initial prompt text — the picker opens already filtered against it, caret at its end. Default "". |
title | A title centered on the box’s top border (the shipped sources set their own); nil for none. |
multiselect | Whether <Tab> marks rows for a batch action (default true); false is a single-choice picker. |
layer | Where 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". |
debounce | Milliseconds before a dynamic source re-runs; 0 off. |
include / exclude | Pre-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 says0.
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 type | It matches |
|---|---|
*.lock | any .lock file, at any depth |
target | the 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 = }:
| Function | Effect |
|---|---|
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)
| Call | Purpose |
|---|---|
btv.qf.list(name, items[, opts]) -> name | create / replace the list name; repaints its tab if open |
btv.qf.show(name) -> name | open or focus the list’s dock tab |
btv.qf.drop(name) -> name | close 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_loclistopen 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_textin 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
prioritywins, then the higher mark id. Within one namespace that is the mark placed later, unless you reuse an explicitid; 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, orCursorLineNron the cursor line. Aline_hl_grouptint runs under the column too, so a diff’s tint reaches its numbers.
The rules
gutter_textis 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_decorand abtv.decorprovider’spublishalike, since the three share one option vocabulary. - A
btv.decor.exprblock can’t paint it: that frame-time surface has its own key list, andgutter_textisn’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-namespaceoverrides 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-cwdto keep the launch directory). A relativeTARGETfile 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.luais 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/:wopen 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’sinjections.scm: markdown’s prose is parsed by themarkdown_inlinegrammar the block grammar injects, and the code inside a```langfence by that language’s own.
What’s different from native
| In the browser | |
|---|---|
| Plugins | init.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. |
| LSP | Works 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-sitter | The in-process parser is gated off the build; highlighting uses the JS-side web-tree-sitter path instead. |
| Processes | Serverless 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). |
| Hosting | Requires 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/:wpersist to the browser’s Origin Private File System, and:e <dir>lists it. Yourinit.luaand 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:won 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 stringbemtvi --daemon --listenprints), or dial it at runtime with:connect bemtvi://…, and:e/:woperate 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 showsHOST:PORTand 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.mjsis the single!Sendthread holding core + Lua; it loads the wasm module (dist/eh.mjs) and runs the production tick. The UI thread (web/index.html) paints the serverredrawframe as HTML/CSS — a per-cell-span DOM renderer (windows, gutter, status/tabline, pmenu, cursor shapes, smooth scroll) — and translatesKeyboardEvents to vim key-notation. The two talk overpostMessageand a shared ring. - One wait drives input and timers. When cross-origin isolated, the Worker parks
on
Atomics.waitover aSharedArrayBufferinput ring, waking on a keystroke or the next timer deadline — sobtv.timer/vim.defer_fnfire without Asyncify, one mechanism. (Without isolation it falls back to apostMessageloop 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 isemcc.src/lib.rsexportseh_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.__bemtvihook (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-emscriptenand links C viaemcc, so it sits in the rootCargo.toml’s[workspace] exclude(a hostcargo build --workspacenever 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 — the same split reached natively, with
the editor local and an
bemtvi --daemonserving fs / processes over ssh or QUIC. - Architecture → the web build — the crate layout and the full redraw/interop projection.
- The edit-host & browser-Lua plan — the design and its phased implementation.
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 asyncHostFsAsync, the LSP transport, and the Lua-facingLuaFs— 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, undo | Opening / saving files and the file explorer |
| The Lua VM and the redraw | Processes — 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. :reconnectre-dials now (and resets the retry budget);:disconnectdrops 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"(ornilfor a local session), and aUser DaemonStatusChangedautocmd fires on every change — so a statusline component can render it (green / yellow / red). Seeexamples/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
WebTransportconstructor.
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, annpm 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 samewtransport/quinnstack the browser’s WebTransport uses, native and browser unify on one daemon (the browser opens the same four streams). See the multi-stream plan. :connectswaps the backend, not the window. In the GUI,:connectbuilds 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
- Browser editor — the same daemon reached from a browser tab.
- Architecture → Embedded vs. remote — where this sits in the client–server model.
- The edit-host & browser-Lua plan — the design and its phased implementation.
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::spawnstarts 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_exitis required. It’s what the app does when the editor quits (:qof 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 (eis:edit). A disabled command fails withE5800: :lua is disabled in this editorwherever it’s run from: typed, from a mapping, from a plugin’sbtv.cmd, an autocmd, under:silentor:tab. As an error it also stops the rest of a|chain. Completion leaves it out, and Lua can askbtv.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 withE5802, 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 ownexec_lua.:commanddefines 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 withE5801: 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,:bdof the file itself. Views, panels, floats, quickfix lists and terminals work as ever, and so do:e!,:wand:spof the file itself. - The plugin manager.
disable_plugin_manager()makes everybtv.pluginscall fail withE5805, so the config declares, loads, installs and updates nothing through it, and its:Plugin*commands are disabled. Plugins under the config’spack/*/start/still load. A config can askbtv.plugins.disabled(). - LSP.
disable_lsp()means no language server starts.btv.lsp.enable,startandrestartfail withE5804, and the:Lsp*commands are disabled. A config meant to run both ways checksbtv.lsp.disabled()first. - Pickers.
disable_pickers(["files", "live_grep"])takes pickers away by source name.btv.picker.openfails withE5803for one, and thecommandsandpickerslists leave it out. A config can askbtv.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.
| Plugin | What it is | Read it to learn |
|---|---|---|
| bemtvi-lspconfig | Ready-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-tree | Dockable 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-line | Lualine-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-helper | Live 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-dap | Debug 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-diff | Meld-style side-by-side diff viewer | Read-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-help | Vim-style :help | A 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— thebtv.pluginspackage manager and its dashboard: declarative specs, asyncgitoverbtv.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:
- Reads are snapshots.
btv.buf.lines(b)and friends read the state pushed at Lua entry. Documented, not disguised as live access. - 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. - Nothing blocks, ever. No wait-pumps, no blocking reads, no uv handles.
Anything that waits returns a promise you
btv.awaitinsidebtv.async, or — for streaming — an async-iterator (btv.run_stream+btv.await_each). See Async & promises. - 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.
- 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 neovim | First-class content surfaces: btv.view in an btv.dock, btv.component for reactive ones |
| Decoration provider | btv.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.apiofnvim_create_autocmd/augroup/del/clear(→btv.on),nvim_create_user_command(→btv.command), andnvim_set_hl(→btv.hl.define). - Callback-shaped async —
vim.notify,vim.schedule,vim.defer_fn, andvim.ui.input/select(process spawning isbtv.run/btv.run_stream— there is novim.system). - Treesitter foldexpr —
vim.treesitter.foldexpr, neovim’s spelling of the native'foldexpr'marker (btv.treesitter.foldexpr). Highlighting itself is declarative buffer state — thebtv.bo.filetype/btv.bo.ts_highlightnouns — 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 plugins — the hands-on authoring guide.
- Async & promises — rule 3 in practice (
btv.promise/async/await). - ADR 0002 — native plugin system — the
decision record and the canonical list of
vim.*aliases. - The native plugin API design sketch — the full surface, the five rules, and six worked examples.
Writing bemtvi plugins
A plugin is pure Lua over the btv.* API — no Rust, no Vimscript. The server
owns every UI surface (windows, floats, the completion menu, the statusline); a
plugin supplies data and behavior and reaches the screen through btv.*.
Our prime directive is every feature that can be a plugin is one, so we exercise
our own APIs.
If you’ve written a neovim plugin, the shape is familiar — a lua/<name>/init.lua
module exposing setup(opts) — but the API you call is btv.*, not vim.*. (A
closed whitelist of vim.* muscle-memory aliases exists for config ergonomics; new
plugin code should target btv.* directly.)
Writing plugins with a coding agent
The btv.* model is not in any agent’s training data — left to itself, an agent
writes neovim plugins that fail on bemtvi (vim.uv, blocking vim.wait, a
nvim_buf_set_lines that does not exist). bemtvi-plugin-skills
is a set of agent skills that teach it the real model, the surfaces below, and the
traps; it also bundles a generated snapshot of the btv.* API reference and every
public btv.*/vim.* name in a live editor.
npx skills add https://github.com/bemtvi/bemtvi-plugin-skills # any agent
Claude Code can install it as a plugin instead:
/plugin marketplace add bemtvi/bemtvi-plugin-skills
/plugin install bemtvi-plugin-skills
One skill per surface — scaffolding, async, keymaps/commands/events,
buffers/windows, UI and components, views and docks, decorations, picker,
completion, statusline, LSP and diagnostics, fs/process/net, testing, vimdoc, and
porting from neovim — with the bemtvi skill routing to the right one.
Anatomy of a plugin
A plugin is a directory laid out the way neovim plugins are, so it resolves on the runtimepath:
my-plugin/
├── lua/
│ └── my-plugin/
│ └── init.lua # the module: returns { setup = … }
├── plugin/ # optional: *.lua here is auto-sourced at load
│ └── my-plugin.lua
├── after/plugin/ # optional: sourced after plugin/
├── colors/ # optional: colors/<name>.lua for a colorscheme
└── test/ # optional: *_spec.lua (see Testing)
└── my-plugin_spec.lua
The conventional entry point is a module that exposes setup:
-- lua/my-plugin/init.lua
local M = {}
function M.setup(opts)
opts = opts or {}
btv.keymap.set("n", "<leader>x", function()
btv.notify("hello from my-plugin", "info")
end, { desc = "my-plugin: do the thing" })
end
return M
setup should be idempotent (a user may call it more than once) and side-effect
light at module load — do the wiring in setup, not at require time, so load order
and lazy-loading stay predictable.
Installing & loading
Two paths, both runtimepath-based:
The built-in manager (btv.plugins). Declare plugins in init.lua; the manager
clones, runtimepaths, and loads them (with optional lazy triggers):
btv.plugins({
-- eager:
{ "bemtvi/bemtvi-keys-helper",
config = function() require("bemtvi-keys-helper").setup({}) end },
-- lazy by key / command / event / filetype:
{ "owner/repo", keys = { "<leader>ff" }, cmd = "Find",
config = function() require("repo").setup({}) end },
-- pinned, or a local checkout for development:
{ "owner/repo", tag = "v1.0.0" },
{ name = "my-plugin", dir = "/path/to/my-plugin",
config = function() require("my-plugin").setup({}) end },
})
Then :PluginSync (realize the declared + locked state), :PluginInstall,
:PluginUpdate (fast-forward, advancing past the lockfile), :PluginRestore
(check out the locked commits), :PluginLock, :PluginClean, :PluginList, or the
:Plugins dashboard. Cloned plugins live under stdpath("data")/plugins/<name>.
Each of those verbs takes an optional plugin list — :PluginUpdate my-plugin,
<Tab>-completed — and then touches only those plugins (and their dependencies),
leaving every other checkout and lockfile entry alone; in the dashboard the
lower-case keys (i u s r x) do the same for the row under the cursor. When
you are developing one plugin, that is the verb you want: re-cloning or updating the
whole set to test one change is how a working editor turns into a bisect.
Every install/update records each plugin’s resolved commit in
<config>/bemtvi-lock.json, and installing reproduces those commits — so a config plus
its lockfile pins the exact plugin tree. :PluginSync therefore does not move a
plugin the lockfile pins; advancing past it is :PluginUpdate’s job, and
:PluginRestore goes back. See
Configuration → the lockfile for the full rules. A spec with cmd/event/ft/keys (or
lazy = true) loads on first use; config runs after the plugin is on the
runtimepath, init runs at startup regardless. Restoring a workspace session counts as
first use too: a persisted btv.view
slot names its owner, so a lazy plugin whose sidebar was open when you quit is loaded by
the restore itself — no trigger to press, nothing extra to declare.
Git submodules are initialised by default — the manager clones with
--recurse-submodules and runs git submodule update --init --recursive on update,
so a plugin that vendors its dependencies as submodules lands complete. Set
submodules = false on a spec to opt a plugin out (skips the extra git work for one
you know has none).
By hand. Drop the plugin under <config>/pack/*/start/* and require it from
init.lua — the runtimepath picks it up with no manager involved.
The btv.* surfaces you’ll use
A plugin composes these (each has runnable examples under examples/
and a deeper treatment in the API design):
- Keymaps —
btv.keymap.set(mode, lhs, rhs, opts)/btv.keymap.del; introspect withbtv.keymap.get. Always pass adesc— it surfaces in completion and which-key. - User commands —
btv.command(name, fn, { desc = …, usage = …, complete = … });fnreceives{ args, fargs, bang, … }.usageis the argument signature in vim help notation (usage = "[file]","{name}") — it heads the command’s:-completion docs pane as:Name <usage>, exactly like a built-in.complete("file"/"dir"/ afn(args)) drives<Tab>completion of the argument. - Autocmds / events —
btv.on(event, { pattern = … }, fn)for editor lifecycle events (BufReadPost,FileType, …), orbtv.on(event, fn)with no options. - Options & vars — read/write
btv.o(global),btv.bo(buffer),btv.wo(window), andbtv.g(globals). Edge docks have their own scope too:btv.dock.opt(side)(e.g.btv.dock.opt("left").size = 32), alongsidebtv.bo/btv.wo— per-dockshowtabline,laststatus,size,title,winhighlight, andautohide. - Highlights —
btv.hl.define(ns, name, spec),btv.hl.get,btv.hl.exists. Define your groups as fallbacks (btv.hl.existsguard) so a colorscheme that already styles them wins. - Messages —
btv.notify(msg, level). - Async — the editor is single-threaded and tick-based, so anything that waits
is promise-based:
btv.async/btv.await,btv.promise,btv.run/btv.run_stream(subprocesses),btv.fs(filesystem),btv.timer,btv.utils.debounce, and the scheduling primitivesbtv.schedule(end of the current tick) /btv.on_next_tick/btv.wait_for(pred, opts)(across ticks). Reach foron_next_tick/wait_for— never abtv.schedulere-arm — when waiting on state that only refreshes between ticks (e.g. a freshly-mounted window id). Full guide: Async & promises. - UI — the floating-widget layer
btv.ui.input/select/confirm/float(promise-based, never steals focus forfloat), andbtv.component(reactive state + a pure render + lifecycle) for live popups. Bigger server-owned surfaces:btv.picker(fuzzy finder),btv.complete(completion sources),btv.snippet,btv.statusline(composable segments),btv.decor(viewport decorations / extmarks),btv.dock(edge docks), andbtv.view(a read-only mountable content surface — what a plugin ui is built on).
When something genuinely useful is missing, the convention is to add it to btv.*
for everyone, so let us know if you find anything missing.
A worked example
bemtvi-keys-helper — the
first-party which-key — is a compact, real-world plugin: it subscribes to the
pending-key oracle (btv.on_key_pending), debounces with btv.utils.debounce, and
renders the continuations on a non-focus btv.component{ surface = "float" }. It is
packaged exactly as above (lua/bemtvi-keys-helper/init.lua with setup/add) and
carries its own test suite.
The examples/ directory has ~85 self-contained configs — one per
feature — that double as plugin-authoring references.
Testing
Plugins are pure Lua, so their tests are too. bemtvi ships a native test framework
(btv.test) and a headless runner — write test/*_spec.lua, then:
bemtvi --test-plugin .
The suite drives a real editor (feed keys, assert on buffer / cursor / UI) and
exits 0/1 for CI. See the full guide: Testing plugins.
See also
- bemtvi-plugin-skills —
agent skills for authoring plugins, one per
btv.*surface. - Native plugin API design — the model and six worked API examples.
- ADR 0002 — native plugin system — why
btv.*, and the exactvim.*alias whitelist. - Testing bemtvi plugins —
btv.test+bemtvi --test-plugin. - Architecture — the crate layout, tick model, and Lua bridge.
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):
btvasync 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. (Thevim.*muscle-memory aliases keep neovim’s callback shapes; newbtvcode 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:
| Method | Does |
|---|---|
: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
:nextcallback runs as a microtask (deferred to the end of the current tick viabtv.schedule), never inline — even on an already-settled promise. Same as the browser running.thenoff 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 viaawait.
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.awaitmust be called inside anbtv.asyncfunction (there’s nothing to suspend otherwise — it errors loudly). It’s also whatsetup/renderrun inside on anbtv.component, so you canbtv.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:
| Surface | Returns | For |
|---|---|---|
btv.run{ cmd, args, cwd, env, stdin } | promise of { code, stdout, stderr } | Run a subprocess to completion. |
btv.run_stream{ … } + btv.await_each | a Stream (async-iterator) | Stream a subprocess’s stdout as it arrives. |
btv.fs.read / read_text / stat / readdir / exists / … | promise of the result | Filesystem, never blocking. |
btv.ui.input / select / confirm / open | promise of the user’s answer | Prompts and choosers. |
btv.lsp.hover / references / … | promise | Language-server requests. |
btv.promise.delay(ms[, v]) | promise that fulfils after ms | An await-able sleep. |
btv.wait_for(pred[, opts]) | promise of the truthy value | Poll 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:
| Combinator | Settles when | With |
|---|---|---|
all(list) | all fulfil (or any rejects) | the array of values, in input order |
all_settled(list) | every one settles | an array of { status, value } / { status, reason } (never rejects) |
race(list) | the first settles | that 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:
| Primitive | Runs 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 tick | observe 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 value | await a cross-tick condition |
The cross-tick gotcha.
btv.scheduleruns 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-armingbtv.schedule(self)to “wait” for such a value spins forever. For anything cross-tick usebtv.on_next_tickorbtv.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 invocationmsafter the last (which-key’s show-delay, on-change/resize/scroll reactions). Callback-shaped, not a promise — a different job — but they compose: pass anbtv.asyncfunction asfnto 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 avim.*-style callback API into the promise world.btv.schedule_wrap(fn)— a function that, when called, defersfnto the end of the tick — thebtv.scheduleanalogue of a wrapped callback.
Try it
| Example | Shows |
|---|---|
async-runtime | The bare loop — btv.schedule vs btv.timer, and a self-rescheduling timer firing on wall-clock time while the editor stays responsive. |
ui-picker | btv.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
- Writing bemtvi plugins — where this fits in a plugin.
- UI primitives —
btv.component’ssetup/renderarebtv.asynccontexts; widgets return promises. - ADR 0002 — native plugin system — why
promise-only, and the
vim.*alias whitelist.
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 adofileat 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 orbtv.decorprovider 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
| Call | Meaning |
|---|---|
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)):
| Matcher | Passes 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:
| Method | Does |
|---|---|
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):
| Method | Returns |
|---|---|
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 withbtv.fsto 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
- Writing bemtvi plugins — the anatomy a spec tests.
- Async & promises — the
btv.async/t:wait_formachinery the context is built on. - Native plugin API design — why plugins are pure Lua, hence testable as pure Lua.
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
| Event | When it fires | Notes |
|---|---|---|
BufAdd | A 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). |
BufReadPost | A 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. |
BufNewFile | A buffer is opened for a path with no file on disk — fires instead of BufReadPost. | buf / file set. |
FileType | A 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 / BufLeave | A 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. |
BufWinEnter | A 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. |
BufReadCmd | A 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. |
BufDelete | Just 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,BufNewFileandFileTypeare replayed. They fire once per read and carry no pairing semantics, so re-delivering one is unambiguous.BufEnterandBufWinEnterare not: they mean “became current” / “a window displayed it”, which may no longer be true by the time the plugins land, and aBufEnterreplayed without itsBufLeavetwin would misreport editor state. - The window is a startup one. It closes at
PluginsLoadedand 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
| Event | When it fires | Notes |
|---|---|---|
BufWritePre | Before 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). |
BufWrite | Same point as BufWritePre — the bare-name spelling. | An alias for BufWritePre (see Event aliases); handlers on both spellings fire once. |
BufWritePost | After a successful write. | The hook “reload affected tools” plugins use. |
Window & tab
| Event | When it fires |
|---|---|
WinNew | A new window is created. |
WinEnter / WinLeave | Focus moves to / away from a window. |
WinClosed | A window is closed. |
WinResized | A window’s rectangle changes (split/resize/layout change). |
WinScrolled | A 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. |
TabNew | A new tab page is created. |
TabEnter / TabLeave | The active tab changes. |
TabClosed | A tab page is closed. |
Mode
| Event | When it fires |
|---|---|
InsertEnter / InsertLeave | The editor enters / leaves Insert (or Replace) mode. |
ModeChanged | The 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.
| Event | When it fires |
|---|---|
TextChanged | The buffer’s text changes in Normal mode (edit, paste, …). |
TextChangedI | The buffer’s text changes in Insert mode (per keystroke). |
BufModifiedSet | A 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'). |
CursorMoved | The cursor moves in Normal mode. |
CursorMovedI | The cursor moves in Insert mode. |
Mouse
| Event | When it fires | Notes |
|---|---|---|
MenuPopup | A 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
| Event | When it fires | Notes |
|---|---|---|
LspAttach | A language server attaches to a buffer. | data = { client_id = … }. |
LspDetach | A language server detaches from a buffer. | data = { client_id = … }. |
LspProgress | A 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
| Event | When it fires | Notes |
|---|---|---|
TSInstalled | A :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
| Event | When it fires | Notes |
|---|---|---|
FileChangedShell | A loaded file changed on disk (the watch/checktime reconcile). | A handler may set vim.v.fcs_choice to "reload" / "edit" / "ask". |
FileChangedShellPost | After the file-change reconcile completes. | |
DirChanged | The working directory changes (:cd / :lcd / :tcd). | file is the new cwd; match is the scope. |
EncodingChanged | The 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). |
ColorScheme | A colorscheme finishes loading. | match is the colorscheme name. This is what colorscheme plugins hook. |
Startup
| Event | When it fires | Notes |
|---|---|---|
VimEnter | Once, 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. |
UIEnter | Each 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.
| Event | When it fires | Notes |
|---|---|---|
QuitPre | First, 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. |
ExitPre | After QuitPre settles — “the editor is really leaving”. | Awaited. The natural place for an async flush before quitting. |
VimLeavePre | After ExitPre settles — the last hook before session state (shada) is written. | Awaited. Modify anything you want persisted from here. |
VimLeave | After 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.
| Event | When it fires | Notes |
|---|---|---|
PluginsLoaded | Once, 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. |
PluginLoaded | Each 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).
| Alias | Canonical event |
|---|---|
BufRead | BufReadPost |
BufWrite | BufWritePre |
BufCreate | BufAdd |
FileEncoding | EncodingChanged |
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 = falsefor plain:editsemantics — 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. - shown in a window → focus that window (across tabs under the default
-
where("main"| nil) — withwhere = "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 (callbtv.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 thestatevalue handed torender. Runs only after the surface is ready, so everything onctxis already valid (no tick-dance). May be async.surface—"view"(default) or"float"; or passbackend(afunction(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 withipairs/#(NOTpairs— PUC 5.4 has no__pairs).ctx.computed(getter)— a cached derived value, read asc()orc.value; it re-evaluates only when a reactive input it read last time has changed.ctx.refresh()— force a re-render.ctx.props— theopts.propsfrommount.ctx.on_close(fn)/ctx.close()— register a teardown hook / close the instance. On the"view"surfacectxalso 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), andctx.keymap_set(mode, lhs, rhs, opts)(buffer-scoped +nowaitby default). A view is read-only, so in a modeless session ('modeless') its keys reach it only through themodelesslayer: pass{ "n", "modeless" }asmodeto serve both editing models.
Surfaces — what render returns, and how the surface behaves:
"view"— a focus-taking, navigablebtv.viewbuffer (dock / split / grabbing float): the file-tree / list / modal-dialog case.renderreturns{ lines, decor }(or a bare line list).btv.view.component(def)is the sugar."float"— a NON-focusbtv.ui.floatcontent float (the which-key surface): never steals focus, binds no keys.renderreturns{ 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 insetup, 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 aftercmd, 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
- so feature-probing stays honest:
'&opt'/'&l:opt'/'&g:opt'/'+opt'-> an option bemtvi models. A completion plugin gates every window-option write onexists('+'..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; ORcommand— 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 (seebtv.augroup.create). Ties this autocmd to the group so a laterclearof that group drops it.buffer— make it buffer-local: it then fires only for that buffer (andpatternis ignored).0resolves 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’sscheme://prefix, or a matcher functionfn(url) -> booleanfor host-pattern routing (e.g. only*.internalhosts).resolver—fn(url) -> spec. It resolvesurlto a session-swap spec (the tablebtv.session.reconnecttakes:{ 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 withbtv.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")isfalse; say"**/*.lua", or passbasename = trueto match the path’s last component. This is shell / gitignore / LSP semantics. Passliteral_separator = falsefor 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 passstyle = "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://orhttps://). Required.opts— an optional table:method— HTTP method (default"GET"); upper-cased for you.query— query parameters appended tourl(encoded), so you never build?a=1&b=2by hand. A{ [name] = value }map or a{ {name, value}, ... }pair-list (repeated/ordered keys); a LIST value repeats the key. Seebtv.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 aContent-Type: application/jsonheader is added unless the caller set one.form— a table sent as anapplication/x-www-form-urlencodedbody (same shape asquery); sets the content-type header unless the caller did. Mutually exclusive withbody.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 infetch.max_redirects— cap on redirects to follow whenredirect == "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 countcharacterin. Pass the answering client’soffset_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 rangedhl_groupextmarks 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-widthline_hl_groupcode background, and their fence delimiter lines are hidden behind a blankingvirt_textoverlay 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 metarget/” knob: set it once and everyfiles/live_grepopens narrowed.history— how many past lines each box keeps for<C-Up>/<C-Down>(default 20).0disables 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 — seebtv.picker.sourcefor 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 (seebtv.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);falseis a single-choice picker (no marking).debounce— ms before adynamicsource re-runs on a query edit;0off.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 withfilter = 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 shippedfiles/live_grepsources set"main",buffersstays"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 toname).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— anitem -> display stringmapper (defaulttostring); 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 aMenuPopuphandler (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 forbtv.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) -> candidatesdriving<Tab>autocomplete (the inline wildmenu above the prompt line —<Tab>/<S-Tab>cycle,<CR>accepts).candidatesis a{ {label, insert?, doc?, start?, length?}, … }list (insertdefaults tolabel), OR a PROMISE of one — so an async source (e.g. a DAPcompletionsrequest) works. The token completed is the trailing identifier run before the cursor, UNLESS a candidate suppliesstart(0-based char offset into the line) +length(chars), an explicit replace span that overrides the token for that row.colis the cursor’s 0-based char offset.complete_docs— show the side docs pane rendering each candidate’sdocbeside the list (default true whencompleteis set;falsesuppresses 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;0disables 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 (defaulttrue= 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) andbtv.ui.floatreturns 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. Withoutpersistit 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/:edituse;- a function
fn(args)— generate candidates dynamically.argsis 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. anbtv.asyncfunction) 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):
idis the plugin-chosen persist string (the same one passed tocreate{ persist=}),place(view)drops a freshly-created view into the reserved window. The plugin keyed its ownbtv.shada.plugin()store byid, so it reads its saved state back and rebuilds the view’s content before callingplace. The owning namespace is resolved from the caller’s location; the optionalnamespacearg 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
- Editing compatibility first. Keystrokes, modes, ex-commands, and options
should match vim/neovim’s observable behavior. When in doubt, the reference
in
vendor/neovimis the source of truth. Note: bemtvi does not aim for neovim UI/client wire-compatibility — there is noext_linegridprotocol and external neovim GUIs are not a target. The client↔server protocol is bemtvi’s own. - A native plugin system;
btv.*is the only API. Extensibility is bemtvi’s own provider-based plugin API (thebtvdesign, ADR 0002): the server owns every UI surface and the frame; plugins supply data and behavior, asynchronously. Configuration is the same namespace:init.luais written againstbtv.*. 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 purevim.tbl_*-style helpers, … — the canonical list is ADR 0002) maps 1:1 onto the samebtvobjects, so config can be written in familiar muscle-memory terms — aliases, not an API; beyond them there is novim.*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 thebtvhighlight API (and itsvim.*aliases), nothing more. Supporting legacy Vimscript (.vimplugins, theeval.clanguage) is likewise an explicit non-goal. - Dogfood the plugin API: first-party features are
btvplugins. Everything that can reasonably be built as anbtv.*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 bundledbtvplugins rather than as bespoke Rust. If a feature can’t be expressed againstbtv.*, 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. - Client-server, always. The editor is a headless server; every UI is a client. There is no “embedded-only” code path.
- 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.
- 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 crate | neovim counterpart | responsibility |
|---|---|---|
bemtvi-core | buffer.c, normal.c, ops.c, edit.c, ex_docmd.c, undo.c, option.c | The editor model: buffers, modes, motions, operators, ex-commands, undo, and the renderable View. Pure & synchronous. |
bemtvi-rpc | msgpack_rpc/ | Async msgpack-RPC transport (bemtvi’s own protocol; msgpack is just the framing). |
bemtvi-server | main.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-lua | lua/ | Embedded Lua runtime (vendored PUC Lua 5.4, the single backend) and the btv.* Lua prelude (plus its bounded vim.* alias layer). |
bemtvi-tui | tui/ | The terminal UI client. A thin RPC client; owns no editor state. |
bemtvi-ts | tree_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-lsp | lsp/ | 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-regex | regexp.c, regexp_nfa.c | The 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. |
bemtvi | the nvim entry point | Wires 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::connectspawns 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-coreis pure/sync) and the!SendVM rather than by neovim’s runtimerecursive-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/childMultiQueueinstead 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
stylespalette — an array of resolved styles{ fg, bg, sp, bold, italic, … }with colors as 24-bit0xRRGGBBints, deduped so identical styles cost one entry; - the per-row
highlightsarray (aligned withlines) of screen-column spans[start, end, group, style_id], wheregroupis the treesitter capture name andstyle_idindexesstyles(or isnilwhen no colorscheme resolved it); - a
chromemap of editor-region →style_idfor 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 anOpenBuffer(the textBufferplus its branching undo tree and the cursor/scroll position saved while the buffer is not current), stored in aBufferStorekeyed by a monotonic, 1-basedBufferIdthat is never reused. -
Window state (the “view”): the live cursor, scroll
top, mode, and pending-input state stay onEditor, alongsidecurrent(the shown buffer) andalternate(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 — aBufferOptionslives on eachBuffer, set via:set/:setlocal/vim.bo, so two buffers can indent differently. bemtvi’s defaults differ from vim’s:tabstopis 4, andshiftwidth/softtabstopfollow it via their0/-1sentinels (softtabstop → shiftwidth → tabstop), so one knob sets the indent width. - The number-gutter options (
number/relativenumber) are window-local — aWindowOptionslives on each window, set via:set/:setlocal/vim.wo(andnvim_win_{get,set}_option/ scopednvim_{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_globalread and write,:set/vim.optwrite together with the local, and:setlocal/vim.bo/vim.woleave 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=3reach files opened later rather than only the buffer that was current whileinit.luaran. 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), themodifiablemarker, and the two nouns derived per buffer (filetype,ts_highlight) — and:setglobalon one fails loud (E5100) rather than storing a value nothing reads.options::has_global_tieris 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. Seedocs/plans/2026-08-01-global-local-options.md. - The indentation options (
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: anHSplitstacks children (dividing height, a─separator row between each), aVSplitplaces them side by side (dividing width, a│column between).sizesare normalized to cells on every layout, so resizing is plain cell arithmetic and a terminal resize rescales proportionally. Each leaf’s text height isrect.height - 1(its own status line). - Surface. Splits:
:split/:vsplit/:new/:vnewand<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/Lswaps 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_windowis 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, andnvim_open_win(both the split form and the float form). Window reads (plusnvim_set_current_win) are aliased into Lua and resolve against thebtv._winsmirror the server pushes before each Lua entry; the window mutation verbs are RPC-only — internally they queue aWindowOpdrained into the core (the same queue the privatebtv._open_win/btv._win_*bridges feed forbtv.viewand the UI primitives), but no public Luanvim_open_win/nvim_win_set_*binding exists. - Floating windows. A float is a
Windowthe layout tree does not own: it lives inWindowTree.floats(ids kept sorted by(zindex, id)), carries aFloatConfig(relativeeditor/win/cursor,anchor,row/col,width/height,zindex,focusable,border,title), and is positioned absolutely by a secondlayout()pass after the tiled rects are known — so it steals no space from its siblings and paints on top.nvim_open_winwith a non-emptyrelativeopens one (an RPC verb, viaWindowOp::OpenFloat; from Lua, floats come frombtv.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 offWindowId.nvim_win_set_config/get_configmove, resize, restyle, and convert a window between float and split. Unsupported config values (relative="mouse", an unknownborder) fail loud rather than silently falling back to a tiled split. Edge semantics (matching neovim)::qon 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>oclose every float too.<C-w>w/<C-w>Wcyclic focus includes focusable floats (in z-order, after the tiled windows) and skips non-focusable ones, thoughnvim_set_current_wincan focus either explicitly; the spatial<C-w>h/j/k/lmoves 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-clampingeditor-relative floats back on-screen. The lifecycle diff firesWinNew/WinEnter/WinClosedfor floats andWinResizedwhenset_configchanges 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 onEditor::windows(the rest park per Tab pages above), sosplit/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 aleft|main|rightmiddle 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 viamove_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 ininput) 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).relayoutcarves 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;Viewcarries the band sizes and tags each window with itsWindowRegion, and each client maps region → absolute origin (the core owns which cells, the client owns where). Surface: thebtv.dock.*Lua table (open{side,size?,buf?,title?,showtabline?,autohide?}/close/focus, plus the per-dock option scopebtv.dock.opt(side)) and the:DockOpen/:DockClose/:DockFocusex-commands, queued as aDockOpdrained into the core. Mouse:hit_testresolves a click across every region (the focused layer plus each parked dock tree, viaregion_geoms), so a left-click in any dock focuses it and places the cursor —set_current_windowcrosses to that layer first. A dock can also be hidden (toggle / auto-hide):dock_hidden[side]collapses it from view while keeping its wholeTabStackparked, so its splits/tabs/cursor/text all return when shown again — distinct from closed (which drops the trees).dock_is_openis the visibility predicate (= present and not hidden) that every layout / render / mouse / enumeration site reads, while the tree-resolution helpers readdock_tabsdirectly so a hidden dock’s content stays addressable.btv.dock.toggle/hide/show(and:DockToggle/:DockHide/:DockShow) drive it; the per-dockautohideoption hides a dock the moment focus leaves it (a hook inswitch_layer, the one chokepoint for every focus cross). A hidden dock isn’t invisible:View.hidden_dockscarries a label per collapsed dock, which each client paints as a clickable▸{label}chip on the idle command-line row (hidden_chip_atmaps a click back toshow_dock). The buffer list is per-layer: each buffer carries the window layer it was last shown in (OpenBuffer::layer, set byset_cur_buffer/set_window_buffer), so:lsand:bnext/:bprevlist 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), andbtv.buf.list{focused=true}exposes the focused-layer list to Lua (nvim_list_bufsstays 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/WinResizedfire from the same server-side lifecycle diff as the buffer events, orderedWinLeave → BufLeave/BufEnter → WinEnteraround 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 (aWindowOptionsper 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.
Editorholds anOption<PanelState>— just the panel’swindow, theprev_windowto refocus on close, and an edgemargin— and no content/cursor/scroll state (that all lives in the buffer and itsWindowView).open_panelmounts a buffer in a bottom split (reusingopen_bottom_window/remove_window, which displace the main window into the rows above) and hard-locks focus to it: a guard inEditor::focus_windowrefuses to move focus anywhere else, so<C-w>navigation,nvim_set_current_win, and mouse focus are all inert untilclose_paneldismisses 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 (
:messagesjust 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 aFileTypeautocmd, never hard-coded: the prelude’sFileType btvlisting/btvbuffers/btvpanels/btvmessages/btvpanelmaps bindq/<Esc>tobtv.panel.close, and a per-listing<CR>action (e.g.btv.buffers.actions.openreads the bufnr off the cursor row and switches) is an ordinarydefaultmap that a user map overrides — rebindable the standard way. - Built-in listings mount here.
:registers,:marks,:jumps,:changesgo throughEditor::open_scratch_listing(name, lines, cursor)(filetypebtvlisting);:ls/:buffersthroughEditor::open_buffer_listing(filetypebtvbuffers, whose<CR>switches buffer); the named-panel list throughbtvpanels;:messagesthroughbtvmessages, whoseCmap (btv.messages.actions.clear→:messages clear) empties the log and re-renders the panel in place. Each opens scrolled to a chosen cursor line —:messagesto the newest line,:lsto the current buffer. - A message history feeds it.
Editor::echois the one place a user-facing message is set; it records each line in amessageshistory (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 aMESSAGE_SEPARATORrow, 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, capturedprint/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 withbtv.panel.close()— queued as aPanelOpdrained by the server (the same “Lua queues, core mutates” flow asvim.cmd/nvim_set_hl).name(default[Panel]) makes the panel unique, so re-opening replaces its content;filetype(defaultbtvpanel, whose ftplugin mapsq/<Esc>to close) lets a plugin pass its own filetype and wire its own keys. The only RPC method is the read-onlybemtvi_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 ordinaryWindowView, and the redraw carries no specialpanelmap. - 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 asbtv_input_mousewith 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 inbemtvi-core, implemented bybemtvi-ts) and queries it duringredraw, 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 theBufferedit journal inbemtvi-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.filetypechooses the language,btv.bo.ts_highlightchooses whether the engine paints it. There is nostart/stopverb — setting the filetype and flippingts_highlightis how you start/stop highlighting.:set filetype/:setfwrite 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 aTsOp::SetQuerythe server pushes straight onto the engine, installing ahighlights/injections/indentsoverride (a replace,nilto drop). Underneath it, the server resolves each query the neovim way before handing it over: the language’s bundled base, its; inherits:chain (sojavascriptpicks upecma/jsx), and the runtimepathqueries/+after/queries/overlay, where a file carrying;; extendsis 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.mjsis the single!Sendthread that owns core + Lua. It loads the wasm module (dist/eh.mjs), constructs the realEditHostbehind a wasmHostEffects(WasmEffects,src/lib.rs), and runs the production tick. The UI thread (web/index.html) is the renderer + input layer, and the two talk overpostMessage/ a shared ring. - Interop is emscripten
ccall/cwrap, not wasm-bindgen.src/lib.rsexposes#[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 isemcc, not wasm-bindgen. - The renderer consumes the same
redrawthe native clients do.web/index.htmlpaints the serverredrawframe as HTML/CSS (a per-cell-span DOM renderer — windows/gutter/status/tabline/panel/pmenu, selection + cursor-shape classes, smooth scroll), the browser analogue ofbemtvi-tui’s layout, and translates a browserKeyboardEventto vim key-notation + mouse gestures toeh_input_mouse. It exposes awindow.__bemtvihook (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 aSharedArrayBufferinput 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) viaeh_set_clock/eh_next_deadline/eh_tick_timers— one mechanism. Without cross-origin isolation it falls back to apostMessage-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:won a bound path), or to a realbemtvi --daemonover WebTransport (a JS msgpack-RPC client,web/rpc.mjs, reached with?daemon=bemtvi://…, which connects only after the user approves thatHOST:PORT+ cert, and whose session keeps its own per-daemon shada blob — seeweb/daemon-trust.mjs). All three ride the same off-tickHostEffectsfs 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.luais 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.mjsbundles each plugin tree intopackage.preloadunder a per-plugin synthetic runtimepath root, sorequireand shada attribution behave as they do natively. LSP works over the daemon wire (the syncSyncLspClientreplaces 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 generatedweb/vendor/grammars, including injections — a markdown buffer reaches themarkdown_inlinegrammar and its fences’ languages), and:TSInstallfetches a prebuilt.wasmgrammar. The one exception to “generated”: no markdown.wasmis published anywhere, so its two parsers are compiled from the pinned npm sources and committed undertreesitter/prebuilt/. - Excluded from the workspace. It targets
wasm32-unknown-emscriptenand links C viaemcc, so it is in the root Cargo.toml’s[workspace] exclude(the hostcargo build/test/clippy --workspacenever touches it) and pins its own dependencies. Built viacrates/bemtvi-edithost/build.sh(cargo →emcclink →dist/eh.{mjs,wasm}, plus the tree-sitter highlighter assets generated once in the crate’streesitter/tooling dir and copied intoweb/vendor/). Deployed as static files (seenetlify.toml); the one hard requirement is cross-origin isolation (COOP/COEP) for theSharedArrayBuffer.
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 /
Viewintegration (crates/bemtvi-server/tests/editing.rs) start a real server, connect over real RPC, send vim key-notation viabtv_input, and assert on observable results: buffer contents (nvim_buf_get_lines), cursor, bytes written to disk, and the semanticredrawView. 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 knownViewinto a cell grid via ratatui’sTestBackend(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 realredraw, 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 actualbemtvibinary 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 semanticViewand 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.cchange 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-corebehind aSyntaxEnginetrait (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 custombtv.statusline.segment{}providers re-rendered only on declared events /invalidate, composed through the shared%-formatlayoutso 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 vocabularybtv.buf.set_extmarktakes and is validated + lowered by the same code, so a provider draws highlights, gutter signs, virtual text/lines and line backgrounds alike — whatpublishadds 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:Pluginsdashboard. -
Treesitter control.
:TSInstall/:TSUpdate/:TSInstallInfohave 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_CCis set), and the browser arm fetches a prebuilt.wasmgrammar instead. The:set-driven highlight toggle has landed too. (Residual:TSInstalledges — grammars needingtree-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 bridgebtv._btv_set_ts_query, and injections are engine-native. There is no Lua parser/AST platform (the vendoredvim.treesitterLua was deleted — ADR 0002); Lua-driven indent has landed too, through the bounded compute sandbox rather than a parser API:btv.indent.exprsits below the tree-sitter verdict and abovesmartindent, 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 thenvim_win_*RPC + Lua-read surface), floating windows (nvim_open_winwithrelative, the z-ordered overlay layer,nvim_win_set_config, and the:q/:only/focus/autocmd edge semantics), and tab pages (aVec<TabSlot>deriving the activeWindowTree, the tabline,gt/:tab*/<C-w>T, theTab*autocmds, thenvim_tabpage_*Lua surface, andshowtabline) 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, andwinhighlightare already wired). -
The
btv.*config surface —init.luatargets bemtvi’s own API (ADR 0002); the prelude’s current vim-shaped spelling is donor code, refactored underbtvwhere it serves bemtvi’s objectives and deleted where it doesn’t, with the muscle-memory aliases as the only lastingvim.*. What the runtime already does: the runtimepath,require,init.lua,nvim_set_hl,:colorscheme, andvim.keymap.set/vim.api.nvim_set_keymap(a per-mode withhold/replay matcher inbemtvi-server/src/keymap.rs; multi-key built-ins fire instantly even under a colliding user prefix, via the shared command grammarbemtvi_core::command_statusthe matcher consults) are in place — enough to load a full colorscheme end to end (see Lua). The LSP and diagnostics surface is native too: thebemtvi-lspcrate (client, protocol, manager, transport) does bemtvi’s own stdio spawning and drives the in-core editing features, andbtv.lspis 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 implementedrather 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, thebtv._notimplraises / runtimebtv._notimpl_hitsscoreboard 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 theOPTIONScatalog — andcanonical()over it — incrates/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.waitand the coroutine pump that hosted them — are not part of thebtvmodel: nothing in it blocks the editor, so the only prompt surface is the promise-basedbtv.ui.input/btv.ui.select, with callback-shapedvim.ui.*aliases.) Legacy Vimscript (eval.c) is not on the roadmap — see guiding principle 2. -
A broad options surface.
:setexists and honors the search booleans, the window-local number-gutter optionsnumber/relativenumber, the cursor-line highlightcursorline, and soft word-wrapwrap(linebreak/breakindent/showbreak) (also via:setlocal/vim.wo/nvim_win_{get,set}_option) and the window-local horizontal-scroll optionssidescroll/sidescrolloff(via:set), and the buffer-local indentation optionstabstop/shiftwidth/softtabstop/expandtabandcommentstring— the comment template thegc/gccoperator reads, set per buffer or defaulted from the filetype for the ~20 most common languages (all also via:setlocal/vim.bo); scopednvim_{set,get}_option_valueroutes 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-locala–z, global file marksA–Z, the automatic special marks,:marks, and'{mark}ex-ranges) are both done, as are keyboard macros — on<F2>/<F3>rather than vim’sq/@(seedocs/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 viavim.keymap.set/nvim_set_keymap). Code folding is done across all four sources — manual (zf/thezfamily),foldmethod=indent,foldmethod=expr(the native tree-sitter foldexpr and a generic per-line Lua'foldexpr'), and LSPtextDocument/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:ssubstitution, which shares search’s canonical-regex engine, are both done; see the search design anddocs/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-localbtv._keymaps):btv._resolve_user_commandgives 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_commandsreturns a buffer’s locals, and a wiped buffer’s locals (commands and keymaps) are purged viabtv._cleanup_bufferso a reused bufnr can’t inherit them. -
An async Lua runtime (event loop). Landed (see the async-runtime plan). A
Sendbackground actor (crates/bemtvi-server/src/evloop.rs, modeled onLspManager) owns timers and child processes; on completion it sends a typedLoopEventback to the single server thread, which runs the matching Lua callback by id (thebtv._cb_fnsregistry, the keymap-callback shape applied to async work).vim.scheduledefers to convergence,vim.defer_fnfires on wall-clock time, and process completions settle their promises off-tick (there is novim.system— spawning is the promise-basedbtv.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 synchronousfs_*/ scalars — is not part of thebtvmodel and is absent entirely. Thebtvasync 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.treesitterLua 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 nounsbtv.bo.filetype/btv.bo.ts_highlight(the onevim.treesittersurvivor is thefoldexprmarker), and query customization rides the runtimepathqueries//after/queries/overlay (next bullet). Lua-driven indent is wired, through the bounded compute sandbox rather than the main VM:btv.indent.expris a Lua expression handed the line, the previous non-blank line and the effective'shiftwidth', sitting below the tree-sitter verdict and abovesmartindent(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:TSInstallfetches the inherited query sets too (javascript→ecma,jsx), so base js/ts highlighting carries theecmapatterns.;; extendscarries 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 anafter/queriescustomization 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, wherejavascriptinside 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 — thevim.uv/vim.looptable 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 synchronousfs_*/ scalar primitives (fs_realpath,cwd,os_homedir,os_uname,hrtime,now) are gone. There are no synchronous filesystemvim.fnbuiltins either (no blocking I/O ever runs on the editor thread): async lives in thebtvAPI (btv.run/btv.timer/btv.fs), and the LSP-config root search runs on the same asyncbtv.fsseam. -
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 pairshowcmd/reportand the undo boundundolevels; 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.optwrite both, and a new buffer is born from the global value — which is what makes a config’svim.opt.tabstop = 3reach files opened later. A few buffer options have no global value (the read decides them) and:setglobalon one fails loud withE5100. One deliberate departure inside that model:commentstring,foldexprandfoldmarkerlive 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:setglobalof 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:setlocalstill pins a buffer against any later global write. bemtvi breaks with vim’s defaults on indentation:tabstopdefaults to 4, withshiftwidth=0(“follow tabstop”) andsofttabstop=-1(“follow shiftwidth”) so the onetabstopknob drives the whole indent width.tabstop,softtabstop, andexpandtabdrive rendering,<Tab>and the<BS>-over-blanks unit delete (bemtvi always has an effective soft-tab unit, so it has nosmarttaband applies that unit wherever vim’ssofttabstopwould);shiftwidthdrives the>>/<<shift operators and the LSP indent width.commentstringbacks thegc/gcccomment 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 spelledshowwhitespace) draws every key butconceal:tab,space,multispace,lead,leadmultispace,trail,nbsp,eol, and undernowraptheextends/precedesedge markers.'listchars'is also spelledwhitespacechars. Where bemtvi differs:- The default is
tab:→ ,space:·,trail:•,nbsp:◦,eol:¬, not neovim’stab:> ,trail:-,nbsp:+. neovim’s draws nothing for an ordinary space or a line end, so:set listchanged 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
liston and notab:entry, vim draws a tab as the 2-cell^I; bemtvi keeps it a plain tab. Drawing^Iwould 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. concealis accepted but not drawn: bemtvi renders no syntax conceal.
:set listchars+=…(and+=/-=/^=for any option) is not supported and fails withE518;vim.opt.listchars:append(…)works. (Plan:docs/plans/2026-10-27-list-and-listchars.md.) - The default is
-
Keyboard macros are on
<F2>/<F3>, notq/@, 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:qstays free for the user and for theq-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}(orbtv.bo.filetype) — from a config, that means aBufReadPost/BufNewFileautocmd, since there is novim.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 — unimplementedvim.fn.*entries are loud gaps, not a TODO to build an evaluator. -
:TSInstallapproximations. The command fetches/compiles grammars (bemtvi_ts::install), with a pinned, checksum-verified Zig fetched on demand (never a compiler from PATH;$BEMTVI_CCoverrides) — on macOS, Linux, and Windows alike. Remaining: (1) grammars needingtree-sitter generate(no committedsrc/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 markdownreports 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.contentrange 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); norange(onlyfull/full/delta); nohighlight_tokenhook (neovim’s per-token Lua callback on the decode hot path — the name is absent, a loud nil index);get_at_posreads the cached mirror even for astopped buffer; no per-client granularity (one cache per buffer); repaints mid-insert (update_in_insertalways on). Seedocs/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) andvim.lsp.inlay_hint.get(with a line-range filter) have landed. What still diverges: oneLspInlayHintgroup for all kinds (no Type/Parameter split); the fetch is whole-document — the viewport-scopedrangerequest is deferred; per-buffer enable only (no per-client granularity); horizontal-scroll (leftcol>0) + inline hints is best-effort; repaints mid-insert. Seedocs/plans/2026-06-08-lsp-inlay-hints.md. -
No synchronous prompts. The blocking
vim.fn.input/vim.fn.confirmare omitted by design (loud gaps like the rest of unimplementedvim.fn.*) — prompting is promise-only:btv.ui.input/btv.ui.select/btv.ui.confirm, awaited insidebtv.async. A config expecting an inline return must be reshaped onto the promise. Seeexamples/ui-prompt/. -
btv.statuslinesegment registry — v1 deferrals. The lualine-shaped surface is built (btv.statusline.setup/segment/invalidate; built-ins composed inbemtvi_core::statusline::compose_segments, custom segments rendered per window and cached server-side; seedocs/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) — soctx.focusedandctx.bufare correct in every window. The server orchestrates the re-render fromrun_pendingwith 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), andbtv.statusline.reset(win)drops the override (EditHost::resolve_window_layout). Mouse-click segment regions have landed: a segment can carry anon_clickhandler, lowered to the%@func@…%Xstatusline syntax (with%nTtabline labels andlaststatus=3), so clicks dispatch back to Lua. What it does not do yet: (1) The custom segmentctxcarries{ buf, win, focused }but nowidth(the server doesn’t mirror the per-window statusline width to Lua). (2)git/lsp_progressare plugin segments (custom-segment examples), not built-ins. The built-in set ismode/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:
| Limitation | What it affects |
|---|---|
| The completion menu is server-owned chrome, not a window | The 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 options | vim.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.