Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

smart-actions.nvim

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 with a/<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.

Status

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

Install

{
  "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,
}

Requires

At least one of:

  • claude CLI on $PATH — uses your existing Claude Code auth, no key setup needed, or
  • ANTHROPIC_API_KEY in env or lua/secrets.lua as { anthropic = { api_key = "..." } }, or
  • OPENAI_API_KEY in env (plus curl on $PATH) — also works with any OpenAI-compatible service via provider_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-in diff previewer so the unified diff renders inline next to the action list. Without snacks, the plugin falls back to vim.ui.select (flat menu; no inline preview).
  • delta on $PATH — if present, Snacks's diff previewer uses it for colored, side-by-side diff rendering.

Model

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.

Using other AI models

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.

OpenAI

Set OPENAI_API_KEY and force the provider:

opts = {
  provider = "openai",
  provider_config = { openai = { model = "gpt-5" } },
}

Ollama (or any local OpenAI-compatible server)

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

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",
      },
    },
  },
}

Groq

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",
    },
  },
}

Gemini (via OpenAI-compat endpoint)

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",
    },
  },
}

Multiple endpoints at once

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.

UX

Quickfix (grA)

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; :w applies 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 / project scopes. 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 via opts.quickfix_region_max_actions if 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.

Explain (grE)

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.

Refactor (grR)

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.

Tests (grT)

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 / it if present, else assert(...) 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.

Review (grV)

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

Suppress (grS)

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.

Eager action after explain (opt-in)

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.

Adding your own provider

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.

Adding your own context

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 and handles_natively filtering
  • priority — higher wins when the max_chars budget is tight (default 0)
  • detect(root) — cheap boolean, called once per grA
  • gather(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.

Testing

Two suites under tests/:

  • diff_spec.lua — fast, offline unit tests for the unified-diff parser & applier. Includes the malformed-header regression (AI header claims old=8 but 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 by SA_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

Docs

:help smart-actions — full vimdoc, including context & provider extension points.

About

AI-assisted code actions: quickfix, explain, suppress, refactor, tests

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages