AI completion in Neovim: Codeium, Gemini and nvim-cmp
- Published on
- ·5 min read
Why three completion layers?
Most people think completion means LSP. It's the standard. But generative AI exploded and suddenly you've got options. You can't just dump everything into one menu - that'd be a mess.
So I built a three-layer system. Not one. Three. Each one handles a specific job, has its own interaction model.
The idea: each layer solves a different problem. Ghost text for passive suggestions that just happen. An AI model inside the cmp menu for more involved cases. And regular nvim-cmp for LSP, snippets, everything else.
Layer 1: NeoCodeium (ghost text)
NeoCodeium. The Neovim client for Codeium. Why does it work?
- Free and unlimited: no token counting. No subscription gates.
- Ghost text: suggestions appear grayed-out, inline in the buffer. No popup stealing screen space.
- Zero conflicts with nvim-cmp: you can run both and they don't interfere.
Ghost text is perfect for obvious stuff: closing a paren, finishing a variable name, basic boilerplate. Accept with a shortcut, or keep typing and it vanishes.
{
"monkoose/neocodeium",
event = "VeryLazy",
config = function()
local neocodeium = require("neocodeium")
neocodeium.setup()
-- Alt-f to accept the suggestion
vim.keymap.set("i", "<A-f>", neocodeium.accept)
-- Alt-n / Alt-p to cycle through suggestions
vim.keymap.set("i", "<A-n>", neocodeium.cycle_or_complete)
vim.keymap.set("i", "<A-p>", function()
neocodeium.cycle_or_complete(-1)
end)
-- Alt-c to clear the suggestion
vim.keymap.set("i", "<A-c>", neocodeium.clear)
end,
}
Keybindings are chosen to avoid any conflict with nvim-cmp. Alt-f to accept? Muscle memory now. Fast, accessible, doesn't step on anything.
Layer 2: Minuet AI (Gemini in the cmp menu)
Minuet. Integrates Gemini directly into the nvim-cmp menu. Unlike ghost text (which is passive), this is an on-demand source that shows up in the same menu as LSP.
{
"milanglacier/minuet-ai.nvim",
config = function()
require("minuet").setup({
provider = "gemini",
provider_options = {
gemini = {
model = "gemini-2.5-flash",
api_key = "GEMINI_API_KEY",
},
},
cmp = {
enable_auto_complete = false,
},
})
end,
}
A few important details:
- The gemini-2.5-flash model balances speed and quality well
- The API key comes from the
GEMINI_API_KEYenvironment variable enable_auto_complete = false: Gemini doesn't fire automatically. You trigger it with Alt-y in the cmp menu.
Intentional. Generative AI is useful for heavier stuff - refactoring, complex patterns, docs. You don't want it slowing down your normal typing. By making it on-demand, you stay in control.
Layer 3: nvim-cmp (the main engine)
At the heart is nvim-cmp. The completion engine. It orchestrates all the sources. Manages the popup, the navigation, the confirmation.
{
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-nvim-lsp",
"hrsh7th/cmp-buffer",
"hrsh7th/cmp-path",
"saadparwaiz1/cmp_luasnip",
},
config = function()
local cmp = require("cmp")
local luasnip = require("luasnip")
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
completion = { completeopt = "menu,menuone,noinsert" },
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
performance = {
fetching_timeout = 2000,
},
mapping = cmp.mapping.preset.insert({
["<C-n>"] = cmp.mapping.select_next_item(),
["<C-p>"] = cmp.mapping.select_prev_item(),
["<C-Space>"] = cmp.mapping.complete(),
["<C-e>"] = cmp.mapping.abort(),
["<CR>"] = cmp.mapping.confirm({ select = true }),
["<A-y>"] = require("minuet").make_cmp_map(),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
elseif luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
else
fallback()
end
end, { "i", "s" }),
["<S-Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item()
elseif luasnip.jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, { "i", "s" }),
}),
sources = cmp.config.sources({
{ name = "minuet" },
{ name = "nvim_lsp" },
{ name = "luasnip" },
{ name = "path" },
}, {
{ name = "buffer" },
}),
})
end,
}
Several key choices here:
- Sources by priority: Minuet (AI) first, then LSP, LuaSnip, path, buffer as fallback
- Bordered windows: the cmp menu and doc popup both have borders. Cleaner.
- 2-second timeout:
fetching_timeout = 2000ensures a sluggy AI source doesn't block your entire workflow - Smart Tab/S-Tab: navigate the cmp menu when it's open. Otherwise jump between LuaSnip positions
LuaSnip: the snippet engine
Alongside completion, LuaSnip handles snippets with the friendly-snippets pack, which provides VSCode-style snippets for all common languages.
{
"L3MON4D3/LuaSnip",
build = "make install_jsregexp",
dependencies = {
{
"rafamadriz/friendly-snippets",
config = function()
require("luasnip.loaders.from_vscode").lazy_load()
end,
},
},
}
Snippets are triggered through nvim-cmp, and you navigate between positions using Tab/S-Tab thanks to the mapping described above.
How it all coexists
The critical thing about this architecture is that all three layers stay out of each other's way:
- NeoCodeium shows ghost text inline. Doesn't touch the cmp menu.
- nvim-cmp handles the standard completion popup: LSP, snippets, path.
- Minuet integrates as a cmp source. Only triggers manually with Alt-y.
In practice, when I'm coding:
- Codeium ghost text shows in gray. Good? Alt-f to accept.
- Want LSP results? The cmp menu opens auto or I hit C-Space.
- Need a fancier AI suggestion? Alt-y queries Gemini without leaving the cmp menu.
The 2-second timeout on cmp? Critical. If Gemini drags, completion keeps going with other sources. No freeze. No waiting. Constant flow.
The wrap-up
This three-layer system gives me the best of both worlds: AI for intelligent suggestions, LSP for precision. Each layer has its own interaction mode. No cognitive overload. You always know where a suggestion comes from and how to accept it. It's a setup that runs every day on my TypeScript and Python projects without missing a beat.
Neovim from scratch series — This article is part of a complete series on configuring Neovim from scratch.
Previous: Terminal, Git and global search integrated in Neovim