agentskills.codes
NE

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

Installs 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.
733 charsno explicit “when” triggerlonger than Claude Code's old 250-char listing cap (fine on current versions)

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 nownvsinner-debugging-playbook (this file explains the mechanism; that one gives the triage steps).
  • You want the story of how a bug was found/fixednvsinner-failure-archaeology (war stories live there; this file states only the resulting rules).
  • You want to re-run the experiments that established a factnvsinner-empirical-verification (probe recipes live there).
  • You want the repo's design rules and layering rationalenvsinner-architecture-contract.
  • You want to know what a plugin/option is set tonvsinner-config-catalog.
  • You're installing, building, or running the distronvsinner-build-and-run.
  • You're writing or running testsnvsinner-testing-and-qa.
  • You're about to edit files and need the process rulesnvsinner-change-control.

Quick reference: trap → rule → repo anchor

TrapRuleRepo anchor
E5560 in a callbackuv-driven callbacks are fast contexts: no vim.api/vim.fn; plain Lua + uv.now() only, vim.schedule to escapeon_lines in lua/core/ai-activity.lua
Winbar expression renders emptyvim.g.statusline_winid is set for 'statusline' eval, NOT 'winbar' — bake the bufnr into a per-window stringterm_bar(win) in lua/core/ui-touch.lua; M.winbar(buf) in lua/core/ai-activity.lua
Terminal changedtick frozen while output streamsTerminal lines aren't materialised (tick not bumped) unless attached or rendered — use nvim_buf_attachheader 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 opentoggleterm's BufWinEnter fires while buftype == "" — re-run styling on TermOpenfocus autocmd in lua/core/ui-touch.lua
External-edit autocmd never firesWith autoread + unmodified buffer, the silent reload fires ONLY FileChangedShellPost, not FileChangedShell — hook bothlua/core/autoreload.lua
Timer/spinner silently stops after a whileAn active-but-unreferenced luv handle can be GC'd — pin it on a live tableM._timer in lua/core/ai-activity.lua
Custom highlight reverts after a plugin loadsAny :colorscheme (re)load wipes ad-hoc groups — re-apply via a ColorScheme autocmdapply_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 unfocusedfg == bg renders as a blank strip — give dim bars a readable muted fgNvTermBarDim in lua/core/ui-touch.lua
New lua/plugins/<dir>/ silently never loadslazy.nvim import does NOT recurse — add { import = "plugins.<dir>" } to init.luainit.lua spec list
Update floats plugins to untested versionsLazy restore pins to lazy-lock.json; sync floats — the distro updates with restorelua/core/update.lua, install.sh
LSP repaints Treesitter colours ~1s after openNil semanticTokensProvider in a "*" on_attach BEFORE any server is enabled; keep automatic_enable = falselua/plugins/lsp/lsp-config.lua
Markdown buffer crashes Neovim 0.12.xUpstream treesitter bug (node:range() on nil node) — highlighter disabled for markdownafter/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.uv timer callbacks (unless wrapped — see below).
  • nvim_buf_attach callbacks (on_lines, on_detach, …). Per :help api-buffer-updates-lua, these are "called frequently in various contexts" under textlock; for a terminal buffer, output arrives via the event loop, so treat on_lines as fast-context code, always.
  • vim.system callbacks (hence vim.schedule_wrap around the completion handler in lua/core/update.lua).

Rules.

  1. Inside a fast context: plain Lua tables, uv.now(), string work — nothing else. vim.uv functions are safe (they don't touch editor state).
  2. To do editor work, escape with vim.schedule(fn) / vim.defer_fn, or create the callback pre-wrapped with vim.schedule_wrap(fn) — then the body runs on the main loop and the full API is available.
  3. 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} — evaluate expr, insert the result as plain text.
  • %{%expr%} — evaluate expr, 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. %!expr full-expressions see g:statusline_winid for 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 on statusline_winid inside %{} items. Probe: nvsinner-empirical-verification recipe 2.

  • term_bar(win) in lua/core/ui-touch.lua builds 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) in lua/core/ai-activity.lua therefore takes its buffer as an argument and must never consult vim.g.statusline_winid. Its busy branch returns %=%#NvAiBusy# … %*%= — the %{%…%} re-parse is what makes the centering and the chip highlight work.

Rules.

  1. Winbar expressions must be self-contained: pass identity (buf/win) in as a baked literal, one string per window.
  2. If the expression returns format items (%=, %#…#), the option value must use %{%…%}, not %{…}.
  3. Evaluation can happen at any redraw — keep these functions cheap and side-effect free (M.winbar only reads state, 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_attach on_lines is the reliable activity signal — an attached listener is always notified. This is the load-bearing fact behind lua/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.

Search skills

Search the agent skills registry