Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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.lua and set options through nx.*.
  • 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 View protocols, the rope text model, and the Lua bridge.

This book is itself generated from the repository: the narrative chapters and the long-form architecture and plugin-authoring docs come from docs/, and the API reference is extracted from crates/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:

CommandAction
:PluginSyncInstall missing and update existing declared plugins
:PluginInstallClone any declared plugin not yet on disk
:PluginUpdateFast-forward every installed, unpinned plugin
:PluginCleanRemove cloned dirs no spec declares
:PluginListPrint a one-line status (installed / loaded / missing) per plugin
:PluginsWelcomeReopen 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

FeatureWhat it is
Multi-cursor modeHelix/Sublime-style multi-editing: drop N cursors in a dedicated placement mode, then have motions, operators, visual mode, and insert all act on every cursor at once.
Smooth scrollingViewport scrolls slide instead of teleporting (neoscroll.nvim built in), interpolated client-side so it stays smooth even over a remote link. On by default.
Image previewsOpen an image file and the picture renders inline — ratatui-image in the terminal, a GPU texture in the GUI, an <img> in the browser.

UI surfaces

FeatureWhat it is
UI primitivesA layered toolkit for plugin UIs — a Vue-shaped reactive component model (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 docksVSCode-style editable edge panels (file tree, terminals, problem lists) that are global across tabs, toggle independently of windows, and carry their own tabs and options.
Fuzzy pickernx.picker: a server-owned fuzzy finder with streaming Lua sources, live (dynamic) sources, a file/location preview pane, and fully rebindable keys.
Quickfix & named-list dock tabsThe quickfix list and named lists open as bottom-dock tabs by default ('qfdock') — several searches side by side, entries jumping into the main area — with the nx.qf.* sinks and the picker’s <C-q> / <Tab> multi-select. Location lists keep vim’s split behavior.

Platform

FeatureWhat it is
Native nx.* plugin APInxvim’s own Lua API where the server owns every UI surface and plugins provide data and behavior.
WorkspacesThe VSCode “open a folder” model: --workspace <dir> opens a directory as a persistent project session, restoring its layout/tabs/buffers and carrying per-workspace option overrides (nx.wso).
Browser editorThe full editor — core, the Lua VM, and the server tick — compiled to WebAssembly, running entirely client-side with no server.
Edit-host split (remote editing)Edit on a remote machine with zero typing lag: the editor and Lua run locally while an nxvim --daemon serves the filesystem, processes, and watching over ssh or QUIC.
Lua plugin testingnx.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
  1. Placement — enter with <A-c> (Alt+c). The status line reads MULTICURSOR and the active cursor is recolored amber. Motions move only that active cursor — you navigate around (including /-search) and drop secondary cursors at the spots you want.
  2. Editing — press <Esc> to leave placement. The dropped cursors stay, and the status line returns to NORMAL. Now motions and operators apply at every cursor at once. A second <Esc> collapses back to a single cursor.

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

Placement grammar

While in MULTICURSOR mode:

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

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

Editing with the cursor set

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

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

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

Per-cursor visual mode

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

Search clears the set (in Normal)

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

Undo

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

Custom keymaps while placing

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

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

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

How it works (in brief)

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

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

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

OptionDefaultMeaning
'scrollanim'trueAnimate viewport scrolls. :set noscrollanim snaps every scroll.
'scrollanimduration'160The longest a slide may last, in milliseconds. The per-scroll duration scales with the travel distance and is clamped to this ceiling; 0 disables animation entirely.
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 forWhat it is
A stateful UI that redraws itself (file tree, dashboard, dialog)nx.view.component / nx.componentA Vue-shaped reactive component: state + a pure render.
Plugin-owned content you update by hand (a list, a report)nx.viewAn 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 / confirmAsync prompt widgets — return a promise.
To pop up read-only content (a hint, a tooltip)nx.ui.floatA bordered content overlay, dismissed by the next key.
A real, editable window placed over the layoutnvim_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 the state value handed to render. It runs only after the surface is ready, so everything on ctx is valid immediately.
  • render(state) is pure: it maps state to what’s on screen and returns it. The framework re-runs it on every state change.

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

Two surfaces, one reactive core

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

  • "view" (the default, and what nx.view.component selects) — a focus-taking, navigable nx.view buffer, mounted in a dock, split, or grabbing float. render returns { lines, decor }. This is the file-tree / list / modal-dialog case; ctx gains keymap_set / line / set_cursor / bufnr / winid / bo / wo.
  • "float" — a non-focus popup-content float (the which-key surface). It never steals focus and binds no keys; render returns { lines, title?, relative?, border? } (rows may be styled chunks), and an empty render hides the float — so a component shows and hides purely by what it returns. Reach it with nx.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)
MethodDoes
:set_lines(lines)Replace the content wholesale.
:set_userdata(list)Opaque per-line data (1-based, parallel to the lines); the selected line’s entry is handed to on_select.
:on_select(fn)fn(line, userdata) on <CR> / confirm.
:set_decor(ns, marks)Replace namespace ns’s extmark decoration — each mark { line, col, <extmark opts> } (0-based), so hl_group / virt_text / sign_text / … all apply.
:mount(opts)Show it — { dock = … } / { split = "vsplit"|"split" } / { float = … } / { tab = true }.
:bufnr() / :winid()The backing buffer / showing window (live, from the mirror).
:line() / :set_cursor(n)Read / move the 1-based cursor line.
:place_in(win)Adopt a reserved restore slot (used by 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)prompt label + default prefill, over the command line. "" on an empty <CR>, nil on <Esc>.

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

    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]; false makes it decline and shows [y/N]. For a multi-choice menu use select.

  • 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 } (like nx.run, it resolves rather than rejects — a missing opener is code = -1).

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?

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

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

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

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

Floating windows directly

When you need a real, editable window over the layout — not a view or a widget — 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/:

ExampleShows
nxchecklistA modal checkbox dialog written with nx.view.component (reactive state + pure render).
nxviewA dockable nx.view content surface with <CR> → open-in-main.
which-keyA real which-key as a surface = "float" component over the pending-key oracle.
ui-promptnx.ui.input and nx.ui.confirm prompts.
ui-selectThe floating chooser, including items that carry data.
ui-floatPopup content (\f / \F), and LSP hover (K) through the popup window.
window-geometryThe shared size / align / margin vocabulary across every surface.
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.

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

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

Opening and closing

Drive docks from Lua or the ex-command wrappers:

-- side is "left" / "right" / "top" / "bottom"; size is columns (left/right) or
-- rows (top/bottom); buf is an optional existing buffer (default: a scratch).
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-commandDoes
:DockOpen {side} [size]Open (or resize/refocus) a dock
:DockFocus {side}Move focus to a dock
:DockClose {side}Close a dock, discarding its content
:DockToggle {side}Hide if shown, show if hidden — keeping content
:DockHide {side} / :DockShow {side}The two halves of toggle, addressed individually

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

Toggle vs. close

These are different on purpose:

  • :DockClose (and nx.dock.close) drops the dock and its content.
  • :DockToggle / :DockHide / :DockShow (and nx.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
OptionMeaning
sizeWidth (left/right) or height (top/bottom); settable live to grow/shrink
titleA fixed strip label, shown ahead of the dock’s tab cells
showtablinePer-dock override of the global option (0 never / 1 if >1 tab / 2 always)
laststatusPer-dock statusline override (0/1/2/3)
autohideCollapse the dock the moment focus leaves it; it pops back when you cross in
winhighlightPer-window highlight remap ("Normal:NormalSB,EndOfBuffer:Hidden") so a dock paints like a sidebar — see examples/dock-winhighlight

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

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

Per-dock tabs and tablines

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

Try it

A runnable playground ships in examples/dock:

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:

MapSource
<leader>fffiles — fuzzy file finder
<leader>fglive_grep — live grep
<leader>fbbuffers — open buffers (scoped to the focused layer, like :ls)
<leader>frresume — reopen the last picker where you left off

These are overridable defaults — your own map for the same key wins, and you can disable one by binding it to an empty function. To open any registered source from your own keymap, call 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):

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

Sending results to a list

<C-q> sends the picker’s current results to a named list keyed <picker>:<query> — 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 of item.path.
  • "location" — shows item.path scrolled to item.row / item.col (1-based) with the match range highlighted.

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

Open-time options

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

OptionMeaning
width / heightA fixed box size: a cell count (100) or a viewport fraction ("80vw" / "60vh" / "50%"). The picker is never content-sized.
align + marginPlacement, like a float ("top-left""center""bottom-right", default centered).
preview"file" / "location" / nil (no pane).
prompt_pos"top" (default) or "bottom" (telescope-style, input under the results).
debounceMilliseconds 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 = }:

FunctionEffect
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)
CallPurpose
nx.qf.list(name, items[, opts]) -> namecreate / replace the list name; repaints its tab if open
nx.qf.show(name) -> nameopen or focus the list’s dock tab
nx.qf.drop(name) -> nameclose its tab and forget the list

opts.title sets the dock-tab label (defaults to name); opts.action is "r" (default — replace in place), " " (push a new list onto the stack), or "a" (append). 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_loclist open it in a bottom split of that window; closing the owner closes the list. It never docks — 'qfdock' does not apply to it.
  • A named list is the dock-tab “save searches” surface. Its list lives in an editor-side registry keyed by name (not on a window), so it is addressed and re-shown by name, sits beside other named lists as its own dock tab, and outlives any window close.

All three jump the same way: a jump excludes the display window as its target, so it falls back to the main layer (always enumerated first), landing the file in the editing area rather than inside the dock. The picker’s <C-q> captures the matched item keys and the live query server-side, then (in Lua) builds a named list keyed <picker>:<query> via 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 workspacenxvim --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-namespace overrides the derived one.)
  • Enables session capture and restore with no plugin opt-in — the layout is saved on exit and replayed on the next launch.
  • Changes the working directory to the workspace root (by default; pass --workspace-no-cwd to keep the launch directory). A relative TARGET file then resolves against the workspace root.

The native GUI also exposes a 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 configinit.lua is read from the browser’s storage at boot and sourced through the real path, so options, keymaps, autocmds, user commands, and highlights all apply.
  • Files persist:e / :w open and save real files (see Files), surviving a reload.
  • Syntax highlighting — done JS-side via web-tree-sitter; :TSInstall <lang> fetches a prebuilt grammar on demand and caches it.

What’s different from native

In the browser
Pluginsinit.lua is one self-contained file — a require of further modules / plugins doesn’t resolve (the runtimepath is empty and storage reads are async).
LSPWorks both ways. A language server compiled to JS/wasm runs serverlessly in a Worker (the python demo ships basedpyright); a real native server runs over the daemon (WebTransport). Both ride the same off-tick LSP seam as native. With neither a wasm server nor a daemon, a configured server fails loud, not silently.
Native tree-sitterThe in-process parser is gated off the build; highlighting uses the JS-side web-tree-sitter path instead.
ProcessesServerless: blocking vim.fn.system always fails loud, and async vim.system / jobstart fail loud too — both need daemon mode (see Files).
HostingRequires 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 / :w persist to the browser’s Origin Private File System, and :e <dir> lists it. Your init.lua and shada live there too, so edits and config survive a reload.
  • Real local files. The File System Access API backs :eo / :wo (and a bare :w on a bound path) — pick a real file or directory on disk through the browser’s permission picker.
  • A real daemon. Open the page with ?daemon=nxvim://HOST:PORT/TOKEN?cert=HASH (the string nxvim --daemon --listen prints), or dial it at runtime with :connect nxvim://…, and :e / :w operate on the daemon’s filesystem over WebTransport (HTTP/3 / QUIC). Editing still happens entirely in the tab — only fs crosses the wire. Having no local disk, the browser is always remote-config: it runs the daemon’s config + plugins (fetched over the wire) 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.mjs is the single !Send thread holding core + Lua; it loads the wasm module (dist/eh.mjs) and runs the production tick. The UI thread (web/index.html) paints the server redraw frame as HTML/CSS — a per-cell-span DOM renderer (windows, gutter, status/tabline, pmenu, cursor shapes, smooth scroll) — and translates KeyboardEvents to vim key-notation. The two talk over postMessage and a shared ring.
  • One wait drives input and timers. When cross-origin isolated, the Worker parks on Atomics.wait over a SharedArrayBuffer input ring, waking on a keystroke or the next timer deadline — so nx.timer / vim.defer_fn fire without Asyncify, one mechanism. (Without isolation it falls back to a postMessage loop where timers don’t fire.)
  • Interop is emscripten ccall/cwrap, not wasm-bindgen — it links the C lua54 backend + vim-regex, so the final link is emcc. src/lib.rs exports eh_input / eh_exec_lua / eh_redraw_json / the fs legs / … and the redraw returns as JSON.
  • It’s agent-drivable. The page exposes a window.__nxvim hook (feed / mouse / execLua / lines / frame) so a headless browser (Playwright) can feed keys and assert on state — the same black-box style as the native test harness.
  • Built outside the workspace. It targets wasm32-unknown-emscripten and links C via emcc, so it sits in the root Cargo.toml’s [workspace] exclude (a host cargo build --workspace never touches it) and pins its own deps. Deployed as static files (netlify.toml); the one hard requirement is cross-origin isolation.

See also

The edit-host split

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

The edit-host split moves the network boundary below the editor instead. The editor and Lua run locally; only an 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 async HostFsAsync, the LSP transport, and the Lua-facing LuaFs — 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, undoOpening / saving files and the file explorer
The Lua VM and the redrawProcesses — 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.
  • :reconnect re-dials now (and resets the retry budget); :disconnect drops the link on demand. Both are server-side ex-commands, so they work on the TUI too.
  • Status is a first-class API: nx.daemon.status() returns "connected" / "reconnecting" / "disconnected" (or nil for a local session), and a User DaemonStatusChanged autocmd fires on every change — so a statusline component can render it (green / yellow / red). See examples/daemon-status.
  • On reconnect the editor re-syncs what a fresh daemon doesn’t know about: it re-opens LSP servers, re-arms file watches, and re-stats open files — a file changed while the link was down is detected (an unmodified buffer autoreloads; an edited one is flagged as a conflict). Remote terminals/jobs do not survive a drop (the PTYs die with the link) and are surfaced as exited; reopen them with :terminal.

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

Auth & identity (QUIC)

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

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

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

How it works (in brief)

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

See also

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.

PluginWhat it isRead it to learn
nxvim-lspconfigReady-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-treeDockable 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-lineLualine-style statusline (sections az, 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-helperLive 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-dapDebug 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-diffMeld-style side-by-side diff viewerRead-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-helpVim-style :helpA 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.editorconfig support: an async nx.fs directory walk, its own glob matcher, and option application driven by autocmds.
  • plugins.lua + plugins_ui.lua — the nx.plugins package manager and its dashboard: declarative specs, async git over nx.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:

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

Because Lua influences the editor through the same queues RPC clients use, every 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 neovimFirst-class content surfaces: nx.view in an nx.dock, nx.component for reactive ones
Decoration providernx.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 / envvim.g / vim.b / vim.w, vim.o / vim.opt / vim.opt_local / vim.bo / vim.wo, vim.env.
  • Dispatch & keymapsvim.cmd, vim.keymap.set / del.
  • Pure helpersvim.tbl_*, vim.split, vim.trim, vim.startswith / endswith, vim.list_extend, vim.deepcopy, vim.inspect, vim.json.
  • Declarative registrations — a partial vim.api of nvim_create_autocmd / augroup / del / clear (→ nx.on), nvim_create_user_command (→ nx.command), and nvim_set_hl (→ nx.hl.define), plus vim.filetype.add.
  • Callback-shaped asyncvim.notify, vim.schedule, vim.defer_fn, vim.ui.input / select, and vim.system in its callback form.
  • Treesitter highlight togglevim.treesitter.start / stop, mapping to the nx.bo.filetype / nx.bo.ts_highlight buffer 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 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):

  • Keymapsnx.keymap.set(mode, lhs, rhs, opts) / nx.keymap.del; introspect with nx.keymap.get. Always pass a desc — it surfaces in completion and which-key.
  • User commandsnx.command(name, fn, { desc = …, usage = …, complete = … }); fn receives { args, fargs, bang, … }. usage is the argument signature in vim help notation (usage = "[file]", "{name}") — it heads the command’s :-completion docs pane as :Name <usage>, exactly like a built-in. complete ("file" / "dir" / a fn(args)) drives <Tab> completion of the argument.
  • Autocmds / eventsnx.on(event, { pattern = … }, fn) for editor lifecycle events (BufReadPost, FileType, …).
  • Options & vars — read/write nx.o (global), nx.bo (buffer), nx.wo (window), and nx.g (globals). Edge docks have their own scope too: nx.dock.opt(side) (e.g. nx.dock.opt("left").size = 32), alongside nx.bo/nx.wo — per-dock showtabline, laststatus, size, title, winhighlight, and autohide.
  • Highlightsnx.hl.define(ns, name, spec), nx.hl.get, nx.hl.exists. Define your groups as fallbacks (nx.hl.exists guard) so a colorscheme that already styles them wins.
  • Messagesnx.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 primitives nx.schedule (end of the current tick) / nx.on_next_tick / nx.wait_for(pred, opts) (across ticks). Reach for on_next_tick/wait_for — never a nx.schedule re-arm — when waiting on state that only refreshes between ticks (e.g. a freshly-mounted window id). Full guide: Async & promises.
  • UI — the floating-widget layer nx.ui.input/select/confirm/float (promise-based, never steals focus for float), and nx.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), and nx.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

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

nx async is promise-only. Every “do a thing, get a result later” API returns a promise — never takes a callback. Streaming is an async-iterator over promises. (The vim.* muscle-memory aliases keep neovim’s callback shapes; new nx code 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:

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

Ready-made promises: 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 :next callback runs as a microtask (deferred to the end of the current tick via nx.schedule), never inline — even on an already-settled promise. Same as the browser running .then off the stack.
  • Unhandled rejections are reported, not swallowed. A promise that rejects with nothing subscribed surfaces a loud nxvim: unhandled promise rejection: … (CLAUDE.md: no silent failures). Attach a :catch — or handle it via await.

async / await

Chains are fine for one step, but nest badly. 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.await must be called inside an nx.async function (there’s nothing to suspend otherwise — it errors loudly). It’s also what setup / render run inside on an nx.component, so you can nx.await(...) straight inside those too.

The async APIs you get

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

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

CombinatorSettles whenWith
all(list)all fulfil (or any rejects)the array of values, in input order
all_settled(list)every one settlesan array of { status, value } / { status, reason } (never rejects)
race(list)the first settlesthat outcome (fulfil or reject)
any(list)the first fulfils (or all reject)the first value (else an aggregate error)
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:

PrimitiveRuns fnUse 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 tickobserve 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 valueawait a cross-tick condition

The cross-tick gotcha. nx.schedule runs within the same convergence, so it cannot observe anything that only refreshes between server ticks — a freshly-mounted window’s id, a server-repopulated mirror. Re-arming nx.schedule(self) to “wait” for such a value spins forever. For anything cross-tick use nx.on_next_tick or nx.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 invocation ms after the last (which-key’s show-delay, on-change/resize/scroll reactions). Callback-shaped, not a promise — a different job — but they compose: pass an nx.async function as fn to 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 a vim.*-style callback API into the promise world.
  • nx.schedule_wrap(fn) — a function that, when called, defers fn to the end of the tick — the nx.schedule analogue of a wrapped callback.

Try it

ExampleShows
async-runtimeThe bare loop — nx.schedule vs nx.timer, and a self-rescheduling timer firing on wall-clock time while the editor stays responsive.
ui-pickernx.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

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.testdescribe / 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

CallMeaning
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)):

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

The context t

Driving methods are async — they settle before returning:

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

Read methods are plain (correct after an await):

MethodReturns
t:lines([first, last]) / t:line(n)Buffer lines.
t:cursor(){ row, col }.
t:mode() / t:mode_info()The mode code ("n", …) / the full table.
t:current_line()The cursor’s line.
t:keymaps([mode])The defined maps (maparg shape).
t:float()The content float — { text, lines, title } — or nil.
t:message() / t: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 with nx.fs to 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

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

EventWhen it firesNotes
BufReadPostA 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.
BufNewFileA buffer is opened for a path with no file on disk — fires instead of BufReadPost.buf / file set.
FileTypeA buffer is first announced and whenever its filetype changes.match is the filetype (e.g. "rust"); file is the path. Where ftplugins and vim.lsp.enable attach.
BufEnter / BufLeaveA buffer becomes / stops being the current one (including plain switches with no read).buf / file set.
BufDeleteJust 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) → FileTypeBufEnter.

Writing

EventWhen it firesNotes
BufWritePreBefore 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.
BufWriteSame point as BufWritePre — the bare-name spelling.
BufWritePostAfter a successful write.The hook format-on-save and “reload affected tools” plugins use.

Window & tab

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

Mode

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

Editing & cursor

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

EventWhen it fires
TextChangedThe buffer’s text changes in Normal mode (edit, paste, …).
TextChangedIThe buffer’s text changes in Insert mode (per keystroke).
CursorMovedThe cursor moves in Normal mode.
CursorMovedIThe cursor moves in Insert mode.

LSP

EventWhen it firesNotes
LspAttachA language server attaches to a buffer.data = { client_id = … }.
LspDetachA language server detaches from a buffer.data = { client_id = … }.

Files & environment

EventWhen it firesNotes
FileChangedShellA loaded file changed on disk (the watch/checktime reconcile).A handler may set vim.v.fcs_choice to "reload" / "edit" / "ask".
FileChangedShellPostAfter the file-change reconcile completes.
DirChangedThe working directory changes (:cd / :lcd / :tcd).file is the new cwd; match is the scope.
ColorSchemeA colorscheme finishes loading.match is the colorscheme name. This is what colorscheme plugins hook.

Startup

EventWhen it firesNotes
VimEnterOnce, after the editor has finished starting (config sourced, first frame imminent).vim.v.vim_did_enter is 1 from this point on. The built-in plugin manager’s first-run prompt hooks it.

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.

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 (call nx.await(...) straight inside it).
  • setup(ctx, props) — OPTIONAL; runs ONCE on mount and owns every side effect — it creates reactive state, subscribes to events, binds keys, fetches data — and RETURNS the state value handed to render. Runs only after the surface is ready, so everything on ctx is already valid (no tick-dance). May be async.
  • surface"view" (default) or "float"; or pass backend (a function(opts) -> adapter) to render to a custom surface.

The ctx handed to setup carries the reactivity and lifecycle:

  • ctx.reactive(tbl) — a deep reactive proxy; writing any key (s.x = 1) schedules a re-render. Iterate with ipairs / # (NOT pairs — PUC 5.4 has no __pairs).
  • ctx.computed(getter) — a cached derived value, read as c() or c.value; it re-evaluates only when a reactive input it read last time has changed.
  • ctx.refresh() — force a re-render. ctx.props — the opts.props from mount.
  • ctx.on_close(fn) / ctx.close() — register a teardown hook / close the instance. On the “view” surface ctx also gains: ctx.view, ctx.bufnr(), ctx.winid(), ctx.line(), ctx.set_cursor(n), ctx.bo / ctx.wo (the view’s buffer/window-local option tables), and ctx.keymap_set(mode, lhs, rhs, opts) (buffer-scoped + nowait by default).

Surfaces — what render returns, and how the surface behaves:

  • “view” — a focus-taking, navigable nx.view buffer (dock / split / grabbing float): the file-tree / list / modal-dialog case. render returns { lines, decor } (or a bare line list). nx.view.component(def) is the sugar.
  • “float” — a NON-focus nx.ui.float content float (the which-key surface): never steals focus, binds no keys. render returns { lines, title?, relative?, border? }; an EMPTY render HIDES the float (a later non-empty one re-opens it), so a component shows/hides purely by what it returns. Only one float component may display at once (a second fails loud rather than clobbering the single content-float slot).

mount(opts) instantiates and returns the instance (with :close()). opts.props is passed to setup; the rest configures the surface — view: name / filetype / dock / split / float (and eob to keep end-of-buffer tildes); float: title / relative / border. Render errors and setup errors are caught and surfaced via 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.storenx.shada.plugin(ns) for the resolved owner namespace: an isolated, cross-session key/value slice. Read saved state in setup, write it on every change.
  • ctx.namespace — the resolved owner namespace; ctx.persist_id — the stable id. (examples/view-persist/ is a runnable pinned-notes plugin built on exactly this.)

Example — a live-updating counter in a floating view:

local Counter = 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

  1. 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 does nx.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 / :augroup ex-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; OR command — an ex-command string queued instead. Provide one of the two.
  • pattern — a glob (or list of globs) the event’s match string is tested against (e.g. "*.lua", { "*.c", "*.h" }). Omitted / "*" matches all.
  • group — an augroup, by numeric id or by name (see nx.augroup.create). Ties this autocmd to the group so a later clear of that group drops it.
  • buffer — make it buffer-local: it then fires only for that buffer (and pattern is ignored). 0 resolves to the current buffer at registration time.
  • once — fire once, then auto-remove. desc — a human description.

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); false is a single-choice picker (no marking).
  • debounce — ms before a dynamic source re-runs on a query edit; 0 off.
  • layer — where a confirmed item opens: “main” crosses back to the main editor area first (so a file picked while focused in a dock lands in the editor, not the sidebar), “active” opens in the focused layer. Defaults to “active”; the shipped files/live_grep sources set “main”, buffers stays “active”.

Defined in picker.lua.

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/:edit use;
  • a function fn(args) — generate candidates dynamically. args is the list of whitespace-separated argument words typed so far, the last being the partial word under the cursor (:Cmd <Tab>{}, :Cmd a<Tab>{"a"}, :Cmd a b<Tab>{"a","b"}). It returns a list of candidates — each a string, or a table { label =, insert =, doc = }. A SYNC function (returns a list) shows inline in the wildmenu and is re-run as you type; an ASYNC one (returns a promise, e.g. an nx.async function) 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

  1. Editing compatibility first. Keystrokes, modes, ex-commands, and options should match vim/neovim’s observable behavior. When in doubt, the reference in vendor/neovim is the source of truth. Note: nxvim does not aim for neovim UI/client wire-compatibility — there is no ext_linegrid protocol and external neovim GUIs are not a target. The client↔server protocol is nxvim’s own.
  2. A native plugin system; nx.* is the only API. Extensibility is nxvim’s own provider-based plugin API (the nx design, ADR 0002): the server owns every UI surface and the frame; plugins supply data and behavior, asynchronously. Configuration is the same namespace: init.lua is written against nx.*. A closed whitelist of muscle-memory aliases (vim.g, vim.o/vim.opt, vim.cmd, vim.keymap.set, autocmd / user-command / highlight registration, vim.notify, the pure vim.tbl_*-style helpers, … — the canonical list is ADR 0002) maps 1:1 onto the same nx objects, so config can be written in familiar muscle-memory terms — aliases, not an API; beyond them there is no vim.* 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 the nx highlight API (and its vim.* aliases), nothing more. Supporting legacy Vimscript (.vim plugins, the eval.c language) is likewise an explicit non-goal.
  3. Dogfood the plugin API: first-party features are nx plugins. Everything that can reasonably be built as an nx.* 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 bundled nx plugins rather than as bespoke Rust. If a feature can’t be expressed against nx.*, that is a gap in the API to close, not a reason to reach behind it — so the API is proven by the editor that depends on it. The line is the one the architecture already draws: Rust owns the pure core, the frame / renderer, and the native engines (treesitter, LSP, regex) — a Lua plugin can’t be the renderer or the synchronous core; the orchestration, UX, and behavior composed on top of those primitives are Lua. “Makes sense” is the only escape hatch, and it means a genuine engine/frame/perf constraint, not convenience.
  4. Client-server, always. The editor is a headless server; every UI is a client. There is no “embedded-only” code path.
  5. Async and responsive. The UI never blocks on the editor and the editor never blocks on the UI. Slow work on one side cannot freeze the other.
  6. Rust-native, not a transliteration. We mirror neovim’s organization and behavior, not its C. We use a rope, ownership, enums, async tasks, and crates instead of globals, longjmp, and libuv callbacks.

Crate layout

The workspace is split into crates that map onto neovim’s src/nvim/ subsystems:

nxvim crateneovim counterpartresponsibility
nxvim-corebuffer.c, normal.c, ops.c, edit.c, ex_docmd.c, undo.c, option.cThe editor model: buffers, modes, motions, operators, ex-commands, undo, and the renderable View. Pure & synchronous.
nxvim-rpcmsgpack_rpc/Async msgpack-RPC transport (nxvim’s own protocol; msgpack is just the framing).
nxvim-servermain.c, event/, api/The headless server: owns the core + Lua, hosts the nvim_* API, runs the async main loop. A library, embedded on its own thread by the nxvim / nxvim-gui binaries; the --daemon role reuses it as the remote fs/process half of the edit-host split.
nxvim-lualua/Embedded Lua runtime (vendored PUC Lua 5.4, the single backend) and the vim.* standard library.
nxvim-tuitui/The terminal UI client. A thin RPC client; owns no editor state.
nxvim-tstree_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-lsplsp/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-regexregexp.c, regexp_nfa.cThe 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-edithostThe 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-harnessThe 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.
nxvimthe nvim entry pointWires 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::connect spawns independent reader and writer tasks, so encoding, decoding, and socket back-pressure never block the consumer.
  • The client multiplexes terminal input and incoming redraws with tokio::select!. Keystrokes are sent the instant they arrive; redraws are painted as they come.
  • The server processes one RPC message at a time against the (non-Send) editor and Lua state, while the RPC tasks keep the wire moving underneath it.

The editor and Lua state are intentionally !Send and live on a single thread, which is why the server gets its own thread/runtime rather than being spawned onto a shared pool.

Multi-source scheduling & event ordering

The server’s tokio::select! loop (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_effectsrun_pendingredraw) 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-core is pure/sync) and the !Send VM rather than by neovim’s runtime recursive-abort.
  • Per-source order (FIFO). Each channel delivers in arrival order, and the per-arm coalescing (while try_recv()) drains a burst in order before one repaint.
  • Cross-source order (divergence). When events from different sources are ready in the same poll, plain tokio::select! picks a ready branch pseudo-randomly. Neovim’s parent/child MultiQueue instead imposes a deterministic relative order by enqueue time. We accept the weaker guarantee: the random pick buys anti-starvation fairness, and because every arm fully settles, the nondeterminism is limited to which independent source lands first — never to interleaving or corruption. A timer-vs-keystroke wall-clock race is inherently timing-dependent in neovim too.

The biased; option (possible future change). tokio::select! accepts a leading biased; that switches branch selection from random to top-to-bottom in declaration order. Adding it would make cross-source scheduling deterministic — the closest analog to neovim’s multiqueue ordering — and turn the arm declaration order into an explicit priority (e.g. input first, so keystrokes are never delayed behind background timers/LSP). It is intentionally not enabled today because:

  • the current random selection is the simpler default and provides fairness for free, and no observed workload depends on cross-source ordering (each arm settles independently, so the relative order of background sources carries no correctness dependency);
  • biased; makes the developer responsible for starvation — a branch that is perpetually ready (e.g. a sustained input flood, or a tight self-re-arming timer) would starve every lower-priority branch, whereas random selection cannot. Our per-arm burst-coalescing bounds this in practice, but it is a real footgun the default avoids.

Adopt biased; if a future need arises: a reproducibility requirement (a test or behavior that must see input drained before a same-tick timer), or a responsiveness bug where background work visibly preempts input. The change is one line plus a deliberate arm ordering — recommended order input → LSP → loop (timers/processes) (user-facing first) — and must be paired with a starvation review of the now-highest-priority arm.


Protocols

RPC framing (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 styles palette — an array of resolved styles { fg, bg, sp, bold, italic, … } with colors as 24-bit 0xRRGGBB ints, deduped so identical styles cost one entry;
  • the per-row highlights array (aligned with lines) of screen-column spans [start, end, group, style_id], where group is the treesitter capture name and style_id indexes styles (or is nil when no colorscheme resolved it);
  • a chrome map of editor-region → style_id for the text background (Normal), the number gutter (LineNr, CursorLineNr), the cursor line (CursorLine), the visual selection (Visual), search (Search, IncSearch), the status line (StatusLine), errors (ErrorMsg), the end-of-buffer filler (EndOfBuffer), and float chrome (FloatBorder, NormalFloat, FloatTitle).

The server still owns which cells are in a group (byte offsets resolved to screen columns via the same tab/wide-char virtcol the selection uses); it now also resolves group → style. The client is a dumb truecolor renderer: it paints the Normal background across the text area, themes the gutter/selection/status from chrome, and colors each span from its style_id. When a span carries no resolved style (no colorscheme loaded), the client falls back to a small built-in theme, so default startup looks exactly as before. (See Syntax highlighting.)

The same split governs the number column: each WindowView carries the per-row 1-based buffer line numbers (numbers, None for ~ filler rows), the number/relativenumber option flags, and the gutter width (number_width, sized like vim’s numberwidth). number/relativenumber are window-local — a WindowOptions lives on each window, set via :set/:setlocal/vim.wo, and a split inherits them from the window it splits off — so two windows onto the same buffer can show different gutters. The core owns what each line’s number is; the client renders the gutter as its own ratatui widget — a horizontal split off the left of the text area — and decides how it looks, computing the relative offsets and the hybrid absolute-on-cursor-line formatting from that data. Text, selection, and cursor columns are all measured from the text sub-area, so they stay gutter-agnostic.

The client owns chrome layout, but the window rects come from the core. The client paints each WindowView at its rect — splitting off that window’s gutter, drawing its text/selection/search, and a status line on its bottom row — then draws the separators between splits and reserves the bottom row for the global command/message line (the panel, being an ordinary bottom-split window, is just another WindowView). The terminal cursor is drawn only in the focused window. Because the core lays out the windows (vertical splits divide width), the client reports both dimensions of the windows area on 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 an OpenBuffer (the text Buffer plus its branching undo tree and the cursor/scroll position saved while the buffer is not current), stored in a BufferStore keyed by a monotonic, 1-based BufferId that is never reused.
  • Window state (the “view”): the live cursor, scroll top, mode, and pending-input state stay on Editor, alongside current (the shown buffer) and alternate (vim’s #). The register and the search options are still global, but options come in three scopes, mirroring vim:
    • The indentation options (tabstop / shiftwidth / softtabstop / expandtab) are buffer-local — a BufferOptions lives on each Buffer, set via :set/:setlocal/vim.bo, so two buffers can indent differently. nxvim’s defaults differ from vim’s: tabstop is 4, and shiftwidth/softtabstop follow it via their 0/-1 sentinels (softtabstop → shiftwidth → tabstop), so one knob sets the indent width.
    • The number-gutter options (number / relativenumber) are window-local — a WindowOptions lives on each window, set via :set/:setlocal/vim.wo (and nvim_win_{get,set}_option / scoped nvim_{get,set}_option_value), and a split inherits them from the window it splits off, so two windows onto the same buffer can show different gutters.

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: an HSplit stacks children (dividing height, a separator row between each), a VSplit places them side by side (dividing width, a column between). sizes are normalized to cells on every layout, so resizing is plain cell arithmetic and a terminal resize rescales proportionally. Each leaf’s text height is rect.height - 1 (its own status line).
  • Surface. Splits: :split/:vsplit/:new/:vnew and <C-w>s/<C-w>v. Focus: <C-w>h/j/k/l (spatial), <C-w>w/<C-w>W (cyclic). Move: <C-w>H/J/K/L swaps the focused window’s buffer (and its view) with the spatial neighbor on that edge, focus following the buffer (swap_window_dir). Close: <C-w>c/ :close, <C-w>o/:only, :hide, <C-w>q/:q. Sizing: <C-w>=, <C-w>+/-/</> (with counts), <C-w>_/<C-w>|, :resize/:vertical resize. focus_window is the window analogue of the buffer switch: it stashes the outgoing view, restores the incoming one (cursor re-clamped), and clears transient state.
  • RPC / Lua. nvim_list_wins, nvim_get_current_win/nvim_set_current_win, nvim_win_get_buf/set_buf, nvim_win_get_cursor/set_cursor (window-handled, 0 = current), nvim_win_get_width/height + setters, nvim_win_close, nvim_win_get_config/nvim_win_get_position, and nvim_open_win (both the split form and the float form). The Lua bindings follow the established “Lua queues, core mutates” flow: window reads resolve against the nx._wins mirror the server pushes before each Lua entry; window mutations queue a WindowOp drained into the core after the chunk.
  • Floating windows. A float is a Window the layout tree does not own: it lives in WindowTree.floats (ids kept sorted by (zindex, id)), carries a FloatConfig (relative editor/win/cursor, anchor, row/col, width/ height, zindex, focusable, border, title), and is positioned absolutely by a second layout() pass after the tiled rects are known — so it steals no space from its siblings and paints on top. nvim_open_win with a non-empty relative opens one (RPC and Lua, the latter via WindowOp::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 off WindowId. nvim_win_set_config/get_config move, resize, restyle, and convert a window between float and split. Unsupported config values (relative="mouse", an unknown border) fail loud rather than silently falling back to a tiled split. Edge semantics (matching neovim): :q on a focused float closes only the float and never quits — the “last window” quit rule counts tiled windows only, so closing the last tiled window quits even with floats open, and a tiled window can’t be closed down to floats-only. :only/<C-w>o close every float too. <C-w>w/<C-w>W cyclic focus includes focusable floats (in z-order, after the tiled windows) and skips non-focusable ones, though nvim_set_current_win can focus either explicitly; the spatial <C-w>h/j/k/l moves stay within the tiled grid. Closing a window also closes every float anchored to it (relative="win", transitively). A terminal resize re-runs the float pass, re-clamping editor-relative floats back on-screen. The lifecycle diff fires WinNew/WinEnter/WinClosed for floats and WinResized when set_config changes a float’s size.
  • Permanent docks. A dock is a global (cross-tab) editable window region pinned to a screen edge — 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 on Editor::windows (the rest park per Tab pages above), so split/close/focus/editing/redraw act on whatever layer holds focus with zero retargeting. <C-w><C-w> is the layer-switch prefix: <C-w><C-w>{h,j,k,l} crosses focus spatially between the regions — full-width top/bottom bands around a left|main|right middle band — stepping to the open region in that direction and wrapping past the far edge (cross_dir_candidates/cross_dir_target; a no-op when nothing in that direction is open), <C-w><C-w>{H,J,K,L} moves the focused buffer to the layer on that edge (the dock on that side — a no-op if it is closed — or back to main from a dock; the source window falls back to a sibling/empty buffer of its own layer via move_buffer_to_layer), and <C-w><C-w>{cmd} crosses then runs the command, while a single <C-w> stays in the focused layer. The layer-switch chord is mode-independent — a small state machine (Editor::dock_chord_intercept, run ahead of the per-mode dispatch in input) reaches the docks from insert, visual, command and terminal mode too, leaving the source mode cleanly before it crosses; only Normal/MultiCursor keep the grammar’s own <C-w> path (which also owns the single-<C-w> window commands). A lone <C-w> in those modes is held one key and replayed on a non-chord follow-up, so its original meaning is preserved. The round trip is mode-transparent: every cross parks the source window’s mode (Window::resume) and the target window resumes its own parked mode (execute_window_layerenter_windowreestablish_mode), so popping over to a dock and back lands you in the same insert/visual/terminal state you left, the cursor where it was — while ordinary window/tab/mouse focus changes still land in Normal (the resume is gated on a one-shot the chord sets). relayout carves the four edge bands (each reserving a separator cell toward the main area, clamped so the main rect keeps ≥1 col/row) and lays every layer’s tree out at origin (0,0) in its own region; View carries the band sizes and tags each window with its WindowRegion, and each client maps region → absolute origin (the core owns which cells, the client owns where). Surface: the nx.dock.* Lua table (open{side,size?,buf?,title?,showtabline?,autohide?} / close / focus, plus the per-dock option scope nx.dock.opt(side)) and the :DockOpen/:DockClose/ :DockFocus ex-commands, queued as a DockOp drained into the core. Mouse: hit_test resolves a click across every region (the focused layer plus each parked dock tree, via region_geoms), so a left-click in any dock focuses it and places the cursor — set_current_window crosses to that layer first. A dock can also be hidden (toggle / auto-hide): dock_hidden[side] collapses it from view while keeping its whole TabStack parked, so its splits/tabs/cursor/text all return when shown again — distinct from closed (which drops the trees). dock_is_open is the visibility predicate (= present and not hidden) that every layout / render / mouse / enumeration site reads, while the tree-resolution helpers read dock_tabs directly so a hidden dock’s content stays addressable. nx.dock.toggle/hide/show (and :DockToggle/:DockHide/:DockShow) drive it; the per-dock autohide option hides a dock the moment focus leaves it (a hook in switch_layer, the one chokepoint for every focus cross). A hidden dock isn’t invisible: View.hidden_docks carries a label per collapsed dock, which each client paints as a clickable ▸{label} chip on the idle command-line row (hidden_chip_at maps a click back to show_dock). The buffer list is per-layer: each buffer carries the window layer it was last shown in (OpenBuffer::layer, set by set_cur_buffer/set_window_buffer), so :ls and :bnext/:bprev list only the focused region’s buffers, closing a document falls back to a sibling in the same layer (never pulling a dock’s buffer into the main area), and nx.buf.list{focused=true} exposes the focused-layer list to Lua (nvim_list_bufs stays global). (Design: docs/plans/2026-06-14-permanent-docked-panels.md, docs/plans/2026-06-14-dock-toggle-autohide.md.)
  • Autocmds. WinNew/WinEnter/WinLeave/WinClosed/WinResized fire from the same server-side lifecycle diff as the buffer events, ordered WinLeave → BufLeave/BufEnter → WinEnter around a focus change.
  • Shared per buffer. Two windows onto one buffer share its SyntaxState, diagnostics, and undo — each just projects a different (top, height) slice. The register, command line, and message line stay global; the number-gutter options (number / relativenumber) are window-local (a WindowOptions per window, set via :set/:setlocal/vim.wo).

Horizontal scrolling rides WindowOptions too: each window tracks a leftcol (the first visible screen column, the horizontal analog of the vertical top) and, under nowrap, scrolls sideways to keep the cursor visible — governed by the window-local sidescroll / sidescrolloff. The core decides leftcol (ensure_visible_horizontal, called on the same beat as the vertical ensure_visible); the client paints from that offset, leaving the number gutter fixed. (Design: docs/plans/2026-06-07-horizontal-scrolling-and-wrap.md.)

Tab pages multiply the window layout the same way BufferStore multiplied the buffer — and they do it per region: the main area and each open dock (see Permanent docks below) carry their own independent tab stack. Each layer owns a TabStack { tabs: Vec<TabSlot>, current }, so Editor holds main_tabs plus dock_tabs: [Option<TabStack>; 4]. The focused layer’s active tab is the live WindowTree on Editor::windows; every other (layer, tab) tree parks in its TabSlot, so across the whole editor exactly one slot is None (the live one) — the same invariant tabs always kept, now spanning two axes. A switch (gt/gT/:tabnext/nvim_set_current_tabpage) is a mem::swap of the live tree with the target’s stash, then re-enters the incoming tab’s focused window — the tab analogue of focus_window, so the entire editing machine is untouched. :tabnew/:tabedit/<C-w>T create one (window ids minted globally off Editor::next_win_id so they never collide across any layer/tab); :tabclose / :tabonly refuse main’s final tab, :q on a tab’s last window closes the tab, and closing a dock’s last tab closes the dock. Tab ops act on the focused region — gt in a focused dock cycles only that dock’s tabs — while the nvim_tabpage_* API stays main-only (it crosses to main first). Reads resolve against a 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. Editor holds an Option<PanelState> — just the panel’s window, the prev_window to refocus on close, and an edge margin — and no content/cursor/scroll state (that all lives in the buffer and its WindowView). open_panel mounts a buffer in a bottom split (reusing open_bottom_window / remove_window, which displace the main window into the rows above) and hard-locks focus to it: a guard in Editor::focus_window refuses to move focus anywhere else, so <C-w> navigation, nvim_set_current_win, and mouse focus are all inert until close_panel dismisses it. Opening a second listing over an open panel re-targets the one window rather than stacking overlays.
  • Everything inside is plain buffer behavior. Motions navigate, search works, and long lines wrap (:messages just sets 'wrap' on the panel window) — because the panel is a real window onto a real buffer, not because the input loop special-cases it. The activation and dismiss keys are buffer-local keymaps installed by a FileType autocmd, never hard-coded: the prelude’s FileType nxlisting/nxbuffers/nxpanels/nxpanel maps bind q/<Esc> to nx.panel.close, and a per-listing <CR> action (e.g. nx.buffers.actions.open reads the bufnr off the cursor row and switches) is an ordinary default map that a user map overrides — rebindable the standard way.
  • Built-in listings mount here. :messages, :registers, :marks, :jumps, :changes go through Editor::open_scratch_listing(name, lines, cursor) (filetype nxlisting); :ls/:buffers through Editor::open_buffer_listing (filetype nxbuffers, whose <CR> switches buffer); the named-panel list through nxpanels. Each opens scrolled to a chosen cursor line — :messages to the newest line, :ls to the current buffer.
  • A message history feeds it. Editor::echo is the one place a user-facing message is set; it records each line in a messages history (the backing store for :messages) as well as showing it on the message line. The server routes its own messages (errors, captured print/nvim_echo) through the same call.
  • It’s scriptable. A plugin mounts its own panel with nx.panel.open{ name?, lines, filetype?, height?, margin? } and dismisses it with nx.panel.close() — queued as a PanelOp drained by the server (the same “Lua queues, core mutates” flow as vim.cmd/nvim_set_hl). name (default [Panel]) makes the panel unique, so re-opening replaces its content; filetype (default nxpanel, whose ftplugin maps q/<Esc> to close) lets a plugin pass its own filetype and wire its own keys. The only RPC method is the read-only nxvim_panel_is_open (clients use it for chrome); there is no separate panel content/cursor RPC because the panel is a window — its text, cursor, and scroll ride the ordinary WindowView, and the redraw carries no special panel map.
  • Mouse. A left-click in the panel window focuses its line like any window (focus-lock keeps you inside the panel). It is one gesture in the editor’s broader, server-owned mouse support (click, drag-select, multi-click, shift-extend, wheel scroll, divider drag, the 'mousemodel' right-click menu, middle-click paste, and tabline clicks), forwarded by both clients as nx_input_mouse with the server owning the hit-test.

The same core hit-test also drives the floating list overlays — the insert completion popup, the fuzzy picker, the promptless 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 in nxvim-core, implemented by nxvim-ts) and queries it during redraw, so spans are correct in the same frame as the keypress — no worker process, no RPC, no async catch-up frame. This is neovim’s posture: a buggy grammar (compiled C) can segfault the editor, a risk accepted because grammars are user-installed and stable, bounded by a parse deadline (a per-parse wall-clock budget; on expiry the last good tree is kept, costing one frame of stale highlights rather than a hang). It also drives treesitter indentation and injections, both of which need a synchronously-queryable tree.
  • Installable grammars. Grammars are not bundled; they load dynamically by filetype from a data directory using tree-sitter’s standard on-disk layout (<data>/parser/<lang>.so, <data>/queries/<lang>/highlights.scm), so any standard tree-sitter grammar + query set is drop-in usable.
  • Incremental parsing. The engine keeps a shadow buffer and a persistent parse tree per buffer; it applies only edit deltas (InputEdit) drained from the Buffer edit journal in nxvim-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.filetype chooses the language, nx.bo.ts_highlight chooses whether the engine paints it. There is no start/stop verb — setting the filetype and flipping ts_highlight is how you start/stop highlighting. :set filetype / :setf write the same per-buffer override, so there is a no-Lua path too (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 a TsOp::SetQuery the server pushes straight onto the engine, installing a highlights / injections / indents override (a replace, nil to 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.mjs is the single !Send thread that owns core + Lua. It loads the wasm module (dist/eh.mjs), constructs the real EditHost behind a wasm HostEffects (WasmEffects, src/lib.rs), and runs the production tick. The UI thread (web/index.html) is the renderer + input layer, and the two talk over postMessage / a shared ring.
  • Interop is emscripten ccall/cwrap, not wasm-bindgen. src/lib.rs exposes #[no_mangle] extern "C" exports — eh_new / eh_input / eh_input_mouse / eh_source_lua / eh_exec_lua / eh_redraw_json / eh_lines / the fs + shada legs / eh_free* — and the redraw comes back as a JSON return value. Because it links the C-heavy lua54 backend + vim-regex, the final link is emcc, not wasm-bindgen.
  • The renderer consumes the same redraw the native clients do. web/index.html paints the server redraw frame as HTML/CSS (a per-cell-span DOM renderer — windows/gutter/status/tabline/panel/pmenu, selection + cursor-shape classes, smooth scroll), the browser analogue of nxvim-tui’s layout, and translates a browser KeyboardEvent to vim key-notation + mouse gestures to eh_input_mouse. It exposes a window.__nxvim hook (feed / mouse / execLua / lines / frame / …) so a headless browser can drive it.
  • The run loop parks on Atomics.wait, and timers fire without Asyncify. When the page is cross-origin isolated the Worker runs a blocking loop parked on a SharedArrayBuffer input ring, waking on a keystroke or the next timer deadline; the same wait that blocks on input fires Worker-side timers (vim.defer_fn / nx.timer) via eh_set_clock / eh_next_deadline / eh_tick_timers — one mechanism. Without cross-origin isolation it falls back to a postMessage-driven loop (input works, timers don’t fire).
  • The browser is the filesystem — three legs. Open/save persist to OPFS (serverless; shada is one JSON blob in OPFS), to real local files via the File System Access API (:eo / :wo / bare :w on a bound path), or to a real nxvim --daemon over WebTransport (a JS msgpack-RPC client, web/rpc.mjs, reached with ?daemon=nxvim://…). All three ride the same off-tick HostEffects fs seam the native edit-host split uses; the Worker fulfills fs requests between ticks.
  • Lua config runs. Unlike the old core-only web build, init.lua is sourced at startup: options / keymaps / autocmds / user commands / highlights apply (require of further modules / plugins does not — empty runtimepath). LSP and native treesitter are gated off the wasm build (:TSInstall fails loud); syntax highlighting is still present, done JS-side via web-tree-sitter (web/highlight.js + the generated web/vendor/ grammars).
  • Excluded from the workspace. It targets wasm32-unknown-emscripten and links C via emcc, so it is in the root Cargo.toml’s [workspace] exclude (the host cargo build/test/clippy --workspace never touches it) and pins its own dependencies. Built via crates/nxvim-edithost/build.sh (cargo → emcc link → dist/eh.{mjs,wasm}, plus the tree-sitter highlighter assets generated once in the crate’s treesitter/ tooling dir and copied into web/vendor/). Deployed as static files (see netlify.toml); the one hard requirement is cross-origin isolation (COOP/COEP) for the SharedArrayBuffer.

Testing philosophy

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 / View integration (crates/nxvim-server/tests/editing.rs) start a real server, connect over real RPC, send vim key-notation via nx_input, and assert on observable results: buffer contents (nvim_buf_get_lines), cursor, bytes written to disk, and the semantic redraw View. They treat the editor as a black box and exercise the whole stack (RPC → server → core → Lua) end to end.
  • Tier 1 — client paint & key translation (crates/nxvim-tui/tests/) render a known View into a cell grid via ratatui’s TestBackend (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 real redraw, paint it with the real client, and assert on the cell grid — the deterministic “what the user sees” workhorse. Also asserts the non-blocking guarantee (a UI that never drains redraws can’t stall the editor).
  • Tier 3 — PTY smoke (crates/nxvim/tests/e2e.rs) drive the actual nxvim binary through a pseudo-terminal (portable-pty), send real key bytes, and assert on the parsed terminal screen (vt100) a user would really see — proving real crossterm decode, real terminal escapes, and process startup/args. Deliberately small; the slow/flaky surface. Includes a responsiveness check that input typed during a slow editor op (:sleep) is buffered and applied once the editor wakes.

A bug should be reproducible as “these keystrokes produced the wrong text or screen,” and that is exactly the shape of these tests.


Compared to neovim

Similarities (by design):

  • Headless, authoritative editor server with thin UI clients.
  • Single-threaded editor core; concurrency via async I/O.
  • Lua 5.4 scripting running inside the server.
  • Source organization mirroring neovim’s subsystems (one crate per area).
  • Vim modes, motions, operators, counts, registers, and ex-commands.

Differences (intentional, rust-native):

  • Rust crates and ownership instead of C translation units and globals; no libuv (tokio), no longjmp error handling (Result/enums).
  • Not a neovim UI host: no ext_linegrid, no grid protocol, no goal of attaching external neovim GUIs. The client gets a semantic View and lays out ratatui widgets per region itself.
  • Rope-backed (ropey 2.0), byte-indexed buffers with a strict trailing-newline invariant — closer to vim’s own byte-column model.
  • A branching undo tree of full-rope snapshots (cheap via ropey’s structural sharing) rather than neovim’s diff-based undo.c change records — same branching semantics (:undo {N}, vim.fn.undotree()), different storage.
  • In-process treesitter with installable grammars and incremental parsing — like neovim, but kept off nxvim-core behind a SyntaxEngine trait (so the pure core never links tree-sitter) and bounded by a parse deadline (see Syntax highlighting).

Not yet implemented (roadmap). The big-ticket items below; the granular vim.* gaps and the silent approximations live in Known approximations & missing features.

  • The native plugin system (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 custom nx.statusline.segment{} providers re-rendered only on declared events / invalidate, composed through the shared %-format layout so clients paint it unchanged; see the segment plan), viewport decorations (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/conceal are 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 / :TSInstallInfo have 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 system cc/clang/gcc/zig/$NXVIM_CC is found), and the browser arm fetches a prebuilt .wasm grammar instead; a real nvim-treesitter plugin that registers :TSInstall shadows the native arm. The :set-driven highlight toggle has landed too. (Residual :TSInstall edges — grammars needing tree-sitter generate, no install-from-HEAD — are tracked in Known approximations.) Treesitter injections have landed (engine-native, see Syntax highlighting). Treesitter is the native engine; control is declarative buffer state (see Treesitter control): highlight on/off + language are buffer nouns (nx.bo.filetype / nx.bo.ts_highlight, also reachable from :set), query customization is the native bridge nx._nx_set_ts_query, and injections are engine-native. There is no Lua parser/AST platform (the vendored vim.treesitter Lua was deleted — ADR 0002); Lua-driven indent 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 the nvim_win_* / Lua API), floating windows (nvim_open_win with relative, the z-ordered overlay layer, nvim_win_set_config, and the :q/:only/focus/autocmd edge semantics), and tab pages (a Vec<TabSlot> deriving the active WindowTree, the tabline, gt/:tab*/<C-w>T, the Tab* autocmds, the nvim_tabpage_* Lua surface, and showtabline) are all implemented — see Windows. What remains on this axis is more window-local options (colorcolumn, …).

  • The nx.* config surfaceinit.lua targets nxvim’s own API (ADR 0002); the prelude’s current vim-shaped spelling is donor code, refactored under nx where it serves nxvim’s objectives and deleted where it doesn’t, with the muscle-memory aliases as the only lasting vim.*. What the runtime already does: the runtimepath, require, init.lua, nvim_set_hl, :colorscheme, and vim.keymap.set/vim.api.nvim_set_keymap (a per-mode withhold/replay matcher in nxvim-server/src/keymap.rs; multi-key built-ins fire instantly even under a colliding user prefix, via the shared command grammar nxvim_core::command_status the matcher consults) are in place — enough to load a full colorscheme end to end (see Lua). The LSP and diagnostics surface is native too: the nxvim-lsp crate (client, protocol, manager, transport) does nxvim’s own stdio spawning and drives the in-core editing features, and nx.lsp is its Lua control surface (server registration, enable, on-attach) — per ADR 0002. (Design background: the LSP support design and the completion plan.)

    What does not work yet is tracked canonically — both the silent approximations (a feature that looks whole but isn’t) and the loud gaps (functions that raise not implemented rather than fake a value, per the no-silent-stubs rule) — in Known approximations & missing features. That doc explains how to enumerate them straight from the code (grep -rn 'INCOMPLETE:' for approximations, the nx._notimpl raises / runtime nx._notimpl_hits scoreboard for loud gaps) and lists the absent subsystems that have no call site to tag — the bulk of vim’s options beyond the handful nxvim honors (window-local number/relativenumber/cursorline/wrap + the horizontal-scroll sidescroll/sidescrolloff, the buffer-local indentation options, and global showtabline are wired; many others are not) and richer diagnostic surfaces. (Blocking reads — vim.fn.input / vim.fn.confirm / vim.fn.getcharstr / vim.wait and the coroutine pump that hosted them — are not part of the nx model: nothing in it blocks the editor, so the only prompt surface is the callback-shaped vim.ui.input / vim.ui.select.) Legacy Vimscript (eval.c) is not on the roadmap — see guiding principle 2.

  • A broad options surface. :set exists and honors the search booleans, the window-local number-gutter options number / relativenumber, the cursor-line highlight cursorline, and soft word-wrap wrap (breakindent/showbreak) (also via :setlocal / vim.wo / nvim_win_{get,set}_option) and the window-local horizontal-scroll options sidescroll / sidescrolloff (via :set), and the buffer-local indentation options tabstop / shiftwidth / softtabstop / expandtab and commentstring — the comment template the gc/gcc operator reads, set per buffer or defaulted from the filetype for the ~20 most common languages (all also via :setlocal / vim.bo); scoped nvim_{set,get}_option_value routes to the right scope. The bulk of vim’s options are still missing. Named/numbered/special registers (:registers, setreg/getreg, the system clipboard "+/"*) and marks (buffer-local az, global file marks AZ, 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 via vim.keymap.set / nvim_set_keymap). Code folding is done across all four sources — manual (zf/the z family), foldmethod=indent, foldmethod=expr (the native tree-sitter foldexpr and a generic per-line Lua 'foldexpr'), and LSP textDocument/foldingRange — sharing one per-window fold model (see Folds under the view section below). (The interactive / / ? cursor search — n/N, hlsearch/incsearch, the search options — and :s substitution, which shares search’s canonical-regex engine, are both done; see the search design and docs/plans/2026-06-07-substitute-command.md.)

  • Per-buffer user commands. Done. nvim_buf_create_user_command(buffer, …) stores into a per-bufnr registry (nx._buf_user_commands[bufnr][name], the command analogue of the buffer-local nx._keymaps): nx._resolve_user_command gives a buffer-local command precedence over a global of the same name and hides it from other buffers, dispatch routes through the editor’s authoritative current bufnr, nvim_buf_get_commands returns a buffer’s locals, and a wiped buffer’s locals (commands and keymaps) are purged via nx._cleanup_buffer so a reused bufnr can’t inherit them.

  • An async Lua runtime (event loop). Landed (see the async-runtime plan). A Send background actor (crates/nxvim-server/src/evloop.rs, modeled on LspManager) owns timers and child processes; on completion it sends a typed LoopEvent back to the single server thread, which runs the matching Lua callback by id (the nx._cb_fns registry, the keymap-callback shape applied to async work). vim.schedule defers to convergence, vim.defer_fn fires on wall-clock time, and vim.system’s on_exit fires 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 synchronous fs_* / scalars — is not part of the nx model and is absent entirely. Async primitives are the nx API’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.treesitter platform is built (see the platform design and ADR 0001). What still diverges: (1) the decoration-provider highlighter highlighter.new fails loud (nx._notimpl) — nxvim’s start/stop drives the in-core Rust engine instead, so a highlight-only start never builds a Lua-side LanguageTree and highlighter.active[buf].tree reads nil until something calls get_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.get returns 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 :TSInstall fetches the inherited query sets too (javascriptecma,jsx), so base js/ts highlighting carries the ecma patterns. 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. javascript injected 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 — the vim.uv / vim.loop table does not exist, so a plugin reaching for it hits a loud nil index. Both the libuv handle surface (new_timer / new_check / new_fs_event / spawn / new_pipe, the plugin event-loop primitives) and the synchronous fs_* / scalar primitives (fs_realpath, cwd, os_homedir, os_uname, hrtime, now) are gone. Async lives in the nx API (nx.run / nx.timer / nx.fs); the synchronous host info the LSP-config paths need is read through vim.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: tabstop defaults to 4, with shiftwidth=0 (“follow tabstop”) and softtabstop=-1 (“follow shiftwidth”) so the one tabstop knob drives the whole indent width. tabstop, softtabstop, and expandtab drive rendering and <Tab>; shiftwidth drives the >>/<< shift operators and the LSP indent width. commentstring backs the gc/gcc comment operator and defaults from the filetype (the ~20 most common languages) when unset. Still, the bulk of vim’s hundreds of options are missing — a write to an unmodeled option is recorded but inert — 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 — unimplemented vim.fn.* entries are loud gaps, not a TODO to build an evaluator.
  • :TSInstall approximations. The command fetches/compiles grammars (nxvim_ts::install), with a pinned, checksum-verified Zig fetched on demand when no system cc/clang/gcc/zig (or $NXVIM_CC) is found — on macOS, Linux, and Windows alike. Remaining: (1) grammars needing tree-sitter generate (no committed src/parser.c) fail loud rather than generating; (2) the nvim-treesitter ref is pinned in source — no :TSInstall-from-HEAD.
  • 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); no range (only full/full/delta); highlight_token is a loud gap (nx._notimpl — a Lua callback on the decode hot path); get_at_pos reads the cached mirror even for a stopped buffer; no per-client granularity (one cache per buffer); repaints mid-insert (update_in_insert always on). See docs/plans/2026-06-08-lsp-semantic-tokens.md.
  • LSP inlay hints approximations. Painted inline, opt-in (crates/nxvim-server/src/lsp/inlay.rs). inlayHint/resolve (lazy per-hint label fill) and vim.lsp.inlay_hint.get (with a line-range filter) have landed. What still diverges: one LspInlayHint group for all kinds (no Type/Parameter split); the fetch is whole-document — the viewport-scoped range request is deferred; per-buffer enable only (no per-client granularity); horizontal-scroll (leftcol>0) + inline hints is best-effort; repaints mid-insert. See docs/plans/2026-06-08-lsp-inlay-hints.md.
  • Synchronous prompts — one caveat. vim.fn.input/confirm return inline via a pumped coroutine (nx._pump), but only pumped entry points (:lua chunk, keymap, user command) can prompt — Lua sourced at startup or off a bare callback has no coroutine to yield from. See examples/sync-prompts/.
  • nx.statusline segment registry — v1 deferrals. The lualine-shaped surface is built (nx.statusline.setup/segment/invalidate; built-ins composed in nxvim_core::statusline::compose_segments, custom segments rendered per window and cached server-side; see docs/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) — so ctx.focused and ctx.buf are correct in every window. The server orchestrates the re-render from run_pending with a fresh window mirror (EditHost::refresh_statusline_segments), so an invalidate fired from an autocmd that ran before the transition still renders against the settled layout. Layouts are also per-window / setlocal-able: 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), and nx.statusline.reset(win) drops the override (EditHost::resolve_window_layout). Mouse-click segment regions have landed: a segment can carry an on_click handler, lowered to the %@func@…%X statusline syntax (with %nT tabline labels and laststatus=3), so clicks dispatch back to Lua. What it does not do yet: (1) The custom segment ctx carries { buf, win, focused } but no width (the server doesn’t mirror the per-window statusline width to Lua). (2) git / lsp_progress are plugin segments (custom-segment examples), not built-ins. The built-in set is mode / filename / filepath / filetype / encoding / location / modified / readonly / diagnostics.

Shared limitations

A handful of the approximations above trace back to the same underlying limitation, so they tend to surface together. The notable ones:

LimitationWhat it affects
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 registrymake_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 optionsvim.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.