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