doc/ai.txt — rendered from the plugin's own vimdoc
*ai.txt* Provider-agnostic ask/stream layer for talking to an AI *ai.nvim*
CONTENTS
1. Introduction ........................ |ai-intro| 2. Requirements ........................ |ai-requirements| 3. Setup ............................... |ai-setup| 4. Configuration ....................... |ai-config| 5. Commands ............................ |ai-commands| 6. Bindings ............................ |ai-bindings| 7. Completion ........................... |ai-completion| 8. Lua API ............................. |ai-api| 9. Providers ............................ |ai-providers| 10. Attachments .......................... |ai-attachments| 11. Context .............................. |ai-context| 12. Scope ................................ |ai-scope| 13. Health check ........................ |ai-health|
1. INTRODUCTION
ai.nvim is a provider-agnostic ask/stream layer for talking to an AI from inside Neovim -- context assembly (buffer/selection/diagnostics), a streaming answer panel, and an:Aicomposer command, built onlib.nvim.net.curl(SSE/NDJSON streaming, secrets kept out of curl's argv). It pairs well with pdfport.nvim: itsclaude/ollamaextraction backends send their PDFs and page images through this plugin'sask()instead of carrying a second hand-written curl/provider path of their own. That migration is what |ai-attachments| were built for.
2. REQUIREMENTS
- Neovim >= 0.10 (needsvim.system, whichlib.nvim.net.curlrequires). - lib.nvim (github.com/StefanBartl/lib.nvim) -- hard dependency.fetch_stream/secret_headersonlib.nvim.net.curlmust be present; a too-old checkout is flagged by|:checkhealth|ai. - ui.nvim (github.com/StefanBartl/ui.nvim) -- also a hard dependency:ui.kitbacks the streaming answer panel, the explain badge, the non-streaming viewer and the prompt popup. All lazy-loaded (no cost until an:Aiaction actually runs), but none of them has another rendering path. Inline completion (|ai-completion|) is the one exception: it renders through raw extmarks, so it still works without ui.nvim. -curlonPATH-- every provider shells out to it. - At least one provider actually usable: - claude:ANTHROPIC_API_KEYset in the environment. - openai:OPENAI_API_KEYset in the environment. - gemini:GEMINI_API_KEYset in the environment. - ollama: theollamabinary onPATH, with the daemon running (default http://127.0.0.1:11434, override withAI_OLLAMA_HOST-- deliberately notOLLAMA_HOST, which is Ollama's own env var for the *server*'s bind address). - loomai: a loomAI server reachable at http://127.0.0.1:8080 (default, override withLOOMAI_HOST). Last in the defaultprovider_order-- see |ai-scope|. No provider is required at install time --|:checkhealth|ai reports which ones are usable on this machine, andprovider = "auto"(the default) picks the first available one inprovider_order. Optional soft dependencies (loaded viapcall(require, ...); absent, the matching |ai-context| flag is a silent no-op): - data.nvim -- backscontext.structured_data. - gitsuite.nvim -- backscontext.conflict.
3. SETUP
With lazy.nvim:
{
"StefanBartl/ai.nvim",
dependencies = { "StefanBartl/lib.nvim", "StefanBartl/ui.nvim" },
cmd = "Ai",
keys = { { "<leader>a", mode = { "n", "v" } } },
config = function()
require("ai").setup()
end,
}
Manual:
require("ai").setup()
cmd/keysare lazy-load triggers -- ai.nvim does nothing until:Aiis run or one of the default<leader>a*keymaps is pressed.
4. CONFIGURATION
Pass a table to |ai.setup()|. All keys are optional; `lua/ai/config/ DEFAULTS.lua` is the source of truth.
require("ai").setup({
provider = "auto", -- "auto"|"claude"|"ollama"|"openai"|"gemini"|"loomai"|custom id
provider_order = { "claude", "ollama", "openai", "gemini", "loomai" }, -- "auto" resolution order
model = {}, -- e.g. { claude = "claude-opus-4-5" }
timeout_ms = 60000,
ui = {
enable = true,
progress_style = "auto", -- "auto"|"notify"|"statusline"|"fidget"|"float"
panel_theme = "rounded",
badge_timeout_ms = 6000,
},
keymaps = {
enable = true,
prefix = "<leader>a",
-- Per-action overrides, keyed by action id (ask/quick/explain):
-- keymaps = { quick = "<leader>xs" } -- move just one
-- keymaps = { explain = false } -- disable just one
},
which_key = { enable = true },
usercmds = { enable = true },
-- Default context for the quick-action keymaps (<leader>as/<leader>ae).
-- :Ai ask/stream build no context of their own -- only what a caller
-- passes to require("ai").ask()/stream() directly.
context = {
buffer = false,
selection = true,
diagnostics = false,
cwd = false, -- expensive (a full cwd sweep); off by default
structured_data = false, -- json/yaml/xml block under the cursor, via data.nvim (optional)
conflict = false, -- both sides of unresolved merge conflicts, via gitsuite.nvim (optional)
},
-- Inline completion (ghost text at the cursor), see |ai-completion|.
completion = {
enable = true,
trigger = "manual", -- "manual"|"auto"
idle_ms = 500, -- auto-mode idle debounce
max_context_lines = 60,
provider = false, -- overrides `provider`, completion only; false = unset
model = false,
keymap = {
trigger = "<C-\\><C-a>", -- insert mode; manual mode only
accept = "<Tab>",
dismiss = "<C-]>",
},
},
log_level = vim.log.levels.WARN,
})
"loomai" is a registered built-in provider (a local loomAI server, see
|ai-providers|) and is in provider_order's default, listed last since it
needs a local server the user must run themselves -- see |ai-scope|.
5. COMMANDS
*:Ai* :Ai [prompt?] Same as:Ai ask [prompt?]. :Ai ask [prompt?] *:Ai-ask* Ask once, non-streaming. Prompts for text ifpromptis omitted. :Ai stream [prompt?] *:Ai-stream* Likeask, but the answer panel opens and fills in live as it streams. :[range]Ai rewrite [prompt?] *:Ai-rewrite* Replace the range (default: the current line) with AI-generated code. :[range]Ai append [prompt?] *:Ai-append* Insert AI-generated code after the range (default: the current line). :[range]Ai prepend [prompt?] *:Ai-prepend* Insert AI-generated code before the range (default: the current line). These three send the task text alongside a fenced copy of the range; the model is told to answer with only code, and the response is written back in one buffer edit (a singleuundoes it). Non-streaming, and with NO preview or confirmation step: the selected code goes to the active provider and its answer lands in the buffer as soon as it arrives -- read it before moving on. An empty or truncated response, or one arriving after the buffer changed underneath it, is discarded with a warning. A range on any other:Aisubcommand is ignored with a warning. :Ai provider {name} *:Ai-provider* Switch the active provider.<Tab>-completes registered ids. :Ai info *:Ai-info* Show the active provider, resolution order, and per-provider availability. None of these build any context automatically -- they only send the literal prompt text. The quick-action keymaps (|ai-bindings|) are what wire in the current buffer/selection/diagnostics viaconfig.context.
6. BINDINGS
All keymaps sit under one configurable prefix (config.keymaps.prefix, default<leader>a) and are individually overridable/disableable viaconfig.keymaps[id](see |ai-config|).
Mode Default Action id Does
n, v <leader>aa ask Ask once, non-streaming (prompts for
text; v: about the selection)
n, v <leader>as quick Send context + a typed task, stream
the answer
n, v <leader>ar rewrite Replace current line/selection with
AI-generated code
n, v <leader>ao append Insert AI-generated code after current
line/selection
n, v <leader>aO prepend Insert AI-generated code before current
line/selection
n, v <leader>ae explain Explain the current context in a small
badge, no panel
rewrite/append/prepend act on whole lines (see |:Ai-rewrite|): a charwise
Visual selection still affects the full line(s) it touches, with a warning.
Autocmds: a VimLeavePre handler in the ai_nvim augroup kills every
still-running stream, so quitting Neovim never leaves an orphaned curl
process behind. With completion.enable, the AiCompletion augroup also
dismisses a shown suggestion once the buffer/cursor moves past it
(TextChangedI, CursorMovedI), clears completion state on InsertLeave,
and -- only for completion.trigger = "auto" -- schedules the idle-pause
trigger on TextChangedI.
7. COMPLETION
Inline "ghost text" suggestions at the cursor, insert mode only. A suggestion is exactly one |ai.ask()| call framed as a fill-in-the-middle prompt (the buffer text before/after the cursor) -- not a true FIM API, so quality/latency vary by provider/model. See |ai-scope| for why this counts as the same single-turn interaction the rest of the plugin covers, just with a different trigger and renderer.
Mode Default Action id Does
i <C-\><C-a> trigger Request a suggestion at the cursor
(manual mode only)
i <Tab> accept Insert the shown suggestion; falls
through to normal <Tab> when nothing
is shown or a completion-menu popup is
open (pumvisible())
i <C-]> dismiss Clear the shown suggestion without
inserting it
config.completion.trigger:
"manual" (default) Only the trigger keymap fires a suggestion.
"auto" Also fires one after completion.idle_ms (default
500) of no typing. An explicit opt-in: this means an
API call -- possibly against a paid cloud provider
-- on every idle pause, not just on deliberate
action. |:checkhealth| ai warns if trigger = "auto"
is combined with a cloud provider.
config.completion.provider/.model override config.provider/.model
for completion requests only, e.g. to always complete against a local
Ollama model regardless of what :Ai ask uses.
Typing or moving the cursor dismisses a shown, unaccepted suggestion (its
context no longer matches reality). A response that arrives after the
buffer or cursor has since changed is discarded rather than rendered, for
the same reason.
Set completion.keymap.<name> = false to drop just that one key, or
completion.enable = false to disable the feature entirely.
8. LUA API
*ai.setup()* require("ai").setup({opts}) Set up ai.nvim: merges {opts} over the defaults, registers the built-in providers, and (unless disabled) installs the default keymaps/usercmds. Calling it a second time is a no-op (warns and returns false). *ai.config()* require("ai").config() Returns the activeAi.Configtable. *ai.ask()* require("ai").ask({req}, {cb}) Ask once, non-streaming. {req} fields: prompt string (required) the question/instruction provider string? explicit provider id; default: config.provider model string? overrides the resolved provider's default model context table?Ai.ContextDefaults, see |ai-context| timeout_ms integer? overrides config.timeout_ms system string? system prompt, provider-dependent max_tokens integer? response-length cap; onlyclaudeneeds one attachments table?Ai.Attachment[], see |ai-attachments| api_key string? overrides the provider's env-var lookup, this request only -- setproviderexplicitly with it, a key belongs to one API host string? overrides a self-hosted provider's base URL (ollama,loomai), this request only {cb} is called ascb(ok, res_or_err):res_or_erris anAi.Responsetable (text,usage,stop_reason,provider) on success, or aLibErrorValueon failure, whosekindis one ofmissing_api_key,invalid_request,timeout,network_error,api_error,invalid_response,blockedorprovider_resolution. *ai.stream()* require("ai").stream({req}, {handlers}) Like |ai.ask()|, but streams the response. {handlers} fields: on_chunk function? function(delta: string) on_done function? function(res: Ai.Response) on_error function? function(err: string) Returns the runningvim.SystemObj(or nil if no provider could be resolved) so a caller can:kill()it to cancel --ai.ui.panelalready does this for the built-in quick-actions (|ai-bindings|). *ai.providers.register()* require("ai.providers").register({provider}) Register a custom (or replacement) provider. Registering under an id that already exists -- built-in or previously custom -- replaces it. {provider} is anAi.Provider:
require("ai.providers").register({
id = "myproxy",
available = function() return true end,
ask = function(req, cb) ... end,
stream = function(req, handlers) return process_handle_or_nil end,
})
Add the id toconfig.provider_orderto make it reachable throughprovider = "auto". *ai.context.assemble()* require("ai.context").assemble({opts}) Build the context block for {opts} (Ai.ContextDefaults:buffer,selection,diagnostics,cwd,structured_data,conflict, all boolean). Every section is best-effort -- a scope that resolves to nothing (no visual selection active, no diagnostics present, no unresolved conflict) is silently omitted.structured_dataneeds data.nvim andconflictneeds gitsuite.nvim, both optional soft dependencies -- absent, those two flags are silent no-ops too. Returns an empty string if nothing was requested or resolved. See |ai-context|.
9. PROVIDERS
claude Anthropic Messages API. Streaming via SSE. Requires
ANTHROPIC_API_KEY.
ollama Local Ollama daemon. Streaming via NDJSON. Requires the ollama
binary and a running daemon.
openai OpenAI Chat Completions API. Streaming via SSE. Requires
OPENAI_API_KEY.
gemini Google Gemini API. Streaming via SSE. Requires GEMINI_API_KEY.
loomai Local loomAI server (POST /ask, POST /ask/stream). Streaming
via SSE. Requires a running loomAI instance (default
http://127.0.0.1:8080, override with LOOMAI_HOST). Last in the
default provider_order -- see |ai-scope|.
provider = "auto" (the default) walks provider_order and uses the first
provider whose available() is true -- a cheap, synchronous check (an
executable on PATH and/or an env var set), never a network round trip. Set
provider to an explicit id to always use one provider; `:Ai provider
{name}` switches it at runtime.
API keys are read from the environment (vim.env.*) unless a caller passes
req.api_key for one request (see |ai-attachments|), never stored by
this plugin, and never appear in curl's argv -- they go through
lib.nvim.net.curl's secret_headers, which is the reason ai.nvim exists
at all (see |ai-intro|). |:checkhealth| ai and :Ai info show only whether
a key is set, never its value.
10. ATTACHMENTS
A request can carry binary payloads alongside its prompt -- a rasterized page, a whole PDF -- throughreq.attachments, a list ofAi.Attachment:
local attachments = require("ai.attachments")
local page, err = attachments.from_file("/tmp/invoice-page-1.png")
if not page then error(err) end
require("ai").ask({
prompt = "Extract every table on this page as Markdown.",
provider = "claude",
attachments = { page },
}, function(ok, res) ... end)
Ai.Attachmentis three fields --kind("image"or"document"),media_type(an IANA type) anddata(base64, unwrapped) -- plus an optionalnameused only in error messages. That is the intersection of what every provider's wire format actually carries; each backend spells it its own way. *ai.attachments.from_file()* require("ai.attachments").from_file({path}, {opts}) Read, size-check and base64 {path}. Returnsattachment, nilornil, err_message-- never raises. {opts}:media_type(overrides the extension guess),kind(overrides the media-type guess),name. Recognized extensions: .png .jpg .jpeg .gif .webp .pdf *ai.attachments.from_bytes()* require("ai.attachments").from_bytes({data}, {media_type}, {opts}) Same, for bytes already in memory (a rasterizer's stdout, say). What each provider can carry: claude image, document content blocks with a base64sourcegemini image, documentinline_dataparts with amime_typeopenai imageimage_urlpart whose url is adata:URI ollama image bareimagesarray on the message loomai -- text only A provider that cannot carry an attachment FAILS the request (err.kind == "invalid_request") before sending anything -- it is never dropped silently, because a prompt asking about a page, sent without the page, does not fail: it answers confidently about nothing. Onlyclaudeandgeminitake a PDF whole; for the others the caller rasterizes pages first and sends images. A capability describes the API, not the model: Ollama's chat endpoint has animagesfield for every model,llavareads it andllama3.2ignores it. Large requests need no configuration. A body over 8 KB is written to a0600temp file and read back by curl with--data-binary @fileinstead of being passed in argv, where Windows caps a command line at 32 767 characters; the file is removed once the request ends.
11. CONTEXT
Ai.ContextDefaultsfields, all boolean, independent (not mutually exclusive): buffer The current buffer, fenced and line-numbered. selection The last visual selection's range, same format. diagnosticsvim.diagnostic.get()for the current buffer, formatted asfile:line:[severity]:message, sorted by line. cwd A full working-directory sweep vialib.nvim.harvest.scope. Expensive; off by default. structured_data The json/yaml/xml block under the cursor, flattened topath: valuelines via data.nvim (optional soft dependency; a silent no-op without it or outside such a block). conflict Both sides of every unresolved merge conflict in the buffer, labeled "ours"/"theirs", via gitsuite.nvim (optional soft dependency; a silent no-op without it or outside a conflict).require("ai").ask()/.stream()prefix the request'spromptwith the assembled context block, if any. The quick-action keymaps (|ai-bindings|) useconfig.contextas their default;:Ai ask/`:Ai stream` build no context of their own.
12. SCOPE
ai.nvim covers single-turn question/answer and streaming: ask a question, get an answer; stream a longer one into a panel; send the current context along with a typed task. This includes more than one *trigger* for that same single-turn call -- an explicit:Ai/keymap action is one; an editor-triggered inline completion suggestion (|ai-completion|) is another. Both are exactly one |ai.ask()| round-trip under the hood, just with a different trigger source and renderer, not a new kind of interaction. It does not cover anything that looks like an autonomous multi-step agent, a sandboxed execution environment, or tool-use/function-calling loops. That is a separate, deliberate boundary with a native, independent project (not a Neovim plugin, not a dependency of ai.nvim) -- see docs/scope.md in the repo for the full reasoning. TheAi.Providerinterface (id,available(),ask(),stream(),capabilities) is deliberately narrow so that an HTTP-backed provider pointing at such a system can be added as one registry entry, with no redesign.loomaiis exactly that (a thin client for loomAI's own/ask//ask/stream, nothing more -- see |ai-providers|) and is, unlike a hypothetical deeper integration, in the defaultprovider_order(default{"claude", "ollama", "openai", "gemini", "loomai"},loomailast). Any *other* provider -- a custom one registered under its own id, or a future built-in not yet added toprovider_order-- stays reachable only by naming it explicitly (provider = "<name>"), never through"auto"by accident.
13. HEALTH CHECK
:checkhealth ai Reports: - core -- Neovim version (vim.systemneeds >= 0.10), curl on PATH. - lib.nvim -- each required submodule (net.curl, harvest.scope, progress, ui.kit, usercmd.composer, bindings.keymap), plus a specific check thatlib.nvim.net.curl.fetch_streamexists -- a too-old lib.nvim checkout has every other module present but not this one. - providers -- every registered provider id and whetheravailable()is currently true (missing binary and/or API key otherwise). Never shows a key's value, only whether one is set. - configuration -- the activeproviderandprovider_order. - completion -- enabled?,triggermode, the resolvedcompletion.provider. Warns iftrigger = "auto"resolves to a paid cloud provider, since that means an API call on every idle pause. - composer route pre-flight --:Ai's own route table, validated bylib.nvim.usercmd.composer.