neovim-internals-reference
>
Install
mkdir -p .claude/skills/neovim-internals-reference && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16695" && unzip -o skill.zip -d .claude/skills/neovim-internals-reference && rm skill.zipInstalls to .claude/skills/neovim-internals-reference
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
The Neovim-internals theory pack for NvSinner. Load BEFORE touching anything in lua/core/ (ai-activity, ui-touch, autoreload, update, health), terminal behavior (toggleterm, PTY buffers, TermOpen), autocmds and their ordering, statusline/winbar expressions, vim.uv timers, highlight groups / winhighlight, lazy.nvim loading/lockfile semantics, or the native vim.lsp API. Also load whenever a Neovim API behaves unexpectedly: E5560 errors, a winbar that renders empty or frozen, a spinner that stops, a changedtick that never moves, an autocmd that "doesn't fire", a highlight that reverts after a plugin loads, or a redraw that doesn't happen. This explains WHY the platform behaves that way and where this repo depends on each rule.About this skill
Neovim internals, as NvSinner uses them
This is the platform-arcana reference: what Neovim actually guarantees (and doesn't), each fact anchored to the exact file in this repo that depends on it. Target: Neovim 0.11+ (the repo's hard floor); the dev machine runs 0.12.3 and every "verified" note below was checked on that build or verified empirically by this repo's own development (labeled as such).
When NOT to use this skill
- You're debugging a live failure right now →
nvsinner-debugging-playbook(this file explains the mechanism; that one gives the triage steps). - You want the story of how a bug was found/fixed →
nvsinner-failure-archaeology(war stories live there; this file states only the resulting rules). - You want to re-run the experiments that established a fact →
nvsinner-empirical-verification(probe recipes live there). - You want the repo's design rules and layering rationale →
nvsinner-architecture-contract. - You want to know what a plugin/option is set to →
nvsinner-config-catalog. - You're installing, building, or running the distro →
nvsinner-build-and-run. - You're writing or running tests →
nvsinner-testing-and-qa. - You're about to edit files and need the process rules →
nvsinner-change-control.
Quick reference: trap → rule → repo anchor
| Trap | Rule | Repo anchor |
|---|---|---|
| E5560 in a callback | uv-driven callbacks are fast contexts: no vim.api/vim.fn; plain Lua + uv.now() only, vim.schedule to escape | on_lines in lua/core/ai-activity.lua |
| Winbar expression renders empty | vim.g.statusline_winid is set for 'statusline' eval, NOT 'winbar' — bake the bufnr into a per-window string | term_bar(win) in lua/core/ui-touch.lua; M.winbar(buf) in lua/core/ai-activity.lua |
Terminal changedtick frozen while output streams | Terminal lines aren't materialised (tick not bumped) unless attached or rendered — use nvim_buf_attach | header comment + attach() in lua/core/ai-activity.lua |
| Spinner frozen while focused in a terminal | :redrawstatus doesn't repaint the winbar in terminal mode — use nvim__redraw{winbar=true, flush=true} (pcall it: private API) | tick() in lua/core/ai-activity.lua |
| Terminal styled as a code pane on first open | toggleterm's BufWinEnter fires while buftype == "" — re-run styling on TermOpen | focus autocmd in lua/core/ui-touch.lua |
| External-edit autocmd never fires | With autoread + unmodified buffer, the silent reload fires ONLY FileChangedShellPost, not FileChangedShell — hook both | lua/core/autoreload.lua |
| Timer/spinner silently stops after a while | An active-but-unreferenced luv handle can be GC'd — pin it on a live table | M._timer in lua/core/ai-activity.lua |
| Custom highlight reverts after a plugin loads | Any :colorscheme (re)load wipes ad-hoc groups — re-apply via a ColorScheme autocmd | apply_hl() in lua/core/ui-touch.lua, lua/core/ai-activity.lua, lua/plugins/ui/theme.lua, lua/plugins/ui/noice.lua |
| Winbar label invisible when unfocused | fg == bg renders as a blank strip — give dim bars a readable muted fg | NvTermBarDim in lua/core/ui-touch.lua |
New lua/plugins/<dir>/ silently never loads | lazy.nvim import does NOT recurse — add { import = "plugins.<dir>" } to init.lua | init.lua spec list |
| Update floats plugins to untested versions | Lazy restore pins to lazy-lock.json; sync floats — the distro updates with restore | lua/core/update.lua, install.sh |
| LSP repaints Treesitter colours ~1s after open | Nil semanticTokensProvider in a "*" on_attach BEFORE any server is enabled; keep automatic_enable = false | lua/plugins/lsp/lsp-config.lua |
| Markdown buffer crashes Neovim 0.12.x | Upstream treesitter bug (node:range() on nil node) — highlighter disabled for markdown | after/ftplugin/markdown.lua |
1. Fast event contexts
Concept. Neovim's event loop (libuv) can invoke Lua callbacks between
input processing, outside the main "editor loop". Such a callback runs in a
fast event context (:help lua-loop-callbacks, :help vim.in_fast_event()):
it may touch Lua state freely but NOT "editor" state — most vim.api and
vim.fn calls raise E5560 (<function> must not be called in a fast event context). Verified on 0.12.3: calling vim.api.nvim_get_current_buf() inside
a raw uv.new_timer() callback fails with exactly that error.
Which callbacks are fast contexts (relevant here):
vim.uvtimer callbacks (unless wrapped — see below).nvim_buf_attachcallbacks (on_lines,on_detach, …). Per:help api-buffer-updates-lua, these are "called frequently in various contexts" undertextlock; for a terminal buffer, output arrives via the event loop, so treaton_linesas fast-context code, always.vim.systemcallbacks (hencevim.schedule_wraparound the completion handler inlua/core/update.lua).
Rules.
- Inside a fast context: plain Lua tables,
uv.now(), string work — nothing else.vim.uvfunctions are safe (they don't touch editor state). - To do editor work, escape with
vim.schedule(fn)/vim.defer_fn, or create the callback pre-wrapped withvim.schedule_wrap(fn)— then the body runs on the main loop and the full API is available. vim.in_fast_event()tells you which world you're in, if you must branch.
Where this repo relies on it. lua/core/ai-activity.lua is the canonical
example of both halves: the on_lines callback only writes to the plain-Lua
state table and stamps uv.now() (comment in attach() marks it), while the
animation timer is started with vim.schedule_wrap(tick) — so tick() itself
is NOT fast-context and may legally call vim.fn.mode(), nvim_buf_is_valid,
and nvim__redraw. The hover debounce timer in lua/core/ui-touch.lua and the
1s checktime poller in lua/core/autoreload.lua use the same
vim.schedule_wrap pattern.
The trap. The failure is not always a loud E5560: some paths error only
when the callback happens to fire in a fast context, so a vim.api call inside
on_lines can appear to work in light testing and blow up under real streaming
output. Never put vim.* editor calls in on_lines; let a scheduled timer do
the redrawing.
2. Statusline vs winbar evaluation
Concept. 'statusline', 'winbar', and 'tabline' share one printf-like
format language (:help 'statusline'). Two expression forms matter:
%{expr}— evaluateexpr, insert the result as plain text.%{%expr%}— evaluateexpr, then re-parse the result as statusline format, so the returned string may itself contain items like%=(separator/centering) and%#Group#…%*(highlight switches). This is the form this repo uses;v:lua.require'…'calls Lua from the expression.
The critical asymmetry (verified empirically by this repo).
vim.g.statusline_winid (:help g:statusline_winid) is populated while a
'statusline' expression is evaluated, but NOT while a 'winbar'
expression is evaluated. A winbar callback that asks "which window am I?" via
that global gets nothing and renders empty — that is exactly what happened
here, and it's why the design is what it is:
⚠️ Refinement (re-probed 2026-07-02, Neovim 0.12.3): the discriminator is the evaluation FORM, not the option.
%!exprfull-expressions seeg:statusline_winidfor BOTH'statusline'and'winbar';%{expr}items see it for NEITHER. This repo's winbar uses a%{%…%}item, so the variable was genuinely absent. The rule below is unchanged: never rely onstatusline_winidinside%{}items. Probe:nvsinner-empirical-verificationrecipe 2.
term_bar(win)inlua/core/ui-touch.luabuilds a per-window winbar string with the buffer number baked in as a literal:%{%v:lua.require'core.ai-activity'.winbar(<buf>)%}.M.winbar(buf)inlua/core/ai-activity.luatherefore takes its buffer as an argument and must never consultvim.g.statusline_winid. Its busy branch returns%=%#NvAiBusy# … %*%=— the%{%…%}re-parse is what makes the centering and the chip highlight work.
Rules.
- Winbar expressions must be self-contained: pass identity (buf/win) in as a baked literal, one string per window.
- If the expression returns format items (
%=,%#…#), the option value must use%{%…%}, not%{…}. - Evaluation can happen at any redraw — keep these functions cheap and
side-effect free (
M.winbaronly readsstate,vim.b[buf].nv_term_label, and buffer validity).
The trap. The bar looks wired (option set, function exists) but renders
empty in real use — nothing errors. Test the rendered content, not just the
option: tests/core/ui_touch_spec.lua asserts the winbar string bakes the
buffer number; tests/core/ai_activity_spec.lua asserts winbar(buf) output.
3. Terminal buffer semantics
Concept. A :terminal (or toggleterm) buffer is PTY-backed: an external
process writes to a pseudo-terminal and Neovim ingests its output into a
special buffer with buftype == "terminal". Several behaviors differ from file
buffers:
-
changedtick non-materialization (verified empirically by this repo). Neovim does not materialise a terminal buffer's lines — and therefore does not bump
b:changedtick— unless something is attached to the buffer or it is being rendered. Polling the tick of an unwatched terminal can sit frozen while output streams. Consequence:nvim_buf_attachon_linesis the reliable activity signal — an attached listener is always notified. This is the load-bearing fact behindlua/core/ai-activity.lua(its header comment records the rejection of tick polling).⚠️ Version caveat (re-probed 2026-07-02, Neovim 0.12.3 headless): the tick of a hidden, unattached terminal buffer DID advance during streaming in the re-probe, so th
Content truncated.