Introduction
nxvim 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 nxvim’s own. Configuration and plugins target the nx.* Lua
namespace, where the server owns every UI surface and plugins provide data and
behavior. There are a few vim.* aliases over the native nx.* API for convenience.
How to read this book
- Getting started — install or build nxvim and run it.
- Configuration — point nxvim at your
init.luaand set options throughnx.*. - Plugin Development — the anatomy of an nxvim plugin and a worked example.
- nx.* 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/nxvim-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 (nxvim) and the native GUI
(nxvim-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):
nxvim file.txt # terminal editor
nxvim-gui file.txt # native GUI (winit + wgpu)
Downloads ship with checksums and SLSA build provenance — see Verifying downloads.
Truecolor terminal recommended. nxvim 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/nxvim file.txt
# …or straight from cargo
cargo run -p nxvim -- file.txt
# the native GUI
cargo run -p nxvim-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://nxvim-demo.netlify.app: use :eo
to open a local file, and :setf <lang> / :TSInstall to activate treesitter
highlighting for a language.
Configuration
nxvim reads a Lua config. On startup it resolves a config directory — the
first of $NXVIM_CONFIG, $XDG_CONFIG_HOME/nxvim, or ~/.config/nxvim — and
sources <config>/init.lua before the first frame. The runtimepath is that
dir plus every pack/*/start/* entry under it:
~/.config/nxvim/
├── init.lua # sourced at startup
└── pack/
└── plugins/
└── start/
└── myplugin/ # a plugin; its lua/ and colors/ are found here
-- ~/.config/nxvim/init.lua
require("myplugin").setup()
nx.* vs vim.*
The editor’s own config API is the nx.* 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
nx.* equivalent, so config can be written in familiar spellings:
vim.g.mapleader = " "
vim.o.number = true
nx.keymap.set("n", "<leader>w", "<cmd>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.
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 nxvim. You declare a set of plugins in init.lua with
nx.plugins{}; it clones/updates them over the async runtime (driving real
git) 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/nxvim/init.lua
nx.plugins({
-- "owner/repo" shorthand expands to a GitHub clone.
{ "davidrios/nxvim-keys-helper",
config = function() require("nxvim-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: I install ·
U update · S sync · X clean. The same operations are available as
ex-commands:
| Command | Action |
|---|---|
:PluginSync | Install missing and update existing declared plugins |
:PluginInstall | Clone any declared plugin not yet on disk |
:PluginUpdate | Fast-forward every installed, unpinned plugin |
:PluginClean | Remove cloned dirs no spec declares |
:PluginList | Print a one-line status (installed / loaded / missing) per plugin |
:PluginsWelcome | Reopen the first-run welcome checklist of recommended plugins |
nxvim ships minimal; on a fresh setup the welcome checklist offers a recommended first-party set pre-ticked. See Writing plugins for authoring your own.
Runnable examples
The examples/
directory has ~70 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 nxvim at:
NXVIM_CONFIG=examples/treesitter cargo run -p nxvim -- examples/treesitter/sample.rs
Beyond vim — what nxvim adds
nxvim 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 |
|---|---|
| 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. |
| 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. |
| 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 (nx.component), plugin-owned content surfaces (nx.view), ready-made async widgets (nx.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 | nx.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 nx.qf.* sinks and the picker’s <C-q> / <Tab> multi-select. Location lists keep vim’s split behavior. |
Platform
| Feature | What it is |
|---|---|
Native nx.* plugin API | nxvim’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 (nx.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 nxvim --daemon serves the filesystem, processes, and watching over ssh or QUIC. |
| Lua plugin testing | nx.test (describe/it/expect + async) plus a nxvim --test-plugin runner, so pure-Lua plugins test themselves. |
Multi-cursor mode
A Helix/kakoune/Sublime-style multi-cursor, built on top of nxvim’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.
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.
Single-line motions at the window edge, 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. |
nx.o.scrollanim = true
nx.o.scrollanimduration = 160 -- ms ceiling; raise for a slower, more visible slide
'scrollanim' is also a window-local option (nx.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.
Image previews
Open an image file and nxvim 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' (nxvim-native, off by default) turns it on:
nx.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
(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
nxvim 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) | nx.view.component / nx.component | A Vue-shaped reactive component: state + a pure render. |
| Plugin-owned content you update by hand (a list, a report) | nx.view | An inert buffer you set lines on and mount in a dock / split / float. |
| To ask the user something (text, choice, yes/no) | nx.ui.input / select / confirm | Async prompt widgets — return a promise. |
| To pop up read-only content (a hint, a tooltip) | nx.ui.float | A bordered content overlay, dismissed by the next key. |
| A real, editable window placed over the layout | nvim_open_win (float form) | The low-level escape hatch — a true floating window. |
Components — reactive UIs
nx.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 nx.await(...) straight inside them (each runs in its
own nx.async coroutine), so a setup that loads from disk, or a render that
fetches to display, reads top-to-bottom.
local Counter = nx.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 whatnx.view.componentselects) — a focus-taking, navigablenx.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 withnx.component{ surface = "float", … }.
-- A self-dismissing toast on the non-focus float surface.
local Toast = nx.component({
surface = "float",
setup = function(ctx)
local s = ctx.reactive({ text = ctx.props.text })
nx.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 nx.notify rather than crashing the
editor.
Views — the content surface
nx.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 = nx.view.create({ name = "files", filetype = "nxfiles" })
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) nx.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 nx.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 nx.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 nx.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 = nx.view.create({ name = "Files", filetype = "nxfiles", persist = "main" })
v:set_lines(render(state))
v:on_select(...)
-- The plugin owns the content; persist just enough to rebuild it.
nx.shada.plugin():set("view:main", state)
-- Clean up the stored state if the user closes the view for good.
v:on_close(function() nx.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.
nx.view.on_restore(function(id, place)
local state = nx.shada.plugin():get("view:" .. id)
local v = nx.view.create({ name = "Files", filetype = "nxfiles", 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
nx.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. 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
nx.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 nx.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 = nx.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 = "nxfiles", persist = "main", dock = "left", size = 30 })
ctx.store is this component’s nx.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 nx.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 nx.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>.
nx.ui.input({ prompt = "Rename to: ", default = vim.fn.expand("%:t") })
:next(function(name) if name and name ~= "" then nx.notify(name) end end)
-- select: a floating chooser. Resolves to the chosen item, or nil on cancel.
nx.ui.select({ "apple", "banana", "cherry" }, { prompt = "Pick:" })
:next(function(item) if item then nx.notify("picked " .. item) end end)
-- confirm: a yes/no dialog. Resolves to a boolean.
nx.ui.confirm("Quit without saving?", { default = false })
:next(function(ok) if ok then nx.cmd("qa!") end end)
-
nx.ui.input(opts)—promptlabel +defaultprefill, over the command line.""on an empty<CR>,nilon<Esc>. -
nx.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):nx.keymap.set("select", "<C-j>", nx.ui.select_actions.next) -- actions: next, nx.keymap.set("select", "q", function() end) -- prev, first, last, ``` -- confirm, cancel -
nx.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. -
nx.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 }(likenx.run, it resolves rather than rejects — a missing opener iscode = -1).
Popup content
nx.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.
nx.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 (nx.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, nx.ui.select, 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 — nx.lsp.hover / signature_help open it internally.
Placing things
Every windowed surface — floating windows, nx.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", "cursor" (anchored at the cursor, flipping for room), or "win". |
border | "none" / "single" / "rounded" / "double" / "solid" (plus "shadow" for nx.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 —
nvim_open_win’s float form is the low-level primitive. It returns a window id
synchronously (the op is queued and applied after the current Lua chunk), so you can
configure it right away. (Most plugins won’t reach here — nx.view:mount{ float }
is the same window with less boilerplate.)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "an editable float" })
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor", width = 40, height = 3, align = "center",
border = "rounded", title = " scratch ",
})
vim.api.nvim_win_set_config(win, { height = 6 }) -- move / resize live
vim.api.nvim_win_close(win, true) -- dismiss
nvim_win_get_config(win) reads a window’s float config back ({ relative = "" }
for a tiled window), and nx.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 |
|---|---|
nxchecklist | A modal checkbox dialog written with nx.view.component (reactive state + pure render). |
nxview | A dockable nx.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 | nx.ui.input and nx.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. |
NXVIM_CONFIG=examples/nxchecklist cargo run -p nxvim
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 nx.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 nx.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).
nx.dock.open({ side = "left", size = 28 })
nx.dock.open({ side = "bottom", size = 6 })
nx.dock.focus("left") -- move focus to a dock
nx.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(andnx.dock.close) drops the dock and its content.:DockToggle/:DockHide/:DockShow(andnx.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()
nx.dock.toggle("left")
end, { desc = "toggle the left explorer dock" })
Per-dock options
Docks have their own option scope, alongside nx.bo / nx.wo / nx.o. Set
options inline in nx.dock.open{...} or after the fact through
nx.dock.opt(side); reads return the cached value (or its default).
nx.dock.opt("left").title = "EXPLORER" -- a fixed strip label
nx.dock.opt("left").showtabline = 2 -- always show the dock's own tabline
nx.dock.opt("bottom").autohide = true -- collapse when focus leaves
nx.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:
NXVIM_CONFIG=examples/dock cargo run -p nxvim -- 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.
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
nx.picker is nxvim’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 three sources — files, live_grep, buffers — and registering
your own is a few lines.
Using a picker
The three built-in sources are bound out of the box, plus a resume map:
| Map | Source |
|---|---|
<leader>ff | files — fuzzy file finder |
<leader>fg | live_grep — live grep |
<leader>fb | buffers — open buffers (scoped to the focused layer, like :ls) |
<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 nx.picker.open:
nx.keymap.set("n", "<leader>o", function() nx.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
nx.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> — nxvim’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
nxvim 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 nx.qf.list / show API
the action builds on.
Writing a source
nx.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:
nx.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) nx.notify("picked " .. item.text) end,
})
A source can be asynchronous — wrap items in nx.async and stream from a
subprocess. nxvim 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 how the built-in files source works:
nx.picker.source({
name = "files",
preview = "file",
items = nx.async(function(ctx)
local stream = nx.run_stream({ cmd = "rg", args = { "--files" }, cwd = ctx.cwd })
ctx.on_cancel(function() stream:kill() end)
for batch in nx.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) nx.picker.edit(item) end,
})
nx.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) nx.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'. nxvim 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 — nx.o.switchbuf = "useopen" (reuse a window in the
current tab only) or nx.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 how
live grep works (re-spawning rg per query):
nx.picker.source({
name = "live_grep",
dynamic = true,
preview = "location",
items = nx.async(function(ctx)
if ctx.query == "" then return end
local stream = nx.run_stream({
cmd = "rg", args = { "--vimgrep", "--", ctx.query }, cwd = ctx.cwd,
})
ctx.on_cancel(function() stream:kill() end)
for batch in nx.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) nx.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 delay defaults to
nx.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
nx.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). |
debounce | Milliseconds before a dynamic source re-runs; 0 off. |
-- a snappier live grep, just for this map:
nx.keymap.set("n", "<leader>fG", function()
nx.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:
nx.keymap.set("picker", "<C-j>", nx.picker.actions.next)
nx.keymap.set("picker", "<C-k>", nx.picker.actions.prev)
nx.keymap.set("picker", "<Tab>", nx.picker.actions.confirm)
-- disable a default binding by mapping it to an empty function:
nx.keymap.set("picker", "<C-n>", function() end)
The actions are next, prev, confirm, 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. 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 the query.
Try it
A runnable playground ships in examples/ui-picker:
NXVIM_CONFIG=examples/ui-picker cargo run -p nxvim -- 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.
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 nx.ui.select), so an item’s arbitrary
fields never need to serialize. Candidates are batched (~1000 per bridge call)
rather than crossing one at a time, which is what makes streaming 100k results
fast. 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
nxvim 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
nx.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 nxvim 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. |
nx.o.qfdock = false -- prefer the classic split behavior
-- or, transiently: :set noqfdock / :set qfdock
The option governs :copen, named lists, and the quickfix nx.qf.*_qflist
actions. Location lists (:lopen, nx.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 nx.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 |
|---|---|
nx.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). |
nx.qf.add_to_loclist(list, opts) | Append to the current window’s location list. |
nx.qf.send_to_qflist(list, opts) | Replace the global quickfix list and show it (one reused dock tab under 'qfdock', else a split). |
nx.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
(nx.qf.list / show, below) — that, not the loclist, is the dock-tab surface.
(Bare nx.send_to_loclist etc. aliases exist too.) Example — send the current
buffer’s TODO lines to a saved location list:
nx.keymap.set("n", "<leader>lt", function()
local items = {}
for i, line in ipairs(nx.api.nvim_buf_get_lines(0, 0, -1, false)) do
if line:match("TODO") then
items[#items + 1] = { filename = nx.buf.name(), lnum = i, text = line }
end
end
nx.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 nx.qf.list(name, items) whenever your data changes — no
datasource/refresh indirection — then nx.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 = nx.buf.name(), lnum = i, col = 1, text = line }
end
end
return items
end
nx.keymap.set("n", "<leader>tl", function()
nx.qf.list("todos", todo_items(), { title = "TODO / FIXME" })
nx.qf.show("todos") -- repaints the tab if already open
end)
| Call | Purpose |
|---|---|
nx.qf.list(name, items[, opts]) -> name | create / replace the list name; repaints its tab if open |
nx.qf.show(name) -> name | open or focus the list’s dock tab |
nx.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). nx.qf.list only writes the list — it never opens or focuses the tab, so
call nx.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:
NXVIM_CONFIG=examples/picker-to-named-list \
cargo run -p nxvim -- 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):
NXVIM_CONFIG=examples/named-lists \
cargo run -p nxvim -- 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 nx.qf.list + nx.qf.show.
Workspaces
A workspace is the VSCode “open a folder” model brought to a modal editor:
open a directory as a workspace — nxvim --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 nxvim <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:
nxvim --workspace ~/code/myproject # open a directory as a workspace
nxvim --workspace ~/code/myproject README.md # …and open a file in it
nxvim --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 client-side :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. (Like :connect, this
is a GUI-client command — the server knows nothing of it.)
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:
nxvim --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 — nx.wso
nx.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.
nx.wso.tabstop = 2 -- this project indents with 2
nx.wso.ignorecase = true -- case-insensitive search here
nx.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 nx.wso overlay when present, otherwise the
global value (nx.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
nxvim runs the real editor — entirely in a browser tab, with no server. Not a
cut-down demo or a syntax-highlighted textarea: nxvim-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;
:TSInstall <lang>fetches a prebuilt grammar on demand and caches it.
What’s different from native
| In the browser | |
|---|---|
| Plugins | init.lua is one self-contained file — a require of further modules / plugins doesn’t resolve (the runtimepath is empty and storage reads are async). |
| 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: blocking vim.fn.system always fails loud, and async vim.system / jobstart fail loud too — both need daemon mode (see Files). |
| Hosting | Requires cross-origin isolation (COOP/COEP) for the SharedArrayBuffer. Without it, input still works but timers (nx.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 nxvim 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=nxvim://HOST:PORT/TOKEN?cert=HASH(the stringnxvim --daemon --listenprints), or dial it at runtime with:connect nxvim://…, 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) and keeps shada on the daemon, where a native client would default to local. Daemon mode also brings async processes (vim.system/jobstart),: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.nx.daemon.status()reports the link state.
Run it locally
cd crates/nxvim-edithost
./build.sh # cargo → emcc 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 — sonx.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.__nxvimhook (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
nxvim --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 nxvim --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 nxvim --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──▶ nxvim --daemon = fs + processes + watch (REMOTE)
- The local half is the same embedded editor as a normal
nxvim, 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
nxvim --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:
NXVIM_DAEMON_CMD="ssh user@host nxvim --daemon" nxvim --connect-daemon path/to/file
NXVIM_DAEMON_CMD is run through sh -c, so any command line ending in
nxvim --daemon over stdio works. Left unset, --connect-daemon spawns this same
binary locally (nxvim --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 … nxvim --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:
nxvim --daemon --listen # binds 127.0.0.1:8765, prints a connect URI
nxvim --daemon --listen 0.0.0.0:9000 # accept off-host connections
It prints a connect URI — nxvim://HOST:PORT/TOKEN?cert=HASH. Dial it from a local
editor (the nxvim://… scheme selects the QUIC path; --connect-daemon is optional):
nxvim nxvim://HOST:PORT/TOKEN?cert=HASH path/to/file
In the GUI, :connect nxvim://… 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 — vim.system / jobstart / :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? nxvim splits it on
purpose. Project-facing fs APIs route to the daemon — vim.fn.glob /
filereadable / readblob / executable, 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.)
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:
nx.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/nx.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.
Plugin Development
A plugin is pure Lua over the nx.* API — a lua/<name>/init.lua module
that exposes setup(opts) and wires keymaps, commands, autocmds, and UI through
nx.*. 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:
nx.plugins({
{ "davidrios/nxvim-keys-helper",
config = function() require("nxvim-keys-helper").setup({}) end },
})
Where to go next
- Writing plugins — the full authoring guide: anatomy,
loading, the
nx.*surfaces you’ll use, a worked example, and testing. - nx.* API Reference — every public function in the
nx.*namespace, generated from the prelude source.
First-party plugins
The surest way to learn the nx.* API is to read code that already ships on it.
nxvim is its own plugin API’s first and most demanding consumer: its first-party
plugins use the same public nx.* 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/davidrios and are each installable
with the built-in manager:
nx.plugins({
{ "davidrios/nxvim-lspconfig",
config = function() require("nxvim-lspconfig").setup({}) end },
})
The catalogue
Each is paired below with the neovim plugin it echoes and the nx.* surfaces
worth studying it for.
| Plugin | What it is | Read it to learn |
|---|---|---|
| nxvim-lspconfig | Ready-made language-server configs (port of nvim-lspconfig) | The smallest, most data-driven plugin: curated config tables driven onto nx.lsp.config / nx.lsp.enable, inlay hints, and the LSP buffer verbs — no neovim compat layer. Start here. |
| nxvim-tree | Dockable file explorer — the official tree (sibling of nvim-tree) | A read-only nx.view surface, docking via nx.layer.main / nx.open, async filesystem walks with a per-directory watch (nx.fs + nx.async / nx.await), and glyphs / guides / git signs painted as extmarks. |
| nxvim-line | Lualine-style statusline (sections a–z, themes, powerline separators) | Composing a rich statusline from nx.statusline components, recolour-by-mode themes (nx.mode + nx.hl.define), git/diff data shelled out via nx.run, buffer options through nx.bo, and refresh on a nx.timer. |
| nxvim-keys-helper | Live popup of the keys that can follow what you’ve typed (a which-key) | Subscribing to the pending-key oracle (nx.on_key_pending) instead of intercepting input, rendering a popup with nx.component, debounce (nx.utils.debounce), and width-aware layout (nx.str.displaywidth). No blocking key reads. |
| nxvim-dap | Debug Adapter Protocol client (sibling of nvim-dap) | The richest example: a Content-Length-framed JSON protocol over the duplex nx.process primitive, breakpoint / stopped signs as extmarks, a scopes-and-stack sidebar plus REPL on read-only nx.view docks, and cross-tick scheduling (nx.on_next_tick). |
| nxvim-diff | Meld-style side-by-side diff viewer | Read-only nx.view panes locked in lockstep through WinScrolled + nx.win.set_topline / set_leftcol / set_cursor, with every line tint and intra-line span an extmark. A renderer you feed a diff to. |
| nxvim-help | Vim-style :help | A navigable read-only nx.view split, a tag index merged across the runtimepath (nx.runtime_file), files read with the promise nx.fs API, and fuzzy topic search through the picker. |
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 handful of plugins ship inside nxvim and load by default — also pure nx.*,
and browsable in this repository under
crates/nxvim-lua/src/prelude/:
editorconfig.lua—.editorconfigsupport: an asyncnx.fsdirectory walk, its own glob matcher, and option application driven by autocmds.plugins.lua+plugins_ui.lua— thenx.pluginspackage manager and its dashboard: declarative specs, asyncgitovernx.process, lazy-loading on command/event/filetype/keys, and a full floating UI.
See also
- Writing plugins — the authoring guide these implement.
- Testing plugins — how their test suites are structured.
- The nx.* model — the five rules they all obey; the nx.* API Reference chapter lists every surface named above.
The nx.* model
nxvim has its own, purpose-built plugin system. Every editor API lives in the
nx.* namespace (config and plugins alike), with a small set of familiar vim.*
names aliased to nx.* for convenience. This page explains
the model that shapes the whole API; Writing plugins is the
hands-on how-to, and the nx.* 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,” nxvim 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.
nxvim’s architecture is different (see Architecture), so its
plugin model is too. nxvim-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.
nx.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. An
async writer guards with a changedtick (
nx.buf.edit{ tick = t, … }) and fails loud if the buffer moved under it. - Nothing blocks, ever. No wait-pumps, no blocking reads, no uv handles.
Anything that waits returns a promise you
nx.awaitinsidenx.async, or — for streaming — an async-iterator (nx.run_stream+nx.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
nx.* 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 nxvim |
|---|---|
| Completion menu (the nvim-cmp shape) | nx.complete engine; plugins are sources |
| Statusline (the lualine shape) | nx.statusline; plugins register segments |
| Fuzzy finder (the telescope shape) | nx.picker engine; plugins are sources |
| Snippets (the LuaSnip shape) | nx.snippet engine (LSP grammar, tabstops) |
| File tree / sidebar / dashboard — a bespoke plugin UI in neovim | First-class content surfaces: nx.view in an nx.dock, nx.component for reactive ones |
| Decoration provider | nx.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. nxvim ships those
as first-class APIs: nx.view (dockable content
surfaces), floating windows, the nx.ui widgets, and the reactive nx.component —
so a plugin describes a UI instead of drawing one.
vim.* aliases
The editor API is nx.*. For convenience, a small set of familiar vim.* names are
provided as thin aliases over their nx.* 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 nx 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(→nx.on),nvim_create_user_command(→nx.command), andnvim_set_hl(→nx.hl.define), plusvim.filetype.add. - Callback-shaped async —
vim.notify,vim.schedule,vim.defer_fn,vim.ui.input/select, andvim.systemin its callback form. - Treesitter highlight toggle —
vim.treesitter.start/stop, mapping to thenx.bo.filetype/nx.bo.ts_highlightbuffer nouns.
The list is intentionally small — these convenience spellings, and nothing more;
everything else (LSP, treesitter, processes, the filesystem, …) is nx.*.
Colorschemes are data, not plugins
A colorscheme is pure data — a table of highlight-group definitions, registered
through the nx 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 nx.* API
The split: the core provides primitives — the engines and UI surfaces, in Rust:
completion, the picker (a float-list widget), statusline, snippets, nx.view /
nx.dock / floats, nx.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.
nxvim is the plugin API’s first and most demanding consumer — its first-party plugins
use the same public nx.* API a third party would, with no privileged access. That
keeps the API honest: a feature that can’t be expressed against nx.* is a gap to
close in it.
See also
- Writing plugins — the hands-on authoring guide.
- Async & promises — rule 3 in practice (
nx.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 nxvim plugins
A plugin is pure Lua over the nx.* 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 nx.*.
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 nx.*, not vim.*. (A
closed whitelist of vim.* muscle-memory aliases exists for config ergonomics; new
plugin code should target nx.* directly.)
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 {}
nx.keymap.set("n", "<leader>x", function()
nx.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 (nx.plugins). Declare plugins in init.lua; the manager
clones, runtimepaths, and loads them (with optional lazy triggers):
nx.plugins({
-- eager:
{ "davidrios/nxvim-keys-helper",
config = function() require("nxvim-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 (clone missing + update), :PluginInstall, :PluginUpdate,
:PluginClean, :PluginList, or the :Plugins dashboard. Cloned plugins live under
stdpath("data")/plugins/<name>. 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.
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 nx.* surfaces you’ll use
A plugin composes these (each has runnable examples under examples/
and a deeper treatment in the API design):
- Keymaps —
nx.keymap.set(mode, lhs, rhs, opts)/nx.keymap.del; introspect withnx.keymap.get. Always pass adesc— it surfaces in completion and which-key. - User commands —
nx.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 —
nx.on(event, { pattern = … }, fn)for editor lifecycle events (BufReadPost,FileType, …). - Options & vars — read/write
nx.o(global),nx.bo(buffer),nx.wo(window), andnx.g(globals). Edge docks have their own scope too:nx.dock.opt(side)(e.g.nx.dock.opt("left").size = 32), alongsidenx.bo/nx.wo— per-dockshowtabline,laststatus,size,title,winhighlight, andautohide. - Highlights —
nx.hl.define(ns, name, spec),nx.hl.get,nx.hl.exists. Define your groups as fallbacks (nx.hl.existsguard) so a colorscheme that already styles them wins. - Messages —
nx.notify(msg, level). - Async — the editor is single-threaded and tick-based, so anything that waits
is promise-based:
nx.async/nx.await,nx.promise,nx.run/nx.run_stream(subprocesses),nx.fs(filesystem),nx.timer,nx.utils.debounce, and the scheduling primitivesnx.schedule(end of the current tick) /nx.on_next_tick/nx.wait_for(pred, opts)(across ticks). Reach foron_next_tick/wait_for— never anx.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
nx.ui.input/select/confirm/float(promise-based, never steals focus forfloat), andnx.component(reactive state + a pure render + lifecycle) for live popups. Bigger server-owned surfaces:nx.picker(fuzzy finder),nx.complete(completion sources),nx.snippet,nx.statusline(composable segments),nx.decor(viewport decorations / extmarks),nx.dock(edge docks), andnx.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 nx.*
for everyone, so let us know if you find anything missing.
A worked example
nxvim-keys-helper — the
first-party which-key — is a compact, real-world plugin: it subscribes to the
pending-key oracle (nx.on_key_pending), debounces with nx.utils.debounce, and
renders the continuations on a non-focus nx.component{ surface = "float" }. It is
packaged exactly as above (lua/nxvim-keys-helper/init.lua with setup/add) and
carries its own test suite.
The examples/ directory has ~40 self-contained configs — one per
feature — that double as plugin-authoring references.
Testing
Plugins are pure Lua, so their tests are too. nxvim ships a native test framework
(nx.test) and a headless runner — write test/*_spec.lua, then:
nxvim --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
- Native plugin API design — the model and six worked API examples.
- ADR 0002 — native plugin system — why
nx.*, and the exactvim.*alias whitelist. - Testing nxvim plugins —
nx.test+nxvim --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 nxvim has no blocking I/O at all. Instead it gives
plugins a browser-shaped async runtime: Promises/A+ (nx.promise) plus
async/await sugar (nx.async / nx.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):
nxasync 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; newnxcode uses promises.)
So nx.run, nx.fs.read, nx.ui.input, nx.lsp.hover — anything that waits —
hands you a promise you :next() / await.
Promises
A promise is a value that arrives later. nx.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 nx.promise.new(function(resolve)
nx.timer(function() resolve("done") end, ms) -- settle later, off the tick
end)
end
after(500)
:next(function(v) nx.notify(v) end) -- "done", ~500ms later
:catch(function(e) nx.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: nx.promise.resolve(v) (already fulfilled), nx.promise.reject(e)
(already rejected), and nx.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 vianx.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
nxvim: 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. nx.async + nx.await flatten them
into straight-line code — the same trade the JS async/await keywords make.
nx.async(fn) returns a function that, when called, runs fn as a coroutine
and returns a promise for its result. Inside, nx.await(p) suspends until p
settles and evaluates to its value (or re-raises its rejection as a Lua error):
local load_config = nx.async(function(path)
local exists = nx.await(nx.fs.exists(path))
if not exists then return {} end
local text = nx.await(nx.fs.read_text(path))
return parse(text) -- becomes the promise's fulfilment
end)
load_config("init.lua")
:next(use_config)
:catch(function(e) nx.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.
nx.awaitmust be called inside annx.asyncfunction (there’s nothing to suspend otherwise — it errors loudly). It’s also whatsetup/renderrun inside on annx.component, so you cannx.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 |
|---|---|---|
nx.run{ cmd, args, cwd, env, stdin } | promise of { code, stdout, stderr } | Run a subprocess to completion. |
nx.run_stream{ … } + nx.await_each | a Stream (async-iterator) | Stream a subprocess’s stdout as it arrives. |
nx.fs.read / read_text / stat / readdir / exists / … | promise of the result | Filesystem, never blocking. |
nx.ui.input / select / confirm / open | promise of the user’s answer | Prompts and choosers. |
nx.lsp.hover / references / … | promise | Language-server requests. |
nx.promise.delay(ms[, v]) | promise that fulfils after ms | An await-able sleep. |
nx.wait_for(pred[, opts]) | promise of the truthy value | Poll a cross-tick condition (below). |
nx.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:
nx.async(function()
local r = nx.await(nx.run({ cmd = "git", args = { "rev-parse", "HEAD" } }))
if r.code == 0 then nx.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), nx.run_stream
returns a Stream you iterate with nx.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:
nx.async(function()
local stream = nx.run_stream({ cmd = "rg", args = { "--files" } })
for batch in nx.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 nx.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) |
nx.async(function()
-- read three files concurrently, wait for all
local texts = nx.await(nx.promise.all({
nx.fs.read_text("a.lua"),
nx.fs.read_text("b.lua"),
nx.fs.read_text("c.lua"),
}))
nx.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 |
|---|---|---|
nx.schedule(fn) | at the end of the current tick (a microtask) | defer off the current call stack, same convergence |
nx.on_next_tick(fn) | on the next event-loop tick | observe state the server only refreshes between ticks |
nx.timer(fn, ms) | after ms wall-clock ms (returns a :stop() handle) | delays, polling, self-rescheduling timers |
nx.wait_for(pred, opts) | polls pred between ticks, fulfils a promise with its truthy value | await a cross-tick condition |
The cross-tick gotcha.
nx.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-armingnx.schedule(self)to “wait” for such a value spins forever. For anything cross-tick usenx.on_next_tickornx.wait_for— the latter is the await-able form:
nx.async(function()
local v = nx.view.create({}); v:mount({ float = { width = 40, height = 8 } })
local win = nx.await(nx.wait_for(function() return v:winid() end)) -- settles when the winid exists
vim.wo[win].number = false
end)()
nx.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
nx.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 annx.asyncfunction asfnto kick await-able work after the quiet period.nx.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.nx.schedule_wrap(fn)— a function that, when called, defersfnto the end of the tick — thenx.scheduleanalogue of a wrapped callback.
Try it
| Example | Shows |
|---|---|
async-runtime | The bare loop — nx.schedule vs nx.timer, and a self-rescheduling timer firing on wall-clock time while the editor stays responsive. |
ui-picker | nx.run_stream + nx.await_each streaming rg results into a source. |
NXVIM_CONFIG=examples/async-runtime cargo run -p nxvim -- examples/async-runtime/sample.txt
How it works (in brief)
nx.promise is pure Lua layered on the editor’s existing async runtime: a
promise’s reactions run as microtasks via nx.schedule, exactly the way the browser
runs .then callbacks off the current stack, so :next is always asynchronous and
the ordering matches Promises/A+. nx.async drives a coroutine one resume at a time —
each nx.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
(nx._system_async / nx._spawn_stream for process, the nx._fs_* bridge for
files, the daemon on wasm); only the shape is promises.
See also
- Writing nxvim plugins — where this fits in a plugin.
- UI primitives —
nx.component’ssetup/renderarenx.asynccontexts; widgets return promises. - ADR 0002 — native plugin system — why
promise-only, and the
vim.*alias whitelist.
Testing plugins
nxvim plugins are pure Lua over the nx.* 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 nxvim --test-plugin. No mocks, no stubs: the same
end-to-end philosophy as nxvim’s own Rust black-box harness
(crates/nxvim-test-harness), reachable from your plugin’s own repo and CI.
The framework is nx.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
nx.test.describe("my-plugin", function()
nx.test.before_each(function()
require("my-plugin").setup({})
end)
nx.test.it("inserts text", function(t)
t:feed("itext<Esc>") -- type in insert mode, then escape
nx.test.expect(t:lines()).to_equal({ "text" })
nx.test.expect(t:mode()).to_be("n")
end)
end)
Run it — defaults to the current directory:
nxvim --test-plugin # runs ./test/**/*_spec.lua
nxvim --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. Every test
starts from a fresh slate — a new empty buffer in normal mode — so one test’s
edits never bleed into the next. A test exercises your plugin against a clean
editor and nothing else.
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).
nx.test handles it for you: every it body runs inside an nx.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):
nx.test.it("shows a debounced popup", function(t)
t:feed("<Space>")
local float = t:wait_for(function() return t:float() end)
nx.test.expect(float.text).to_contain("write")
end)
API
Structure
| Call | Meaning |
|---|---|
nx.test.describe(name, fn) | A group; nestable. |
nx.test.it(name, fn) | A test; fn receives the context t. |
nx.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 — nx.test.expect(value)
Matchers are called with a dot; prefix any with .never to invert
(nx.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:cmdline() / t:statusline() | The message / command / status line text. |
Hermetic seams
For plugins that touch the clipboard or the filesystem:
nx.test.clipboard.seed(text[, linewise])— put text on"+/"*as if an external app set it.nx.test.clipboard.peek()→text, linewise(what a plugin wrote).nx.test.clipboard.clear().nx.test.tempdir()— a fresh, already-created unique directory; pair withnx.fsto exercise a plugin’s file I/O without collisions.
A real example
nxvim-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: nx.test is nil and the
UI mirror it reads (nx._ui) is unpopulated. It is turned on only by the
--test-plugin runner (via the nx_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 nxvim plugins — the anatomy a spec tests.
- Async & promises — the
nx.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 nxvim editor currently emits. Register a handler for
one with nx.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. nxvim 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.
Buffer lifecycle
| Event | When it fires | Notes |
|---|---|---|
BufReadPost | A file-backed buffer is first shown after reading an existing file from disk. | Fires once per buffer (gated by the “announced” set). buf / file set. |
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. |
BufEnter / BufLeave | A buffer becomes / stops being the current one (including plain switches with no read). | buf / file set. |
BufDelete | Just before a buffer is deleted (:bdelete), while its state still exists. | buf / file set. |
Ordering on opening a file is BufReadPost (or BufNewFile for a new path) → FileType → BufEnter.
Writing
| Event | When it fires | Notes |
|---|---|---|
BufWritePre | Before a buffer is written to disk (:w, :wall, and finalized off-tick saves). | match / file is the path; glob-matchable, e.g. a *.rs format-on-save hook. |
BufWrite | Same point as BufWritePre — the bare-name spelling. | |
BufWritePost | After a successful write. | The hook format-on-save and “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). |
CursorMoved | The cursor moves in Normal mode. |
CursorMovedI | The cursor moves in Insert mode. |
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 = … }. |
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. |
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. |
nx.* API Reference
The public nx.* Lua API, extracted directly from the prelude
(crates/nxvim-lua/src/prelude/*.lua) by book/gen/generate.py. Every
entry is a public declaration plus its doc-comment; private nx._*
internals are excluded. This is the canonical surface per
ADR 0002.
| Namespace | Functions |
|---|---|
nx | 47 |
nx.align | 3 |
nx.augroup | 1 |
nx.autocmd | 5 |
nx.buf | 20 |
nx.buffers | 1 |
nx.bufinfo | 1 |
nx.cmdline_complete | 1 |
nx.cmdtype | 1 |
nx.complete | 3 |
nx.cursor | 2 |
nx.daemon | 1 |
nx.decor | 1 |
nx.diagnostic | 10 |
nx.dock | 2 |
nx.env | 2 |
nx.explorer | 3 |
nx.fname | 2 |
nx.fs | 16 |
nx.hash | 6 |
nx.hl | 2 |
nx.json | 2 |
nx.jumplist | 1 |
nx.keymap | 8 |
nx.layer | 2 |
nx.list | 3 |
nx.lsp | 29 |
nx.match | 5 |
nx.ns | 1 |
nx.option | 2 |
nx.panel | 1 |
nx.panels | 1 |
nx.picker | 4 |
nx.pos | 2 |
nx.process | 1 |
nx.pum | 1 |
nx.qf | 24 |
nx.reg | 5 |
nx.screen | 3 |
nx.shada | 3 |
nx.snippet | 3 |
nx.socket | 1 |
nx.statusline | 4 |
nx.str | 13 |
nx.tabpage | 8 |
nx.tbl | 14 |
nx.terminal | 1 |
nx.treesitter | 1 |
nx.ui | 5 |
nx.undotree | 1 |
nx.user_command | 4 |
nx.utils | 1 |
nx.view | 5 |
nx.win | 22 |
nx.wininfo | 1 |
nx.workspace | 2 |
314 functions across 56 namespaces.
nx
Top-level nx.* functions (those without a sub-namespace).
nx.mode()
nvim_get_mode(): the editor’s current mode, read from the nx._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.
nx.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.
nx.regex(pattern, opts)
nx.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 nx.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: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 = nx.regex([[(\w+)@(\w+)]]) local _, _, user, host = re:find(“to jo@acme now”) – “jo”, “acme” for word in nx.regex([[\w+]]):gmatch(“one two”) do … end local masked = nx.regex([[\d]]):gsub(“id 42”, “*”) – “id **”
Defined in api.lua.
nx.replace_termcodes(str, _from_part, _do_lt, _special)
nvim_replace_termcodes(str, from_part, do_lt, special): in neovim, translate
key notation (<CR>, <C-w>, <lt>, …) into the internal terminal-byte
encoding. nxvim represents keys as that notation throughout — parse_keys and
nvim_feedkeys consume notation directly — so the canonical internal form of a
key string already IS the notation, and this returns str unchanged. The
result round-trips exactly through nvim_feedkeys (which re-parses the notation),
which is the contract callers rely on (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; <lt> and the special names are handled by
parse_keys at feed time, so no pre-translation is needed here.
Defined in api.lua.
nx.mode_str(_expanded)
nx.mode_str([expanded]) [alias vim.fn.mode]: the single-letter mode code
(“n”/“i”/“v”/“V”/“R”/“c”). (Distinct from nx.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.
nx.err_writeln(msg)
Message writers (aliases nvim_err_writeln / nvim_err_write / nvim_out_write).
Error writers route through nx._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.
nx.err_write(msg)
No documentation comment in the prelude.
Defined in api.lua.
nx.out_write(msg)
No documentation comment in the prelude.
Defined in api.lua.
nx.list_uis()
nx.list_uis() [alias nvim_list_uis]: the attached UIs. nxvim 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 (nxvim’s redraw protocol carries no external-UI widgets).
Defined in api.lua.
nx.exec(src, output)
nx.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.
nx.component(def)
nx.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 (callnx.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).
Surfaces — what render returns, and how the surface behaves:
- “view” — a focus-taking, navigable
nx.viewbuffer (dock / split / grabbing float): the file-tree / list / modal-dialog case.renderreturns{ lines, decor }(or a bare line list).nx.view.component(def)is the sugar. - “float” — a NON-focus
nx.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
nx.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 nx.view.create{ persist=} +
nx.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 nx.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—nx.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 = nx.component({
setup = function(ctx)
local s = ctx.reactive({ n = 0 })
ctx.keymap_set("n", "+", function() s.n = s.n + 1 end) -- write -> re-render
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 } })
Defined in component.lua.
nx.on_key_pending(fn)
No documentation comment in the prelude.
Defined in keymap.lua.
nx.on(event, opts, fn)
Events — structured autocmd subscriptions. nx.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 nx.off).
Defined in nx.lua.
nx.off(id)
Drop a subscription created by nx.on.
Defined in nx.lua.
nx.command(name, fn, opts)
User commands — nx.command(name, fn, opts) defines :Name; fn is a
function or an ex-command string.
Defined in nx.lua.
nx.uuid()
nx.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 nx.lua.
nx.echo(msg)
nx.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 nx.notify.
Defined in nx.lua.
nx.argv()
nx.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 NXVIM_ARGV environment variable, so the binary stays the single source of truth.
Defined in nx.lua.
nx.reexec(args)
nx.reexec(args) -> does not return on success. Replace THIS process with a fresh
nxvim <args…> of the current executable — a launcher relaunches the editor with
chosen flags this way (e.g. { “–shada-namespace”, ns, “–restore-session” }). On
Unix this execv()s (never returns on success); elsewhere it spawns and exits with
the child’s status. Raises if the exec / spawn itself fails.
Defined in nx.lua.
nx.now_ms()
nx.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 nx.lua.
nx.runtime_file(name, all)
nx.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 nx.lua.
nx.open(path, opts)
nx.open(path[, opts]) -> nil. Open a file or directory in the editing area, like
:edit. With opts.where == "main" it first crosses to the main editor layer (so
an open fired from a dock / sidebar keymap lands in the main area, not the dock);
the default opens in the current window.
Defined in nx.lua.
nx.run(spec)
nx.run { cmd, args, cwd, env, stdin } -> promise of { code, stdout, stderr }.
Runs a child to completion off the input tick, collecting all of stdout. It
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. The one-shot promise twin of
nx.run_stream.
Defined in process.lua.
nx.run_stream(spec)
nx.run_stream { cmd, args, cwd, env } -> Stream. Spawns a child and streams its stdout in newline-delimited batches. The streaming twin of nx.run; the picker / completion sources consume it to feed results as they arrive.
Defined in process.lua.
nx.await_each(stream)
nx.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 nx.async function (nx.await suspends the
enclosing coroutine).
for batch in nx.await_each(stream) do for _, line in ipairs(batch) do … end end
Defined in process.lua.
nx.async(fn)
nx.async(fn) returns a function that, when called, runs fn as a coroutine
and returns a promise for its result. Inside, nx.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 = nx.async(function(path)
local stat = nx.await(fs.stat(path))
local data = nx.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 nxvim’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.
nx.await(awaitable)
nx.await(awaitable): suspend the enclosing nx.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 nx.async coroutine — there is nothing to suspend.
Defined in promise.lua.
nx.schedule(fn)
nx.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.
nx.schedule_wrap(fn)
nx.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.
nx.timer(fn, timeout)
nx.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.
nx.on_next_tick(fn)
nx.on_next_tick(fn): run fn on the NEXT event-loop tick — the turn after the
current one finishes. The cross-tick sibling of nx.schedule: where nx.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), nx.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.
nx.notify(msg, level, _opts)
No documentation comment in the prelude.
Defined in runtime.lua.
nx.notify_once(msg, level, opts)
nx.notify_once [alias vim.notify_once]: in neovim this dedups by message; we have no message history to dedup against during a one-shot colorscheme load, so route to notify.
Defined in runtime.lua.
nx.inspect(value)
nx.inspect [alias vim.inspect]: pretty-print a value (tables recursively).
Defined in runtime.lua.
nx.exists(expr)
nx.exists(expr) [alias vim.fn.exists]: does the vim entity named by expr exist? (1 / 0). nxvim
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 nxvim models. A completion
plugin gates every window-option write on
exists('+'..key), so an unknown option is skipped instead of erroring the float setup. - ‘g:’/‘b:’/‘w:’/‘t:’/‘v:’ prefixed name -> that scoped variable is set.
- ‘:Cmd’ -> a user command nxvim 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.
nx.set_option(name, value)
nx.set_option / nx.get_option(name[, value]) [aliases nvim_set_option / nvim_get_option]: the global-scope (deprecated) accessors. Route through nx.o, the canonical global-option table that canonicalizes the scope the name implies.
Defined in state.lua.
nx.get_option(name)
No documentation comment in the prelude.
Defined in state.lua.
nx.npcall(fn, ...)
nx.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.
nx.nonnil(...)
nx.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.
nx.keytrans(s)
nx.keytrans(s) [alias vim.fn.keytrans]: translate the internal form of a key
sequence to readable key notation (<C-w>, <Space>, …). nxvim represents keys
AS that notation throughout (parse_keys / nvim_feedkeys consume notation directly,
and nvim_replace_termcodes returns its input unchanged), so the internal form
already IS the notation — this returns s unchanged, the inverse of
nvim_replace_termcodes exactly as in vim.
Defined in stdlib.lua.
nx.print(...)
nx.print(…) [alias vim.print]: pretty-print each argument (via nx.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.
nx.iter(src, state, ctrl)
nx.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.
nx.line(expr)
nx.line(expr) [alias vim.fn.line]: a buffer line number. “.” is the cursor line (1-based), “$” the last line (the line count). The window-relative forms (“w0”/“w$”) need the scroll position, which the mirror doesn’t carry yet, so they error loud.
Defined in vimfn.lua.
nx.col(expr)
nx.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.
nx.localtime()
nx.localtime() [alias vim.fn.localtime]: the current time in seconds. nxvim
sources this from a MONOTONIC clock (the server’s nx._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.
nx.expand(expr)
nx.expand(expr) [alias vim.fn.expand]: the % (current file) forms autocmd
callbacks and statuslines use to resolve paths, backed by the current-buffer
snapshot. % is the stored name; %:<mods> routes through fnamemodify (so %:t,
%:p, %:h, %:r, %:~:., … all work). A non-% expression errors loud.
(the override below extends this with cursor keywords / globs, re-binding nx.expand.)
Defined in vimfn.lua.
nx.getmousepos()
nx.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 — nxvim has no
‘virtualedit’). Reads the nx._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.
nx.align
nx.align.left(line, width)
nx.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 nx.str.width, so wide (CJK / emoji) glyphs pad correctly.
Defined in stdlib.lua.
nx.align.right(line, width)
nx.align.right(line, width): like nx.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 (nx.str.width).
Defined in stdlib.lua.
nx.align.center(line, width)
nx.align.center(line, width): like nx.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 (nx.str.width).
Defined in stdlib.lua.
nx.augroup
nx.augroup.create(name, opts)
nx.augroup.create(name, opts) -> id [alias nvim_create_augroup]: define (or look
up) an autocommand group and return its numeric id. An augroup is just a named
bucket for autocmds: pass the returned id as opts.group to nx.autocmd.create so
the whole set can be cleared and re-registered as a unit.
Arguments:
name— the group name (string). Calling create again with the same name returns the SAME id; the id is stable across recreation, so it’s safe to store.opts.clear— when the group already exists, whether to remove its existing autocmds first. Defaults to TRUE (matching neovim). This is what makes re-sourcing your config idempotent: a config that doesnx.augroup.create("MyGroup")on every load clears the previous run’s autocmds instead of double-registering them. Pass{ clear = false }to keep them (the augroup-block /:augroupex-command path uses this to append).
The idiomatic pattern — own a group, then hang autocmds off it:
local grp = nx.augroup.create("MyConfig") -- clears on re-source
nx.autocmd.create("BufEnter", {
group = grp, -- numeric id, or "MyConfig"
callback = function(ev) nx.notify("entered " .. (ev.file or "[No Name]")) end,
})
Defined in autocmd.lua.
nx.autocmd
nx.autocmd.create(event, opts)
nx.autocmd.create(event, opts) -> id [alias nvim_create_autocmd]: run something
whenever event fires. Returns the autocmd’s numeric id (pass it to
nx.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
nxvim emits and what each carries.
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 (see nx.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.
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.
nx.autocmd.create("FileType", {
pattern = "markdown",
callback = function(ev)
nx.bo[ev.buf].textwidth = 80
end,
})
Defined in autocmd.lua.
nx.autocmd.del(id)
nx.autocmd.del(id) [alias nvim_del_autocmd]: remove the autocmd with this id, so it stops firing.
Defined in autocmd.lua.
nx.autocmd.exec(event, opts)
nx.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.
Defined in autocmd.lua.
nx.autocmd.get(opts)
nx.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} entries, optionally filtered by opts.event (string or list) and
opts.group (id or name). Run it interactively as
:lua print(vim.inspect(nx.autocmd.get({}))).
Defined in autocmd.lua.
nx.autocmd.clear(opts)
nx.autocmd.clear(opts) [alias nvim_clear_autocmds]: remove every autocmd
matching the filter — the bulk analogue of nx.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
nx.autocmd.get’s matching.
Defined in autocmd.lua.
nx.buf
nx.buf.name(bufnr)
nx.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.
nx.buf.current()
The buffer / window / cursor getters read the nx._bufs / nx._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
nx.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.
nx.buf.current() -> bufnr [alias nvim_get_current_buf]: the current buffer’s
number (0 if there is none).
Defined in api.lua.
nx.buf.call(buf, fn)
nx.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.
nx.buf.is_loaded(bufnr)
nx.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.
nx.buf.is_valid(bufnr)
nx.buf.is_valid(bufnr) -> bool [alias nvim_buf_is_valid]: whether bufnr (0/nil =
current) names a buffer nxvim knows about. There is no separate “valid but
unloaded” state in the mirror yet, so this currently matches nx.buf.is_loaded.
Defined in api.lua.
nx.buf.line_count(bufnr)
nx.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.
nx.buf.offset(bufnr, index)
nx.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.
nx.buf.text(bufnr, start_row, start_col, end_row, end_col, _opts)
nx.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 nx.buf.lines for whole lines.
Defined in api.lua.
nx.buf.lines(bufnr, start, end_, strict)
nx.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.
nx.buf.set_lines(bufnr, start, end_, strict, replacement)
nx.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 nx.await(nx.buf.set_lines(…)) is
the point at which a following nx.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 / nx.view / quickfix) is refused loudly server-side.
Defined in api.lua.
nx.buf.search(bufnr, pattern, opts)
nx.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.
nx.buf.set_extmark(buffer, ns, line, col, opts)
nx.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 nx.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.
Defined in api.lua.
nx.buf.del_extmark(buffer, ns, id)
nx.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.
nx.buf.clear_namespace(buffer, ns, line_start, line_end)
nx.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.
Defined in api.lua.
nx.buf.extmarks(buffer, ns, start, end_, opts)
nx.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.
nx.buf.set_option(buf, name, value)
nx.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 nx.option.set(name, value, { buf = buf }),
which this wraps.
Defined in api.lua.
nx.buf.get_option(buf, name)
nx.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
nx.buf.set_option. In new code prefer nx.option.get(name, { buf = buf }).
Defined in api.lua.
nx.buf.list(opts)
nx.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.
nx.buf.getline(buf, lnum, lend)
nx.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
nx.buf.lines (0-based, end-exclusive). An out-of-range request yields {} (vim).
Defined in api.lua.
nx.buf.nr(expr)
nx.buf.nr(expr) [alias vim.fn.bufnr]: the buffer number for expr. “” / “%” / nil
/ 0 -> current buffer; “$” -> the last (largest) buffer number; a string -> the
loaded buffer whose name matches (exact, else suffix), -1 when none. Backed by
the Phase-6 nx._bufs mirror.
Defined in api.lua.
nx.buffers
nx.buffers.actions.open()
nx.buffers.actions.open(): the default <CR> action inside the :ls / :buffers
list panel (filetype nxbuffers). 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 (nx.qf.actions.jump, nx.panels.actions.open). Note this
is the buffer-list panel’s key action, distinct from the nx.buf.* buffer API.
Defined in keymap.lua.
nx.bufinfo
nx.bufinfo.get(arg)
No documentation comment in the prelude.
Defined in api.lua.
nx.cmdline_complete
nx.cmdline_complete.setup(opts)
nx.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.
nx.cmdtype
nx.cmdtype.get()
No documentation comment in the prelude.
Defined in api.lua.
nx.complete
nx.complete.setup(opts)
nx.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. sources is a list of { name, opts... } entries — a
built-in (buffer / lsp / snippets) or a registered plugin source.
min_chars (from a source, or the top level) gates the prefix length before the
popup opens. auto (default true) completes as you type. keys overrides any of
the four control actions.
Defined in complete.lua.
nx.complete.trigger()
nx.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 nx.complete.setup{}.
Defined in complete.lua.
nx.complete.source(spec)
nx.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> } — and signals completion by
returning (an nx.async source returns its promise; a synchronous one just
returns — nx is promise-only, so there is no done callback).
The source runs off the input path (debounced by debounce ms, default
nx.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. Activate the
source by listing its name in nx.complete.setup{ 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.
Defined in complete.lua.
nx.cursor
nx.cursor.get(win)
No documentation comment in the prelude.
Defined in api.lua.
nx.cursor.set(pos, win)
nx.cursor.set(pos[, win]): move window win’s cursor (0/nil = the current
window) to pos, a { row, col } pair in the SAME convention nx.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 nx._win_set_cursor bridge.
Defined in api.lua.
nx.daemon
nx.daemon.status()
nx.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 nx.lua.
nx.decor
nx.decor.provider(spec)
nx.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, end_row?, end_col?, hl?, priority? } (row/col may be positional
{ row, col, ... } or named). v1 renders hl; other fields fold into the extmark
layer where they are accepted-but-unrendered until that layer grows them.
Defined in decor.lua.
nx.diagnostic
nx.diagnostic.get(bufnr, opts)
nx.diagnostic.get([bufnr, [opts]]): diagnostics as plain tables. nil bufnr →
every buffer; 0 → the current one. opts.severity (a number) filters. The
entries are copied out (callers must not mutate the mirror), each tagged with
its bufnr, matching neovim’s shape.
Defined in diagnostic.lua.
nx.diagnostic.goto_next(opts)
No documentation comment in the prelude.
Defined in diagnostic.lua.
nx.diagnostic.goto_prev(opts)
No documentation comment in the prelude.
Defined in diagnostic.lua.
nx.diagnostic.toqflist(diagnostics)
nx.diagnostic.toqflist(diagnostics): convert a list of diagnostic tables (the
shape nx.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.
nx.diagnostic.setqflist(opts)
nx.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.
nx.diagnostic.setloclist(opts)
nx.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.
nx.diagnostic.open_float(_opts)
nx.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. opts (scope/severity filters, formatting) is not yet honored — the
default cursor-line scope is what nxvim shows.
Defined in diagnostic.lua.
nx.diagnostic.config(opts, _namespace)
No documentation comment in the prelude.
Defined in diagnostic.lua.
nx.diagnostic.set(namespace, bufnr, diagnostics, _opts)
nx.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 nx._diagnostics_ns. A plugin drives its own
buffer’s diagnostics through this.
Defined in diagnostic.lua.
nx.diagnostic.reset(namespace, bufnr)
nx.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.
nx.dock
nx.dock.opt(side)
nx.dock.opt(side) — an options proxy for one dock, mirroring nx.wo/nx.bo:
reads return the cached value (or the default), writes queue the change.
Defined in nx.lua.
nx.dock.open(o)
No documentation comment in the prelude.
Defined in nx.lua.
nx.env
nx.env.get(name)
No documentation comment in the prelude.
Defined in state.lua.
nx.env.set(name, value)
nx.env.set(name, value) [alias vim.fn.setenv]: set an environment variable for this session. nxvim 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.
nx.explorer
nx.explorer.actions.open()
No documentation comment in the prelude.
Defined in explorer.lua.
nx.explorer.actions.up()
No documentation comment in the prelude.
Defined in explorer.lua.
nx.explorer.enable()
nx.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.
nx.fname
nx.fname.modify(fname, mods)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.fname.escape(fname)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.fs
nx.fs.stat(path)
nx.fs.stat(path) -> promise of { type, size, mtime, atime, mode } (follows links)
Defined in fs.lua.
nx.fs.lstat(path)
nx.fs.lstat(path) -> like stat but does NOT follow a symlink (type may be “link”).
Defined in fs.lua.
nx.fs.exists(path)
nx.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.
nx.fs.readdir(path)
nx.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.
nx.fs.walk(dir, opts)
nx.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 nx.fs op, so it works against local disk, a daemon, and OPFS alike.
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 })
An unreadable subdirectory is skipped (not fatal). MUST be awaited inside nx.async.
Defined in fs.lua.
nx.fs.grep(dir, query, opts)
nx.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 nx.fs op, so it works against local disk, a daemon, and OPFS.
Walks dir (nx.fs.walk), reads each file (nx.fs.read_text), and matches query as a
LITERAL substring per line. Binary / unreadable files (read_text rejects) are skipped.
opts pass through to nx.fs.walk (max / hidden / skip). MUST be awaited inside nx.async.
Defined in fs.lua.
nx.fs.read(path)
nx.fs.read(path) -> promise of the file’s RAW bytes (a Lua byte-string).
Defined in fs.lua.
nx.fs.read_text(path, opts)
nx.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 nx.fs.read for raw bytes.
Defined in fs.lua.
nx.fs.write(path, data)
nx.fs.write(path, data) -> promise (resolves nil). Truncates / creates.
Defined in fs.lua.
nx.fs.append(path, data)
nx.fs.append(path, data) -> promise (resolves nil). Creates if absent.
Defined in fs.lua.
nx.fs.mkdir(path, opts)
nx.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.
nx.fs.rename(from, to)
nx.fs.rename(from, to) -> promise (resolves nil).
Defined in fs.lua.
nx.fs.remove(path, opts)
nx.fs.remove(path[, { recursive = false }]) -> promise (resolves nil). A file is
unlinked; a directory needs recursive unless already empty.
Defined in fs.lua.
nx.fs.copy(src, dst, opts)
nx.fs.copy(src, dst[, { recursive = false }]) -> promise (resolves nil). A file
copies (overwriting); a directory needs recursive.
Defined in fs.lua.
nx.fs.realpath(path)
nx.fs.realpath(path) -> promise of the canonical absolute path (symlinks resolved).
Defined in fs.lua.
nx.fs.watch(path, opts)
No documentation comment in the prelude.
Defined in fs.lua.
nx.hash
nx.hash.file(path, algo)
nx.hash.file(path[, algo]) -> promise of the file’s lowercase-hex digest. The
streaming member of the nx.hash.* family (the in-memory string hashers and the
incremental nx.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 nx.hash.sha256(nx.await(nx.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.
nx.hash.sha1(data)
nx.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.
nx.hash.sha256(data)
nx.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.
nx.hash.sha512(data)
nx.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.
nx.hash.md5(data)
nx.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.
nx.hash.new(algo)
nx.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 nx.await_each — feed each chunk in as it arrives:
nx.async(function() local h = nx.hash.new(“sha256”) for batch in nx.await_each(nx.run_stream({ cmd = “curl”, args = { “-s”, url } })) do for _, line in ipairs(batch) do h:update(line) end end print(h:hexdigest()) end)()
Note nx.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 nx.process.open’s on_stdout instead. To digest a file on disk, prefer nx.hash.file, which streams the file server-side and never sends its bytes to Lua.
Defined in hash.lua.
nx.hl
nx.hl.get(ns, opts)
No documentation comment in the prelude.
Defined in api.lua.
nx.hl.exists(name)
nx.hl.exists(name): is the highlight group name defined? Returns a native
boolean (the rest of nx.* is boolean, not vim’s 1/0). Backed by the same
nx._hl_defs registry nvim_get_hl reads (concrete groups and links both count).
Defined in api.lua.
nx.json
nx.json.encode(value, opts)
nx.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.
nx.json.decode(str)
nx.json.decode(str) -> value. Parses a JSON document (objects -> string-keyed
tables, arrays -> sequences, null -> nil); raises on malformed input.
Defined in stdlib.lua.
nx.jumplist
nx.jumplist.get(winnr, _tabnr)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.keymap
nx.keymap.set(mode, lhs, rhs, opts)
nx.keymap.set(mode, lhs, rhs, opts): map lhs to rhs in mode.
rhs is a function (stored in nx._keymap_fns) or a string (fed as keys).
Maps are non-recursive by default (the nx.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.
Defined in keymap.lua.
nx.keymap.del(mode, lhs, opts)
nx.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.
nx.keymap.raw_buf_set(buffer, mode, lhs, rhs, opts)
nx.keymap.raw_buf_set(buffer, mode, lhs, rhs, opts) [alias nvim_buf_set_keymap].
Defined in keymap.lua.
nx.keymap.raw_set(mode, lhs, rhs, opts)
nx.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.
nx.keymap.buf_del(buffer, mode, lhs)
nx.keymap.buf_del(buffer, mode, lhs) [alias nvim_buf_del_keymap]: remove a buffer-local mapping. The global form, nvim_del_keymap, aliases nx.keymap.del directly — its (mode, lhs) call already matches nx.keymap.del’s signature.
Defined in keymap.lua.
nx.keymap.get(mode)
nx.keymap.get(mode) [alias nvim_get_keymap]: every GLOBAL mapping that applies
in mode, as maparg dicts. Buffer-local maps are excluded (nx.keymap.buf_get
returns those).
Defined in keymap.lua.
nx.keymap.buf_get(buffer, mode)
nx.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.
nx.keymap.arg(name, mode, _abbr, dict)
nx.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 — nxvim has no abbreviations. A
buffer-local map for the current buffer shadows a global one at the same lhs.
Defined in keymap.lua.
nx.layer
nx.layer.focus(target)
nx.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 nx.lua.
nx.layer.main()
nx.layer.main() -> nil. Shorthand for nx.layer.focus(“main”) — focus the main editor area.
Defined in nx.lua.
nx.list
nx.list.extend(dst, src, start, finish)
nx.list.extend(dst, src, start, finish) [alias vim.list_extend]: append src[start..finish] onto dst.
Defined in stdlib.lua.
nx.list.slice(list, start, finish)
nx.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.
nx.list.is_list(t)
No documentation comment in the prelude.
Defined in stdlib.lua.
nx.lsp
nx.lsp.config(name, opts)
nx.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 nx.lsp.config[name] = {…} table-assignment sugar.
Defined in lsp.lua.
nx.lsp.enable(names)
nx.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.
nx.lsp.disable(names)
nx.lsp.disable(names): the inverse of enable — future buffers won’t start the
named servers (already-running servers keep serving until their buffers close).
Defined in lsp.lua.
nx.lsp.start(cfg, opts)
nx.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.
nx.lsp.definition()
Each verb queues an LspOp the server drains on the same input tick (reading the
cursor where the key fired) and routes into its existing surface: a single
location jumps, many open the loclist; hover/signature open the cursor float;
code actions open the select menu; format/rename apply edits. There is no reply
handling in Lua. The verbs are bare (no implicit args) so
nx.keymap.set("n", "gd", nx.lsp.definition) works. kind ints mirror
LspReqKind::as_u16 (crates/nxvim-server/src/lsp/mod.rs) — keep the two in step.
Defined in lsp.lua.
nx.lsp.declaration()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.type_definition()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.implementation()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.references()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.hover()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.signature_help()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.signature_help_autotrigger(enable)
nx.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.
nx.lsp.format()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.code_action()
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.document_symbol()
nx.lsp.document_symbol(): the symbols defined in the current document, opened in nx.picker (kind 16 mirrors LspReqKind::DocumentSymbol::as_u16).
Defined in lsp.lua.
nx.lsp.workspace_symbol(query)
nx.lsp.workspace_symbol(query): symbols across the workspace matching query,
opened in nx.picker. With no query, prompt for one via nx.ui.input (non-blocking)
— an empty/cancelled prompt does nothing.
Defined in lsp.lua.
nx.lsp.rename(new_name)
nx.lsp.rename(new_name): rename the symbol under the cursor. With a name, request
it straight away; with none (the bare nx.keymap.set("n", "<leader>rn", nx.lsp.rename) case), prompt for it via nx.ui.input (non-blocking promise),
prefilled with the symbol under the cursor, and rename on confirm. An empty /
cancelled prompt does nothing.
Defined in lsp.lua.
nx.lsp.clients(filter)
nx.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.
Defined in lsp.lua.
nx.lsp.request(method, params, handler, bufnr)
nx.lsp.request(method, params, handler, bufnr): sugar resolving the buffer’s
primary client and issuing client:request. No attached client fails loud.
Defined in lsp.lua.
nx.lsp.notify(method, params, bufnr)
nx.lsp.notify(method, params, bufnr): the fire-and-forget sibling of request.
Defined in lsp.lua.
nx.lsp.semantic_tokens.start(bufnr)
start/stop the per-buffer semantic-token paint (neovim’s start/stop verbs).
Defined in lsp.lua.
nx.lsp.semantic_tokens.stop(bufnr)
No documentation comment in the prelude.
Defined in lsp.lua.
nx.lsp.semantic_tokens.enable(enabled)
nxvim’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.
nx.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.
nx.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).
Defined in lsp.lua.
nx.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.
nx.lsp.inlay_hint.is_enabled(filter)
is_enabled(filter?): whether inlay hints are on for the buffer.
Defined in lsp.lua.
nx.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.
Defined in lsp.lua.
nx.lsp.foldexpr(_lnum)
nx.lsp.foldexpr is the canonical LSP foldexpr, the foldmethod=expr fold
source backed by textDocument/foldingRange:
nx.bo.foldmethod = "expr"
nx.bo.foldexpr = "v:lua.nx.lsp.foldexpr()"
nxvim 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/nxvim-core/src/editor/fold.rs and crates/nxvim-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; nxvim recognizes both.
Defined in lsp.lua.
nx.match
nx.match.add(group, pattern, priority, id, opts)
nx.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 nx.match.get
reflects them — but nxvim 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.
nx.match.addpos(group, pos, priority, id, opts)
nx.match.addpos(group, pos[, priority[, id[, opts]]]) -> id [alias vim.fn.matchaddpos]:
like nx.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
nx.match.add.
Defined in vimfn.lua.
nx.match.delete(id, win)
nx.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.
nx.match.clear(win)
nx.match.clear([win]) -> 0 [alias vim.fn.clearmatches]: remove every match from
window win (0/nil = current).
Defined in vimfn.lua.
nx.match.get(win)
nx.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 nx.match.add,
the pos form from nx.match.addpos.
Defined in vimfn.lua.
nx.ns
nx.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.
nx.option
nx.option.set(name, value, opts)
nx.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 nx.wo (the targeted
window, else the current one); otherwise it routes through nx.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 nx.o / nx.bo / nx.wo
are the primary option API; this is the by-name funnel plugins reach for.)
Defined in state.lua.
nx.option.get(name, opts)
nx.option.get(name, opts) [alias nvim_get_option_value]: read an option from the scope its name implies (see nx.option.set), so a wired option reflects the core’s current value (default until set).
Defined in state.lua.
nx.panel
nx.panel.open(opts)
No documentation comment in the prelude.
Defined in nx.lua.
nx.panels
nx.panels.actions.open()
No documentation comment in the prelude.
Defined in keymap.lua.
nx.picker
nx.picker.source(spec)
nx.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 nx.async and returns the
promise (the engine awaits it; nx is promise-only, so there is no done
callback). A streaming source consumes a nx.run_stream with nx.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 nx.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 nx.picker.debounce (250), and is
also overridable per open via nx.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.
resumable = false opts the source out of nx.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.
nx.picker.open(name, opts)
nx.picker.open(name[, opts]): open the picker for the registered source name.
Each opts field overrides the matching field on the source (which in turn
overrides the picker default):
width/height— a FIXED box size: a cell count (e.g. 100) or a CSS-style viewport fraction string (“80vw” / “60vh” / “50%”). The picker is never content-sized (a content-hugging box looks ragged).align+margin— placement, like a float (see nx.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.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.
nx.picker.resume()
nx.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 nx._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.
nx.picker.edit(item, mode, layer)
nx.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 nx._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. nx._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.
nx.pos
nx.pos.get(expr)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.pos.set(expr, pos)
nx.pos.set(expr, pos) [alias vim.fn.setpos]: move the cursor when expr is “.”
(the only settable position nxvim models); pos is {bufnr, lnum, col, off}.
Other marks are accepted but not stored (no writable-mark mirror), returning 0.
Defined in vimfn.lua.
nx.process
nx.process.open(spec)
nx.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 nx callback): on_stdout(chunk) / on_stderr(chunk) per raw
batch, on_exit(code) exactly once.
Defined in process.lua.
nx.pum
nx.pum.visible()
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.qf
nx.qf.actions.jump()
nx.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. nx.buffers.actions.open).
Defined in keymap.lua.
nx.qf.getqflist(what)
nx.qf.getqflist([what]) -> list | dict [aliases nx.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 nx._qflist mirror the server pushes). With a what
dict, returns a dict carrying only the requested keys (title / items / size).
Defined in vimfn.lua.
nx.qf.setqflist(list, action, what)
nx.qf.setqflist(list[, action[, what]]) -> 0 [aliases nx.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 nx.qf.getqflist() only after the server drains the op — read it on a
later tick.
Defined in vimfn.lua.
nx.qf.getloclist(winnr, what)
nx.qf.getloclist(winnr[, what]) -> list | dict [aliases nx.getloclist /
vim.fn.getloclist]: the location list of window winnr (0 = current window;
otherwise an nxvim window id, NOT vim’s 1-based window number). Same return shape
as nx.qf.getqflist; an empty list when the window has none.
Defined in vimfn.lua.
nx.qf.setloclist(winnr, list, action, what)
nx.qf.setloclist(winnr, list[, action[, what]]) -> 0 [aliases nx.setloclist /
vim.fn.setloclist]: populate the location list of window winnr (0 = current
window; otherwise an nxvim window id). Same list/action/what semantics as
nx.qf.setqflist, only scoped to a window. Queued server-side like setqflist.
Defined in vimfn.lua.
nx.qf.send_to_loclist(list, opts)
send_to_loclist: results -> a (new) location list. add_to_loclist: append.
Defined in vimfn.lua.
nx.qf.add_to_loclist(list, opts)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.qf.send_to_qflist(list, opts)
send_to_qflist: results -> the global quickfix list. add_to_qflist: append.
Defined in vimfn.lua.
nx.qf.add_to_qflist(list, opts)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.qf.list(name, items, opts)
nx.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 nx.qf.show(name) for that.
opts.title (string) the list title shown in the dock tab (defaults to name).
opts.action (string) “r” (default, replace in place) / “ “ (push a new list onto
the stack) / “a” (append to the current list).
Returns the name.
Defined in vimfn.lua.
nx.qf.show(name)
nx.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 nx.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.
nx.qf.drop(name)
nx.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.
nx.qf.open(height)
nx.qf.open([height]) [wraps :copen]: open the quickfix window, optionally
height rows tall.
Defined in vimfn.lua.
nx.qf.close()
nx.qf.close() [wraps :cclose]: close the quickfix window.
Defined in vimfn.lua.
nx.qf.next()
nx.qf.next() [wraps :cnext]: jump to the next entry in the quickfix list.
Defined in vimfn.lua.
nx.qf.prev()
nx.qf.prev() [wraps :cprev]: jump to the previous entry in the quickfix list.
Defined in vimfn.lua.
nx.qf.older(count)
nx.qf.older([count]) [wraps :colder]: go to an older quickfix list in the stack
(count lists back, default 1).
Defined in vimfn.lua.
nx.qf.newer(count)
nx.qf.newer([count]) [wraps :cnewer]: go to a newer quickfix list in the stack
(count lists forward, default 1).
Defined in vimfn.lua.
nx.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.
nx.qf.lopen([height]) [wraps :lopen]: open the location-list window, optionally
height rows tall.
Defined in vimfn.lua.
nx.qf.lclose()
nx.qf.lclose() [wraps :lclose]: close the location-list window.
Defined in vimfn.lua.
nx.qf.lnext()
nx.qf.lnext() [wraps :lnext]: jump to the next entry in the location list.
Defined in vimfn.lua.
nx.qf.lprev()
nx.qf.lprev() [wraps :lprev]: jump to the previous entry in the location list.
Defined in vimfn.lua.
nx.qf.lolder(count)
nx.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.
nx.qf.lnewer(count)
nx.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.
nx.reg
nx.reg.recording()
No documentation comment in the prelude.
Defined in state.lua.
nx.reg.executing()
No documentation comment in the prelude.
Defined in state.lua.
nx.reg.set(name, value, options)
nx.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 (nx._set_reg) and write-through the mirror so a getreg
later in the same chunk is consistent.
Defined in state.lua.
nx.reg.get(name)
nx.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 nx._registers mirror the server refreshes before this chunk; an
uppercase name reads its lowercase store, matching vim.
Defined in state.lua.
nx.reg.gettype(name)
nx.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.
nx.screen
nx.screen.row()
No documentation comment in the prelude.
Defined in api.lua.
nx.screen.col()
No documentation comment in the prelude.
Defined in api.lua.
nx.screen.pos(win, lnum, col)
nx.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 nx.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.
nx.shada
nx.shada.plugin(dev_namespace)
No documentation comment in the prelude.
Defined in stdlib.lua.
nx.shada.namespaces()
nx.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.
nx.shada.forget(namespace)
nx.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.
nx.snippet
nx.snippet.setup(opts)
nx.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.
nx.snippet.add(ft, list)
nx.snippet.add(“rust”, { { trigger = “fn”, body = “fn ${1:name}() {\n\t$0\n}” }, … })
Register snippets for a filetype. trigger and body are required strings.
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.
nx.snippet.expand(body)
nx.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.
nx.socket
nx.socket.connect(spec)
nx.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.
nx.statusline
nx.statusline.segment(spec)
nx.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.
nx.statusline.invalidate(name)
nx.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 nx._statusline_invalidate.
Defined in statusline.lua.
nx.statusline.setup(opts)
nx.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.
nx.statusline.reset(win)
nx.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.
nx.str
nx.str.trim(text, mask, dir)
No documentation comment in the prelude.
Defined in stdlib.lua.
nx.str.startswith(s, prefix)
nx.str.startswith(s, prefix) [alias vim.startswith]: does s begin with prefix?
Defined in stdlib.lua.
nx.str.endswith(s, suffix)
nx.str.endswith(s, suffix) [alias vim.endswith]: does s end with suffix?
Defined in stdlib.lua.
nx.str.split(s, sep, opts)
nx.str.split(s, sep, opts) [alias vim.split]: split s on sep.
Defined in stdlib.lua.
nx.str.to_list(s, _utf8)
nx.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 }). nxvim 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.
nx.str.from_char(nr, _utf8)
nx.str.from_char(nr[, utf8]) [alias vim.fn.nr2char]: the string for codepoint nr
(nr2char(65) == "A"). The inverse of one nx.str.to_list element; nxvim is always
UTF-8 so utf8 is accepted and ignored.
Defined in stdlib.lua.
nx.str.chars(s, _skipcc)
nx.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 nxvim doesn’t classify combining marks.
Defined in stdlib.lua.
nx.str.displaywidth(s, col)
nx.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.
nx.str.utfindex(s, a, b)
No documentation comment in the prelude.
Defined in stdlib.lua.
nx.str.byteindex(s, a, b)
No documentation comment in the prelude.
Defined in stdlib.lua.
nx.str.charpart(s, start, len)
nx.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.
nx.str.trans(s)
nx.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.
nx.str.substitute(str, pat, sub, flags)
nx.str.substitute(str, pat, sub, flags) [alias vim.fn.substitute]: a real vim-regex
substitution, backed by the Rust engine (nx._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 nxvim’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.
nx.tabpage
nx.tabpage.current()
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.list()
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.is_valid(tab)
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.number(tab)
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.wins(tab)
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.win(tab)
No documentation comment in the prelude.
Defined in api.lua.
nx.tabpage.nr(arg)
nx.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
nx._tabs / nx._tab_order mirror the server pushes before evaluating the tabline.
Defined in api.lua.
nx.tabpage.buflist(nr)
nx.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.
nx.tbl
nx.tbl.is_empty(t)
nx.tbl.is_empty(t) [alias vim.tbl_isempty]: does t have no entries?
Defined in stdlib.lua.
nx.tbl.contains(t, value)
nx.tbl.contains(t, value) [alias vim.tbl_contains]: is value one of t’s values?
Defined in stdlib.lua.
nx.tbl.keys(t)
nx.tbl.keys(t) [alias vim.tbl_keys]: a list of t’s keys.
Defined in stdlib.lua.
nx.tbl.values(t)
nx.tbl.values(t) [alias vim.tbl_values]: a list of t’s values.
Defined in stdlib.lua.
nx.tbl.count(t)
nx.tbl.count(t) [alias vim.tbl_count]: number of entries in t (any keys, not just the sequence).
Defined in stdlib.lua.
nx.tbl.deep_equal(a, b)
nx.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.
nx.tbl.get(o, ...)
nx.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.
nx.tbl.filter(f, t)
nx.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.
nx.tbl.map(f, t)
nx.tbl.map(f, t) [alias vim.tbl_map]: apply f to each value, keeping keys.
Defined in stdlib.lua.
nx.tbl.flatten(t)
nx.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.
nx.tbl.deepcopy(orig)
nx.tbl.deepcopy(orig) [alias vim.deepcopy]: a recursive copy of orig (metatables preserved).
Defined in stdlib.lua.
nx.tbl.deep_extend(behavior, ...)
nx.tbl.deep_extend(behavior, …) [alias vim.tbl_deep_extend]: Merge ... maps into one. behavior is “force” | “keep” | “error”. Nested
tables merge recursively; scalar conflicts resolve per behavior.
Defined in stdlib.lua.
nx.tbl.extend(behavior, ...)
nx.tbl.extend(behavior, …) [alias vim.tbl_extend]: Shallow variant of nx.tbl.deep_extend.
Defined in stdlib.lua.
nx.tbl.spairs(t)
nx.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.
nx.terminal
nx.terminal.open(opts)
nx.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 nx.lua.
nx.treesitter
nx.treesitter.foldexpr(_lnum)
No documentation comment in the prelude.
Defined in nx.lua.
nx.ui
nx.ui.select(items, opts, on_choice)
nx.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 = item -> display string (default tostring); the original item round-trips to the resolved value regardless.
Defined in ui.lua.
nx.ui.input(opts, on_confirm)
nx.ui.input(opts) -> a PROMISE that resolves to the entered string on <CR>, or
to nil on <Esc> (cancel). Promise-only: there is no on_confirm argument (passing
one is the old callback shape and errors loudly). opts:
prompt = the label drawn ahead of the editable line (default “”)
default = text prefilled into the line, cursor at its end (default “”)
history = a namespace string enabling readline-style recall: <Up>/<Down>
(and <C-p>/<C-n>) browse the prompts submitted under this
namespace, and each non-empty submission is recorded into it. Each
namespace is an independent ring, so one plugin’s REPL history is
separate from another’s. Session-only for now. Absent ⇒ no history.
complete = a function (line, col) -> candidates driving <Tab> autocomplete
(the inline wildmenu above the prompt line — <Tab>/<S-Tab> cycle,
<CR> accepts). candidates is a { {label, insert?, doc?, start?, length?}, … } list (insert defaults to label), OR a PROMISE of one —
so an async source (e.g. a DAP completions request) works. The token
completed is the trailing identifier run before the cursor, UNLESS a
candidate supplies start (0-based char offset into the line) + length
(chars), an explicit replace span that overrides the token for that row.
col is the cursor’s 0-based char offset.
complete_docs = show the side docs pane rendering each candidate’s doc beside
the list (default true when complete is set; false suppresses it).
complete_debounce = ms to coalesce refresh queries (narrowing an open menu as you
type) so an async source isn’t a wire round-trip per keystroke; the
initial <Tab> is always immediate (default 100; 0 disables it).
The server owns the prompt: it opens the editor’s command line as a labelled
Prompt (Editor::open_prompt), and delivers the result to nx._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.
nx.ui.confirm(message, opts, on_choice)
nx.ui.confirm(message, opts) -> a PROMISE that resolves to a boolean — true on Yes, false on No or cancel (<Esc>). Promise-only: there is no on_choice argument (the old callback forms — a third arg, or opts-as-function — error loudly). opts (optional): default = true | false – which button <CR> selects (default true = Yes) nx-native (no vim.ui twin): neovim spells this blocking vim.fn.confirm, which the nx model omits (rule 3). For an arbitrary multi-choice menu use nx.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 nx.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.
nx.ui.float(contents, opts)
nx.ui.float(contents, opts): open the list-less content float — the sibling of
the selectable-list widget (docs/specs/2026-06-14-nx-ui-float-widget.md, “What
stays out of this widget”) — rendering content with no list / selection.
contents is a string (split on newlines), a list of line strings, or — for a
styled float (the “pretty” which-key) — a list where a row may be a CHUNK LIST
{ {text, hl_group?}, … } (neovim’s virt_text shape): each chunk paints its
text in hl_group, so a row can colour its key one group and its description
another, or dim a whole row with a Comment/dim group. Plain and chunk rows mix
freely; a plain row is just one un-grouped chunk. opts:
border = “none”|“single”|“rounded”|“double”|“solid” (default “rounded”)
title = a string drawn on the top border (optional)
relative = “cursor” (default, anchors at the cursor) | “editor” (centered) |
“bottom” (pinned to the editor’s bottom-right corner — the
which-key shape)
persist = when truthy, the float survives keystrokes (it is not dismissed by
the next key) and nx.ui.float returns a HANDLE with :update(contents,
opts) / :close() / :is_open(). This is the surface a key-observer
plugin (e.g. which-key) renders through, refreshing it as keys arrive.
Without persist it is fire-and-forget: the server owns the float, its
geometry, and its dismissal (the next key closes it); returns nil. Empty
contents open nothing. LSP hover and signature help use the non-persistent form.
Defined in ui.lua.
nx.ui.open(uri)
nx.ui.open(uri) -> a PROMISE of the opener’s exit result { code, stdout, stderr }
(the nx.run shape). Hands uri — a file path or a URL — to the OS opener chosen
by platform (nx._ui_opener: open on macOS, explorer on Windows, xdg-open
elsewhere) and runs it off-tick. Like nx.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.
nx.undotree
nx.undotree.get(bufnr)
No documentation comment in the prelude.
Defined in vimfn.lua.
nx.user_command
nx.user_command.create(name, command, opts)
nx.user_command.create(name, command, opts) [alias nvim_create_user_command]:
register a global :Name. command is a function or an ex-command string.
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. annx.asyncfunction) lists in the picker. A throw / rejection yields no candidates.
Defined in autocmd.lua.
nx.user_command.buf_create(buffer, name, command, opts)
nx.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; nx._resolve_user_command
consults both at dispatch.
Defined in autocmd.lua.
nx.user_command.get(_opts)
No documentation comment in the prelude.
Defined in autocmd.lua.
nx.user_command.buf_get(buf, _opts)
No documentation comment in the prelude.
Defined in autocmd.lua.
nx.utils
nx.utils.debounce(fn, ms)
nx.utils.debounce(fn, ms): wrap fn into a trailing-edge debounce over
nx.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 nx.async function as fn to kick awaitable work after
the quiet period, and reach for nx.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.
nx.view
nx.view.component(def)
nx.view.component(def) — the view-backed component (a focus-taking nx.view buffer): the
common case (file tree, list, modal dialog). Sugar over nx.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 nx.component above.
Defined in component.lua.
nx.view.actions.confirm()
No documentation comment in the prelude.
Defined in view.lua.
nx.view.create(opts)
nx.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 nx.view.on_restore, and the
plugin (which keyed its own nx.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
nx.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 nx.shada.plugin(dev_namespace) contract.
Defined in view.lua.
nx.view.pending_restores()
nx.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 nx.view.on_restore and the black-box test hook.
Refreshed each tick from core’s nx._view_pending mirror.
Defined in view.lua.
nx.view.on_restore(fn, namespace)
No documentation comment in the prelude.
Defined in view.lua.
nx.win
nx.win.current()
No documentation comment in the prelude.
Defined in api.lua.
nx.win.list()
No documentation comment in the prelude.
Defined in api.lua.
nx.win.set_current(win)
nx.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 nx.win.current() / current-buffer read
in the same chunk reflects the new focus.
Defined in api.lua.
nx.win.buf(win)
No documentation comment in the prelude.
Defined in api.lua.
nx.win.width(win)
No documentation comment in the prelude.
Defined in api.lua.
nx.win.height(win)
No documentation comment in the prelude.
Defined in api.lua.
nx.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 nxvim the
callback runs synchronously in-VM, where “current” is the mirror the server
pushed (nx._cur_win / nx._cur_buf / nx._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 nxvim 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 nxvim 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, nx._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 nx._assert_call_ctx). Plugins use these
calls to read a window’s view/dimensions, which is fully faithful.
Defined in api.lua.
nx.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 nx._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.
nx.win.saveview()
nx.win.saveview() [alias vim.fn.winsaveview]: the current window’s view — cursor
position, scroll (topline/leftcol), and the cursor-restore fields neovim
returns. nxvim has no separate curswant/coladd/skipcol state, so those
mirror col / are 0.
Defined in api.lua.
nx.win.set_topline(win, topline)
nx.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.
nx.win.set_leftcol(win, leftcol)
nx.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.
nx.win.set_cursor(win, line, col)
nx.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.
Defined in api.lua.
nx.win.restview(win, view)
nx.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.
nx.win.nr(arg)
nx.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.
nx.win.width_nr(nr)
No documentation comment in the prelude.
Defined in api.lua.
nx.win.height_nr(nr)
No documentation comment in the prelude.
Defined in api.lua.
nx.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.
nx.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.
nx.win.getid(winnr, _tabnr)
nx.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.
nx.win.findbuf(bufnr)
nx.win.findbuf(bufnr) [alias vim.fn.win_findbuf]: the ids of every window currently
displaying bufnr.
Defined in api.lua.
nx.win.gettype(winid)
nx.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.
nx.win.screenpos(winnr)
nx.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.
nx.wininfo
nx.wininfo.get(winid)
No documentation comment in the prelude.
Defined in api.lua.
nx.workspace
nx.workspace.dir()
nx.workspace.dir() -> the absolute workspace root (a string), or nil when this is not
a --workspace launch. Read-only — nxvim chooses the workspace from the command
line, not from Lua. For a daemon session this is the daemon’s directory.
Defined in nx.lua.
nx.workspace.active()
nx.workspace.active() -> true if this launch is a --workspace directory session,
false otherwise.
Defined in nx.lua.
nxvim architecture
nxvim 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. nxvim is its own editor, not a
neovim build: editing behavior tracks vim/neovim’s
observable behavior, but every API is nxvim’s own — configuration and
extensibility live in the nx.* Lua namespace
(the nx API;
ADR 0002) — and the vim.* that
remains is small and closed: a whitelist of muscle-memory aliases over nx
(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. nxvim 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: nxvim 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 nxvim’s own. - A native plugin system;
nx.*is the only API. Extensibility is nxvim’s own provider-based plugin API (thenxdesign, 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 againstnx.*. 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 samenxobjects, so config can be written in familiar muscle-memory terms — aliases, not an API; beyond them there is novim.*API. nxvim 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 nxvim’s own: a colorscheme is Lua that registers highlight groups through thenxhighlight 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
nxplugins. Everything that can reasonably be built as annx.*Lua plugin is built as one. nxvim’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 bundlednxplugins rather than as bespoke Rust. If a feature can’t be expressed againstnx.*, 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:
| nxvim crate | neovim counterpart | responsibility |
|---|---|---|
nxvim-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. |
nxvim-rpc | msgpack_rpc/ | Async msgpack-RPC transport (nxvim’s own protocol; msgpack is just the framing). |
nxvim-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 nxvim / nxvim-gui binaries; the --daemon role reuses it as the remote fs/process half of the edit-host split. |
nxvim-lua | lua/ | Embedded Lua runtime (vendored PUC Lua 5.4, the single backend) and the vim.* standard library. |
nxvim-tui | tui/ | The terminal UI client. A thin RPC client; owns no editor state. |
nxvim-ts | tree_sitter/ | The in-process treesitter engine: an ordinary library that loads installable grammars and parses incrementally, implementing nxvim-core’s SyntaxEngine trait. Heavy C deps (tree-sitter, libloading) live here only. |
nxvim-lsp | lsp/ | The native LSP client: protocol, transport, manager — nxvim’s own stdio spawning, driving the in-core editing features. nxvim-server/src/lsp/ is the editing-loop glue on top. |
nxvim-regex | regexp.c, regexp_nfa.c | The vendored vim regexp engine compiled as C, shared by search and :s (engine-global mutex). |
nxvim-view | (UI layer) | Frontend-neutral decode/input layer (View, Style, Key, notation, paste encoding) shared by the native clients. |
nxvim-gui | (a GUI frontend) | The native GUI client on winit + wgpu + glyphon; the GUI sibling of nxvim-tui, consuming the same View. |
nxvim-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. |
nxvim-test-harness | — | The shared black-box integration-test harness (a publish = false dev-dependency of nxvim and nxvim-server): spawns a real server over an in-process RPC pipe and drives it as a UI client would. See Testing philosophy. |
nxvim | the nvim entry point | Wires an embedded server + the TUI client together over RPC. |
Dependency direction is strictly one-way:
nxvim (bin)
/ \
nxvim-server nxvim-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 — nxvim and nxvim-server both onto nxvim-lsp, nxvim-lua
onto nxvim-ts (the treesitter query/control bridge), nxvim-gui onto
nxvim-server + nxvim-view, and nxvim-server onto nxvim-regex.
The treesitter engine is a normal crate dependency now: nxvim-server
constructs it and installs it on the editor (which owns a Box<dyn SyntaxEngine>
defined in nxvim-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.
nxvim-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 (nxvim-tui) │ ───── nx_input ─────────▶ │ Server (nxvim-server) │
│ • crossterm input │ ◀──── redraw events ─────── │ • nxvim-core (model) │
│ • paints the grid │ ───── nx_command ───────▶ │ • nxvim-lua (vim.*) │
│ • 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 nxvim 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 nxvim has a better answer for editing on another
machine: open an SSH session and run nxvim 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 nxvim --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 (nxvim --connect-daemon [file], or a nxvim://… URI for
the QUIC transport) is the same embedded editor as the default role, with its host
seams (nxvim_core::HostFs, nxvim_server::HostProc, the async HostFsAsync, the
LSP transport, and the Lua-facing nxvim_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 … nxvim --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 nxvim://… 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 (nxvim_gui::run’s session loop), 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: project-facing
fs APIs (vim.fn.readblob/glob/filereadable/executable/…) route
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 (nxvim_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 … nxvim --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 nx.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):
nxvim-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 (nxvim-server::run) multiplexes eight
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 and write-acks from the daemon fs
seam, :TSInstall grammar-build completions, and daemon file-watch events. 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
matching async actor (a Send background task) only ever ferries ids / bytes /
durations back, never the !Send editor or Lua state. This is nxvim’s analog of
neovim’s main-thread + worker-thread model, where workers hand results to the one
editor thread by enqueuing events — see
neovim’s threading model for the reference design.
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 (nxvim-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 (nxvim-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 nx_*: nx_input, nx_input_mouse, nx_ui_attach,
nx_ui_try_resize, nx_command. The editing-API methods keep the familiar
neovim spelling (nvim_buf_get_lines, nvim_open_win, nvim_win_set_cursor,
…) as muscle-memory names for config and Lua, but they are nxvim’s own methods
with nxvim’s own semantics.
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, 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
nx_ui_attach/nx_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 nxvim 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 nx.lsp.foldexpr marker, requested per buffer and pushed in). The generic-expr
and LSP are external sources: nxvim-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 (nxvim-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.
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 (nxvim-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, :undo {N} jumps to any seq across
branches, and vim.fn.undotree() projects the tree in neovim’s dict shape —
closer to neovim’s undo.c than the original two-stack model.
Buffers
The editor holds multiple open buffers and switches the one window between
them. nxvim-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. nxvim’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.
- 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.
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. nxvim 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). The Lua bindings follow the established “Lua queues, core mutates” flow: window reads resolve against thenx._winsmirror the server pushes before each Lua entry; window mutations queue aWindowOpdrained into the core after the chunk. - 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 (RPC and Lua, the latter viaWindowOp::OpenFloat); 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 — nxvim’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: thenx.dock.*Lua table (open{side,size?,buf?,title?,showtabline?,autohide?}/close/focus, plus the per-dock option scopenx.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.nx.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), andnx.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 nx._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), the
cursor-line highlight (cursorline), soft word-wrap (wrap, with its gj/gk
display motions, breakindent/showbreak), and the horizontal-scroll options.
Still pending: more window-local options (colorcolumn, …) 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
nxvim-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 nxlisting/nxbuffers/nxpanels/nxpanelmaps bindq/<Esc>tonx.panel.close, and a per-listing<CR>action (e.g.nx.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.
:messages,:registers,:marks,:jumps,:changesgo throughEditor::open_scratch_listing(name, lines, cursor)(filetypenxlisting);:ls/:buffersthroughEditor::open_buffer_listing(filetypenxbuffers, whose<CR>switches buffer); the named-panel list throughnxpanels. 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. The server routes its own messages (errors, capturedprint/nvim_echo) through the same call. - It’s scriptable. A plugin mounts its own panel with
nx.panel.open{ name?, lines, filetype?, height?, margin? }and dismisses it withnx.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(defaultnxpanel, 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-onlynxvim_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 asnx_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 nx.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 nx_input_mouse cell, with no
client-side geometry. (Command-line-mode mouse needs c in 'mouse'; the default
nvi omits it.)
Lua
nxvim 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
nx.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. nxvim resolves a config dir and
runtimepath the way neovim does ($NXVIM_CONFIG / $XDG_CONFIG_HOME/nxvim /
~/.config/nxvim, plus pack/*/start/* discovery and $NXVIM_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 nxvim-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; FS/env-touching helpers
(vim.fn.stdpath/getftime/mkdir, …) are Rust-backed. :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 nx 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
nx.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 nx.shada.plugin section and
examples/plugin-shada/.
The editor’s scripting namespace is its own: config files and plugins target
the nx.* API (ADR 0002;
the nx 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 nx.run, not a vim.system alias —
mapping 1:1 onto the nx 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 nx build-out: what serves nxvim’s objectives is refactored under
nx.*, the rest is deleted (see the roadmap).
Syntax highlighting (treesitter)
nxvim 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 innxvim-core, implemented bynxvim-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 innxvim-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 nxvim’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 nx.treesitter
nor vim.treesitter table at all (vim.treesitter.* is wholly 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:
nx.bo.filetypechooses the language,nx.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 (the web build, which has no Lua, drives it from the ex line). - Query customization is the native bridge
nx._nx_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). There is no;extends/after-queries/ runtimepath merge: base queries come from the engine’s data-dir files, an override replaces them.
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 nx.*). 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
nxvim 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 nxvim’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: nxvim-gui is a native GUI client
(crates/nxvim-gui) on winit + wgpu + glyphon. It is
the GUI sibling of nxvim-tui and reuses the same frontend-neutral
nxvim-view decode/input layer (View, Style, Key,
notation, encode_paste) — the seam the view crate was extracted for. The
nxvim-gui binary embeds a server on its own thread exactly like the default
nxvim binary, joined by the same in-process duplex RPC; the only difference from
the TUI is the client. winit owns the main thread (its loop is not async), so the RPC runs on a
separate IO thread that decodes each redraw into a View and forwards it to
the event loop via an EventLoopProxy, while 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, paced per frame from
winit’s about_to_wait (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, 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
nx_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). Still
deferred: wide-char column fidelity (a char index stands in for a screen column),
and undercurl is drawn as a plain underline.
Because the GUI can’t be black-box
tested over RPC the way the TUI’s paint is (it needs a GPU), only the pure,
frontend-specific translation layers have Tier-1 tests — the winit→notation input
(crates/nxvim-gui/tests/keys.rs), the pointer math
(crates/nxvim-gui/tests/mouse.rs), and the :connect target / nxvim:// URI / SSH
askpass parsing (crates/nxvim-gui/tests/remote.rs); the rendered frame, and the live
:connect session swap, are validated by running it.
The web build — a fully client-side WebAssembly editor
nxvim-edithost runs the editor in the browser, with no server
(crates/nxvim-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: nxvim-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 ofnxvim-tui’s layout, and translates a browserKeyboardEventto vim key-notation + mouse gestures toeh_input_mouse. It exposes awindow.__nxvimhook (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/nx.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 realnxvim --daemonover WebTransport (a JS msgpack-RPC client,web/rpc.mjs, reached with?daemon=nxvim://…). All three ride the same off-tickHostEffectsfs seam the native edit-host split uses; the Worker fulfills fs requests between ticks. - Lua config runs. Unlike the old core-only web build,
init.luais sourced at startup: options / keymaps / autocmds / user commands / highlights apply (requireof further modules / plugins does not — empty runtimepath). LSP and native treesitter are gated off the wasm build (:TSInstallfails loud); syntax highlighting is still present, done JS-side via web-tree-sitter (web/highlight.js+ the generatedweb/vendor/grammars). - 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/nxvim-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
nxvim 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/nxvim-server/tests/editing.rs) start a real server, connect over real RPC, send vim key-notation vianx_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/nxvim-tui/tests/) render a knownViewinto a cell grid via ratatui’sTestBackend(nxvim_tui::paint) and assert on the painted cells, and test the crossterm-KeyEvent→key-notation translation (nxvim_tui::encode_key) directly. Fast and fully deterministic — no process, no timing. - Tier 2 — full-stack screen (
crates/nxvim/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/nxvim/tests/e2e.rs) drive the actualnxvimbinary 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},vim.fn.undotree()), different storage. - In-process treesitter with installable grammars and incremental parsing —
like neovim, but kept off
nxvim-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 (
nx.*) — 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 (nx.picker, with a preview pane), the completion engine (nx.complete, buffer + lsp + snippets sources), the snippet engine (nx.snippet— LSP snippet-syntax parsing, the tabstop session with<Tab>/<S-Tab>navigation and mirrored placeholders; see the snippet plan), the statusline segment registry (nx.statusline— the lualine-shaped surface: built-in segments resolved natively each frame plus customnx.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 (nx.decor— off-tick providers woken once per visible-range change that publish generation-gated extmarks; v1 renders highlight (hl) marks only —virt_text/sign/concealare not yet exposed in the provider API; see the decor plan), the floating-widget UI layer (nx.ui.input/select/confirm/float, promise-based; see the content-float plan), and the tree docks (nx.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). Still ahead: the manifest loader / built-in package manager. -
Treesitter control.
:TSInstall/:TSUpdate/:TSInstallInfohave landed: the native arm fetches + compiles each grammar into the data dir off the editor thread (nxvim_ts::install, with a pinned checksum-verified Zig fetched on demand when no systemcc/clang/gcc/zig/$NXVIM_CCis found), and the browser arm fetches a prebuilt.wasmgrammar instead; a real nvim-treesitter plugin that registers:TSInstallshadows the native arm. 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 (nx.bo.filetype/nx.bo.ts_highlight, also reachable from:set), query customization is the native bridgenx._nx_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 remains the one deferred item on this axis. -
Window-local options. Multiple windows (splits, the layout tree, per-window view state, the
<C-w>family, and thenvim_win_*/ Lua API), 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 more window-local options (colorcolumn, …). -
The
nx.*config surface —init.luatargets nxvim’s own API (ADR 0002); the prelude’s current vim-shaped spelling is donor code, refactored undernxwhere it serves nxvim’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 innxvim-server/src/keymap.rs; multi-key built-ins fire instantly even under a colliding user prefix, via the shared command grammarnxvim_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: thenxvim-lspcrate (client, protocol, manager, transport) does nxvim’s own stdio spawning and drives the in-core editing features, andnx.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, thenx._notimplraises / runtimenx._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 handful nxvim honors (window-localnumber/relativenumber/cursorline/wrap+ the horizontal-scrollsidescroll/sidescrolloff, the buffer-local indentation options, and globalshowtablineare wired; many others are not) 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 thenxmodel: nothing in it blocks the editor, so the only prompt surface is the callback-shapedvim.ui.input/vim.ui.select.) 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(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; what remains here is macros and 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 (nx._buf_user_commands[bufnr][name], the command analogue of the buffer-localnx._keymaps):nx._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 vianx._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/nxvim-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 (thenx._cb_fnsregistry, the keymap-callback shape applied to async work).vim.scheduledefers to convergence,vim.defer_fnfires on wall-clock time, andvim.system’son_exitfires off-tick. 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 thenxmodel and is absent entirely. Async primitives are thenxAPI’s job (nx.run/nx.timer/nx.fs) — the existing timer/process machinery is the donor for those (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
nxvim 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 nxvim 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
nxvim: 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 nxvim 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 nx._notimpl_hits, so you
can see precisely which gaps you actually hit — not just which ones exist:
:lua print(vim.inspect(nx._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 nx._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
tree-sitter platform with start/stop, on-disk and customized queries,
injections, incremental updates, and :TSInstall fetch/compile — aren’t listed;
only the edges that still diverge are.)
- Treesitter — two edges remain. The
vim.treesitterplatform is built (see the platform design and ADR 0001). What still diverges: (1) the decoration-provider highlighterhighlighter.newfails loud (nx._notimpl) — nxvim’sstart/stopdrives the in-core Rust engine instead, so a highlight-onlystartnever builds a Lua-sideLanguageTreeandhighlighter.active[buf].treereads nil until something callsget_parser; (2) Lua-driven indent (indentexpr=v:lua…/indent.lua) is unwired — it wants the live buffer mid-keystroke, which fights the snapshot bridge, so the Rust indent stays.query.getreturns nil for a missing on-disk query file. - Treesitter query resolution — additive, host-only. 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. Two edges remain: (1) the merge is additive concatenation, not neovim’s full replace-vs-extend precedence; (2) it resolves only the buffer’s own language — an injected child grammar still loads its query off disk raw, so an; inherits:-based child (e.g.javascriptinjected into markdown) paints only its own non-inherited captures until that child language is itself opened as a buffer (which installs its resolved overlay). - No
vim.uv/vim.loop. neovim exposes libuv as a public Lua API; nxvim 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. Async lives in thenxAPI (nx.run/nx.timer/nx.fs); the synchronous host info the LSP-config paths need is read throughvim.fn(executable/exepath/glob/filereadable/resolve/ …) instead. - Broad options surface. The set of honored options has grown well past the
indentation knobs (the authoritative list is
crates/nxvim-core/src/options.rs).:set(and:setlocal/vim.bo/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/breakindent/showbreak/sidescroll/sidescrolloff/winhighlight/fillchars); the fold options (foldmethod/foldenable/foldcolumn/foldlevel); the buffer-local indentation options (tabstop/shiftwidth/softtabstop/expandtab/commentstring); and a set of nxvim-native options (scrollanim/scrollanimduration,qfdock,imagepreview,history/persisthistory,regexsyntax,switchbuf,laststatus/showtabline, …). nxvim 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 and<Tab>;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 — as are macros. - 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 (nxvim_ts::install), with a pinned, checksum-verified Zig fetched on demand when no systemcc/clang/gcc/zig(or$NXVIM_CC) is found — 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.- LSP semantic tokens approximations. Painted over the treesitter floor
(
crates/nxvim-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);highlight_tokenis a loud gap (nx._notimpl— a Lua callback on the decode hot path);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/nxvim-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. - Synchronous prompts — one caveat.
vim.fn.input/confirmreturn inline via a pumped coroutine (nx._pump), but only pumped entry points (:luachunk, keymap, user command) can prompt — Lua sourced at startup or off a bare callback has no coroutine to yield from. Seeexamples/sync-prompts/. nx.statuslinesegment registry — v1 deferrals. The lualine-shaped surface is built (nx.statusline.setup/segment/invalidate; built-ins composed innxvim_core::statusline::compose_segments, custom segments rendered per window and cached server-side; seedocs/plans/2026-06-15-nx-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:nx.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), andnx.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 |
|---|---|
| LSP helpers not window-arg-aware (always use the current window) | make_position_params(window) is now window-aware (reads the passed window’s buffer + cursor), and open_floating_preview returns real float handles (a relative="cursor" float over a scratch buffer, auto-closing on cursor move). Remaining: the completion-doc preview box is still a single bespoke box with no separate preview-window handle / completeopt matrix (the completion menu is server-owned chrome, not a window). Note: splits, floats, and tab pages themselves are implemented — see architecture.md Windows. |
| No multi-buffer name/disk registry | make_text_document_params (non-current bufnr → empty URI), locations_to_items & apply_workspace_edit for unopened files |
| Core honors a fixed set of buffer-local options | vim.bo / nvim_set_option_value writes other than filetype / tabstop / shiftwidth / softtabstop / expandtab / commentstring / regexsyntax / fileencoding / bomb are recorded but inert |
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. | vim.diagnostic.config keys other than underline / virtual_text / signs (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 nxvim-core’s text width, so a full-width line under nowrap can clip its last two cells). |
Verifying downloads
Every released nxvim 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 nxvim-0.2.0-x86_64-linux.tar.gz --repo davidrios/nxvim
A successful run confirms the artifact was produced by the nxvim 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 nxvim
# Gatekeeper assessment — "accepted" / "source=Notarized Developer ID" (needs network):
spctl -a -t exec -vv nxvim
The TUI binary is not stapled (Apple does not support stapling a notarization ticket to a
standalone executable), so the spctl check performs an online verification. A terminal
install (curl … | tar xz) sets no quarantine attribute and runs offline regardless.