NORMAL ~/wkd/p/ai/help :set skin=modern utf-8

ai.txt

Provider-agnostic ask/stream layer for talking to an AI — ai.nvim

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 *ai-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-intro*

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 :Ai composer command, built on
lib.nvim.net.curl (SSE/NDJSON streaming, secrets kept out of curl's argv).

It pairs well with pdfport.nvim: its claude/ollama extraction backends
send their PDFs and page images through this plugin's ask() instead of
carrying a second hand-written curl/provider path of their own. That
migration is what |ai-attachments| were built for.

2. REQUIREMENTS *ai-requirements*

- Neovim >= 0.10 (needs vim.system, which lib.nvim.net.curl requires).
- lib.nvim (github.com/StefanBartl/lib.nvim) -- hard dependency.
  fetch_stream/secret_headers on lib.nvim.net.curl must be present; a
  too-old checkout is flagged by |:checkhealth| ai.
- ui.nvim (github.com/StefanBartl/ui.nvim) -- also a hard dependency: ui.kit
  backs the streaming answer panel, the explain badge, the non-streaming
  viewer and the prompt popup. All lazy-loaded (no cost until an :Ai
  action 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.
- curl on PATH -- every provider shells out to it.
- At least one provider actually usable:
  - claude:  ANTHROPIC_API_KEY set in the environment.
  - openai:  OPENAI_API_KEY set in the environment.
  - gemini:  GEMINI_API_KEY set in the environment.
  - ollama:  the ollama binary on PATH, with the daemon running
             (default http://127.0.0.1:11434, override with
             AI_OLLAMA_HOST -- deliberately not OLLAMA_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 with LOOMAI_HOST). Last in the default
             provider_order -- see |ai-scope|.

No provider is required at install time -- |:checkhealth| ai reports which
ones are usable on this machine, and provider = "auto" (the default) picks
the first available one in provider_order.

Optional soft dependencies (loaded via pcall(require, ...); absent, the
matching |ai-context| flag is a silent no-op):
- data.nvim -- backs context.structured_data.
- gitsuite.nvim -- backs context.conflict.

3. SETUP *ai-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/keys are lazy-load triggers -- ai.nvim does nothing until :Ai is
run or one of the default <leader>a* keymaps is pressed.

4. CONFIGURATION *ai-config*

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-commands*

                                                                       *:Ai*
:Ai [prompt?]                    Same as :Ai ask [prompt?].

:Ai ask [prompt?]                                                 *:Ai-ask*
  Ask once, non-streaming. Prompts for text if prompt is omitted.

:Ai stream [prompt?]                                            *:Ai-stream*
  Like ask, 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 single u undoes 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 :Ai subcommand 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 via config.context.

6. BINDINGS *ai-bindings*

All keymaps sit under one configurable prefix (config.keymaps.prefix,
default <leader>a) and are individually overridable/disableable via
config.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 *ai-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-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 active Ai.Config table.

                                                                  *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; only claude needs one
    attachments  table?     Ai.Attachment[], see |ai-attachments|
    api_key      string?    overrides the provider's env-var lookup, this
                            request only -- set provider explicitly 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 as cb(ok, res_or_err): res_or_err is an Ai.Response
  table (text, usage, stop_reason, provider) on success, or a
  LibErrorValue on failure, whose kind is one of missing_api_key,
  invalid_request, timeout, network_error, api_error,
  invalid_response, blocked or provider_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 running vim.SystemObj (or nil if no provider could be
  resolved) so a caller can :kill() it to cancel -- ai.ui.panel already
  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 an Ai.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 to config.provider_order to make it reachable through
  provider = "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_data needs
  data.nvim and conflict needs 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 *ai-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 *ai-attachments*

A request can carry binary payloads alongside its prompt -- a rasterized
page, a whole PDF -- through req.attachments, a list of Ai.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.Attachment is three fields -- kind ("image" or "document"),
media_type (an IANA type) and data (base64, unwrapped) -- plus an
optional name used 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}. Returns attachment, nil or
  nil, 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 base64 source
  gemini   image, document    inline_data parts with a mime_type
  openai   image              image_url part whose url is a data: URI
  ollama   image              bare images array 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.

Only claude and gemini take 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 an images field for every model,
llava reads it and llama3.2 ignores it.

Large requests need no configuration. A body over 8 KB is written to a
0600 temp file and read back by curl with --data-binary @file instead 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-context*

Ai.ContextDefaults fields, all boolean, independent (not mutually
exclusive):

  buffer        The current buffer, fenced and line-numbered.
  selection     The last visual selection's range, same format.
  diagnostics   vim.diagnostic.get() for the current buffer, formatted as
                file:line:[severity]:message, sorted by line.
  cwd           A full working-directory sweep via
                lib.nvim.harvest.scope. Expensive; off by default.
  structured_data
                The json/yaml/xml block under the cursor, flattened to
                path: value lines 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's prompt with the
assembled context block, if any. The quick-action keymaps
(|ai-bindings|) use config.context as their default; :Ai ask/`:Ai
stream` build no context of their own.

12. SCOPE *ai-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.

The Ai.Provider interface (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. loomai is 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 default provider_order (default
{"claude", "ollama", "openai", "gemini", "loomai"}, loomai last). Any *other*
provider -- a custom one registered under its own id, or a future built-in
not yet added to provider_order -- stays reachable only by naming it
explicitly (provider = "<name>"), never through "auto" by accident.

13. HEALTH CHECK *ai-health*

:checkhealth ai

Reports:
  - core -- Neovim version (vim.system needs >= 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 that
    lib.nvim.net.curl.fetch_stream exists -- a too-old lib.nvim checkout
    has every other module present but not this one.
  - providers -- every registered provider id and whether available() is
    currently true (missing binary and/or API key otherwise). Never shows a
    key's value, only whether one is set.
  - configuration -- the active provider and provider_order.
  - completion -- enabled?, trigger mode, the resolved
    completion.provider. Warns if trigger = "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 by
    lib.nvim.usercmd.composer.

keys

j / k
next / previous line
gg / G
first / last line
⏎
open the line under the cursor
/
search the plugins
:
command line — Tab completes
:help x
vimdoc of a plugin (:e x = plugin page)
:ls · :log · :stack
plugin list · activity stream · dependency graph
:colo x
colorscheme
:set skin=
modern | tui
?
this help
esc
close