AI-suggested code actions for Neovim. Complements (does not replace) the stock LSP code-action flow on gra.
Six entry points:
grA— quickfix actions (bug fixes, edge-case hardening). Pick from an inline-diff picker.grE(:SmartActionExplain) — prose explanation of a diagnostic / tricky code, streamed into a floating window. Pivot to quickfix witha/<CR>.grS(:SmartActionSuppress) — language-appropriate suppression-comment actions for LSP diagnostics (pyright-ignore, ts-expect-error, allow, noqa, etc.) without modifying logic.grR(:SmartActionRefactor) — behaviour-preserving refactors (extract, inline, simplify, replace-mutation-with-functional). Explicitly not a bug-fix or styling category.grT(:SmartActionTests) — generate ONE test for the function under cursor, framework auto-detected (pytest / vitest /#[test]/ Go testing / plenary). Currently appends to the current file (multi-file test-file placement is a deferred feature).grV(:SmartActionReview) — broad review with[blocker]/[suggestion]/[nit]/[question]severity tags. Items may be fixes (have a diff, apply normally) or observations (rationale-only, surface as a notification).
All six share the same scope picker (line / function / file / folder / project / auto / visual), the same provider layer (Claude Code CLI → Anthropic API → OpenAI-compatible auto-fallback), and the same pluggable context system.
v0.11.0. Categories shipped: quickfix, explain, suppress, refactor, tests, review. Providers shipped: claude_code (CLI), anthropic (API), openai (generic OpenAI-compatible). The openai provider covers OpenAI itself, Ollama, LM Studio, OpenRouter, Groq, Gemini-compat, and any other /chat/completions-speaking endpoint; see the "Using other AI models" section for ready-to-copy recipes. Default anthropic model is claude-sonnet-4-6. Quickfix has two modes: cursor-focus (non-visual scopes, cap 3) and region-fix (visual selection, dynamic cap up to quickfix_region_max_actions, default 10). Cursor-line diagnostics are split into an "AT cursor column" priority tier. Folder/project enumeration streams through vim.system (file_scan_timeout_ms, default 500ms).
{
"Chiarandini/smart-actions.nvim",
keys = {
{ "grA", mode = { "n", "x" }, desc = "smart code [A]ction" },
{ "grE", function() require("smart_actions").explain() end, desc = "smart action: [E]xplain" },
{ "grS", function() require("smart_actions").suppress() end, desc = "smart action: [S]uppress diagnostic" },
{ "grR", function() require("smart_actions").refactor() end, desc = "smart action: [R]efactor" },
{ "grT", function() require("smart_actions").tests() end, desc = "smart action: generate [T]est" },
{ "grV", function() require("smart_actions").review() end, desc = "smart action: re[V]iew" },
},
cmd = {
"SmartAction", "SmartActionCancel", "SmartActionLastDiff",
"SmartActionExplain", "SmartActionSuppress", "SmartActionRefactor",
"SmartActionTests", "SmartActionReview",
},
opts = {
default_scope = "ask", -- or "auto" / "line" / "function" / ...
categories = { "quickfix" },
-- eager_action_after_explain = true, -- opt-in: pre-warm quickfix while you read the explanation
},
config = function(_, opts) require("smart_actions").setup(opts) end,
}At least one of:
claudeCLI on$PATH— uses your existing Claude Code auth, no key setup needed, orANTHROPIC_API_KEYin env orlua/secrets.luaas{ anthropic = { api_key = "..." } }, orOPENAI_API_KEYin env (pluscurlon$PATH) — also works with any OpenAI-compatible service viaprovider_config.openai.base_url; see "Using other AI models" below for Ollama / OpenRouter / Groq / Gemini / xAI recipes.
Providers are probed in registration order (claude_code → anthropic → openai) and the first that passes wins; force a specific one with opts.provider = "..." or reorder via opts.probe_order.
Optional but recommended:
snacks.nvim— the results picker uses Snacks's built-indiffpreviewer so the unified diff renders inline next to the action list. Without snacks, the plugin falls back tovim.ui.select(flat menu; no inline preview).deltaon$PATH— if present, Snacks's diff previewer uses it for colored, side-by-side diff rendering.
Default model for the anthropic provider is claude-sonnet-4-6 — the code-actions sweet spot (roughly half the latency of Opus at near-identical quality on scope-bounded edits). Override for judgment-heavy work:
opts = {
provider_config = {
anthropic = { model = "claude-opus-4-7" }, -- or a Haiku id for speed
},
}The claude_code provider uses whatever model Claude Code itself is configured with — set it via the CLI's /model command or your Claude Code config.
Three providers ship built-in: claude_code (local CLI), anthropic (Messages API), and openai — a generic OpenAI-compatible /chat/completions client that talks to basically any service speaking that wire format. One provider, many endpoints: OpenAI, Ollama (local), LM Studio, OpenRouter, Groq, Together, xAI, Gemini-compat, and any self-hosted OpenAI-compatible server.
Set OPENAI_API_KEY and force the provider:
opts = {
provider = "openai",
provider_config = { openai = { model = "gpt-5" } },
}Point base_url at your local endpoint and disable auth with api_key_env = "":
opts = {
provider = "openai",
provider_config = {
openai = {
base_url = "http://localhost:11434/v1",
model = "qwen3-coder",
api_key_env = "", -- no auth for local Ollama
},
},
}LM Studio (:1234/v1), LocalAI, llama.cpp server — same shape, different base_url.
OpenRouter brokers hundreds of models behind one API key:
opts = {
provider = "openai",
provider_config = {
openai = {
base_url = "https://openrouter.ai/api/v1",
model = "anthropic/claude-sonnet-4-6", -- or any OR model slug
api_key_env = "OPENROUTER_API_KEY",
extra_headers = {
["HTTP-Referer"] = "https://github.com/Chiarandini/smart-actions.nvim",
["X-Title"] = "smart-actions.nvim",
},
},
},
}opts = {
provider = "openai",
provider_config = {
openai = {
base_url = "https://api.groq.com/openai/v1",
model = "llama-3.3-70b-versatile",
api_key_env = "GROQ_API_KEY",
},
},
}Google exposes an OpenAI-compatible surface for Gemini, so the same provider works:
opts = {
provider = "openai",
provider_config = {
openai = {
base_url = "https://generativelanguage.googleapis.com/v1beta/openai",
model = "gemini-2.5-flash",
api_key_env = "GEMINI_API_KEY",
},
},
}If you want more than one OpenAI-compatible endpoint live simultaneously (e.g. OpenAI for cloud + Ollama for offline fallback in probe_order), use the factory:
local openai = require("smart_actions.providers.openai")
require("smart_actions").setup({
probe_order = { "claude_code", "anthropic", "openai", "ollama" },
provider_config = {
openai = { model = "gpt-5" },
ollama = { base_url = "http://localhost:11434/v1", model = "qwen3-coder", api_key_env = "" },
},
})
require("smart_actions.providers").register(
openai.define({
id = "ollama",
base_url = "http://localhost:11434/v1",
model = "qwen3-coder",
api_key_env = "",
})
)Each define(spec) call returns a first-class provider with its own id, so it slots into probe_order and provider_config[id] like any built-in.
Scope picker (if default_scope = "ask") → AI streams → results picker opens with all actions, inline diff preview in the side pane:
<CR>applies the highlighted action's diff (or all Tab-selected actions — see below).<Tab>/<S-Tab>toggle-select actions for multi-apply. Selected actions apply sequentially with a single undo unit; any whose context can't land on the mutated buffer is silently skipped and reported ("N of M applied, K skipped").<C-e>opens the diff in a scratch buffer for hand-editing;:wapplies the (possibly edited) patch,:q!cancels. Always targets the hovered action, even if others are Tab-selected.<Esc>dismisses without applying.
Two modes, picked automatically from the scope shape:
- Cursor-focus — the default for
line/function/file/folder/projectscopes. The AI biases hard toward the trigger line (the diagnostic at the cursor column first, then the rest of the cursor line, then the rest of the scope). Cap: 3 actions. - Region-fix — triggered by a visual selection. The AI ignores cursor position and tries to fix every real bug in the selection, one action per distinct concern. Cap scales with diagnostic count:
clamp(3, n_diagnostics + 2, quickfix_region_max_actions). So a clean selection still gets 3, a selection with 5 diagnostics gets 7, a giant buggy selection gets capped at the ceiling (default 10). Raise the ceiling viaopts.quickfix_region_max_actionsif you regularly select large regions with many lint warnings.
The distinction matches the intent: function scope means "include this context when reasoning about the one thing I'm pointing at", while visual select means "fix everything in here".
Every apply is a single undo unit — u reverts the full action cleanly (including multi-select bundles). The applier is anchor-by-context: if the AI's hunk header is slightly off, hunks relocate to where the body's context lines actually match the buffer (like git apply), so minor drift doesn't corrupt the edit.
Streams a prose explanation into a bordered floating window — useful when an LSP diagnostic is cryptic or a piece of code looks wrong but you're not sure why. In the float:
a/<CR>— close and pivot to quickfix on the same scope ("OK, now fix it").q/<Esc>— dismiss without further action.
The active keybindings are shown in the float's bottom border so they don't need to be memorised.
Same picker UX as quickfix, but each action is a behaviour-preserving refactor — extract a helper, inline a variable, simplify a conditional, replace a mutation loop with a functional expression. Explicitly forbidden from this category: bug fixes (use grA), stylistic tweaks, renames, comments. Returns nothing when the scope has no clear refactor opportunity.
Generates ONE test for the function or method closest to the cursor, framework auto-detected from the file extension + existing imports:
- Python:
def test_xxx():+assert - TypeScript / JavaScript: vitest or jest (
describe/it/expect) - Rust:
#[test]inside#[cfg(test)] mod tests {} - Go:
func TestXxx(t *testing.T)(only if the file is a_test.go) - Lua: plenary
describe/itif present, elseassert(...)block
The test is appended to the current file (scope is forced to file). Multi-file placement — creating or extending a separate tests/test_foo.py-style file — is a deferred v2.x feature; in the meantime you can move the generated test by hand.
Broad feedback, explicitly opted-in. Each item carries a severity tag: [blocker] / [suggestion] / [nit] / [question]. Items may be either:
- Fixes — have a unified_diff, apply normally via
<CR>like any other category. - Observations — rationale-only (empty diff). The picker renders their rationale as markdown in the preview pane; hitting
<CR>surfaces the rationale as a notification rather than trying to apply nothing.
Use this when you want opinions that grA / grR deliberately leave out (style, naming, design judgement, clarifying questions).
Same picker UX as quickfix, but each action is a language-appropriate suppression comment rather than a code fix. No logic is modified. Supports:
- Python:
# pyright: ignore[...],# type: ignore,# noqa - TypeScript / JavaScript:
// @ts-expect-error,// eslint-disable-next-line - Rust:
#[allow(...)] - Go:
//nolint:... - Shell:
# shellcheck disable=... - Lua:
---@diagnostic disable-next-line: ...
Returns nothing when there are no LSP diagnostics to suppress.
When eager_action_after_explain = true in setup, the quickfix category starts streaming in the background the moment an explain stream finishes. If you press a/<CR> in the float, the picker opens (near-)instantly — the read time has hidden the latency. If you press q/<Esc>, the in-flight quickfix is cancelled. Trade-off: roughly doubles token cost on any explain that's dismissed before the background quickfix completes. Default off.
Most new providers are just OpenAI-compatible endpoints — use providers.openai.define(spec) above. The full register() API is only needed when your provider speaks a different wire format (Gemini's native REST, a proprietary internal API, a CLI that isn't claude, etc.).
require("smart_actions.providers").register({
id = "my_cli",
display_name = "My custom LLM CLI",
probe = function()
return vim.fn.executable("my-llm") == 1, "my-llm not on $PATH"
end,
stream = function(req, cb)
-- req: { system, messages, opts, timeout_ms }
-- opts = config.provider_config.my_cli or {}
-- cb: { on_text(chunk), on_done(), on_error(err) }
-- return: a cancel function that kills any in-flight work
end,
})
-- Then in setup():
require("smart_actions").setup({
probe_order = { "my_cli", "claude_code", "anthropic", "openai" },
provider_config = { my_cli = { ... } },
})For the common case (any OpenAI-compatible endpoint) the factory form is much shorter — see the previous section.
Context providers inject project knowledge (rules, idioms, conventions) into the system prompt ahead of every request. Five built-ins self-register at setup: claude_md, agents_md, cursorrules, neovim_plugin, language_default. You add your own with register():
-- Tiny: always-on personal style
require("smart_actions.context").register({
id = "my_style", priority = 150,
detect = function(_) return true end,
gather = function(_) return "Use 2-space indents. Prefer explicit returns." end,
})
-- Medium: framework / project detection
require("smart_actions.context").register({
id = "rust_workspace", priority = 120,
detect = function(root)
return vim.uv.fs_stat(root .. "/Cargo.toml") ~= nil
end,
gather = function(_)
return "Rust workspace. Prefer `?` for error propagation. No panics in lib code."
end,
})
-- Rich: pull content from disk
require("smart_actions.context").register({
id = "contributing", priority = 95,
detect = function(root)
return vim.uv.fs_stat(root .. "/CONTRIBUTING.md") ~= nil
end,
gather = function(scope)
local root = require("smart_actions.util").project_root(scope.trigger.file)
local f = io.open(root .. "/CONTRIBUTING.md", "r")
if not f then return "" end
local content = f:read("*a"); f:close()
return "--- CONTRIBUTING.md ---\n" .. content
end,
})Contract:
id— unique string, used for budget bookkeeping andhandles_nativelyfilteringpriority— higher wins when themax_charsbudget is tight (default 0)detect(root)— cheap boolean, called once pergrAgather(scope)— returns the context block text;""means "skip"
Config knobs (all optional):
context = {
enabled = true,
max_chars = 4000, -- total budget; low-priority dropped first
allowlist = { "claude_md" }, -- nil = all matching
denylist = { "language_default" },
}Inspect what the AI sees:
:lua =require("smart_actions.context").assemble(
require("smart_actions.scope").get("function"),
require("smart_actions.providers").active())Provider / context coordination: if your AI provider already consumes a context source natively (e.g. Claude Code auto-reads CLAUDE.md), list the context id in the provider's handles_natively field — the assembler will skip it for that provider only.
Two suites under tests/:
-
diff_spec.lua— fast, offline unit tests for the unified-diff parser & applier. Includes the malformed-header regression (AI header claimsold=8but body has 7, applier trusts the body).NVIM_APPNAME=noethervim nvim --headless \ --cmd 'set rtp+=~/programming/custom_plugins/smart-actions.nvim' \ -c 'luafile tests/diff_spec.lua' -c 'qa!'
-
e2e_spec.lua— end-to-end: each test opens a deliberately-buggy fixture, runs the full pipeline (scope → context → category → real Claude Code stream → apply), and asserts shape properties (action count, apply succeeds within scope bounds, bug gone after apply, edit distance from cursor). Gated bySA_E2E=1. Covers all 7 scopes (line/function/file/folder/project/visual/auto), five languages (Python, Rust, Go, TypeScript; LaTeX if your env permits), every shipped category (quickfix cursor-mode + region-mode, explain, suppress, refactor, tests, review), multi-hunk, syntax-broken input, and dirty-buffer apply../tests/run-e2e.sh # ~20 cases, ~2 min total -
variance.lua— an N-run harness for measuring AI variance on a fixed fixture. Reports deletions/additions/max-distance-from-cursor per run so you can see if a prompt tweak is narrowing or widening the AI's output.SA_RUNS=10 ./tests/run-variance.sh
:help smart-actions — full vimdoc, including context & provider extension points.