feat(ai): better completion/suggestions of AI engines (#4752)

## Description

The whole completion / snippets / AI is very tricky:
- multiple snippet engines
- native snippets on > 0.11 set their own keymaps, but not on 0.10
- multiple completion engines, like `nvim-cmp` and `blink.cmp`
- multiple ai completion engines that have a different API
- user's preference of showing ai suggestions as completion or not
- none of the ai completion engines currently set undo points, which is
bad

Solution:
- [x] added `LazyVim.cmp.actions`, where snippet engines and ai engines
can register their action.
- [x] an action returns `true` if it succeeded, or `false|nil` otherwise
- [x] in a completion engine, we then try running multiple actions and
use the fallback if needed
- [x] so `<tab>` runs `{"snippet_forward", "ai_accept", "fallback"}`
- [x] added `vim.g.ai_cmp`. When `true` we try to integrate the AI
source in the completion engine.
- [x] when `false`, `<tab>` should be used to insert the AI suggestion
- [x] when `false`, the completion engine's ghost text is disabled
- [x] luasnip support for blink (only works with blink `main`)
- [x] create undo points when accepting AI suggestions 

## Test Matrix

| completion   | snippets     | ai          | ai_cmp | tested? |
|--------------|--------------|-------------|--------|---------|
| nvim-cmp     | native       | copilot     | true   |       |
| nvim-cmp     | native       | copilot     | false  |       |
| nvim-cmp     | native       | codeium     | true   |       |
| nvim-cmp     | native       | codeium     | false  |       |
| nvim-cmp     | luasnip      | copilot     | true   |       |
| nvim-cmp     | luasnip      | copilot     | false  |       |
| nvim-cmp     | luasnip      | codeium     | true   |       |
| nvim-cmp     | luasnip      | codeium     | false  |       |
| blink.cmp    | native       | copilot     | true   |       |
| blink.cmp    | native       | copilot     | false  |       |
| blink.cmp    | native       | codeium     | true   |       |
| blink.cmp    | native       | codeium     | false  |       |
| blink.cmp    | luasnip      | copilot     | true   |       |
| blink.cmp    | luasnip      | copilot     | false  |       |
| blink.cmp    | luasnip      | codeium     | true   |       |
| blink.cmp    | luasnip      | codeium     | false  |       |


## Related Issue(s)

- [ ] Closes #4702

## Screenshots

<!-- Add screenshots of the changes if applicable. -->

## Checklist

- [ ] I've read the
[CONTRIBUTING](https://github.com/LazyVim/LazyVim/blob/main/CONTRIBUTING.md)
guidelines.
This commit is contained in:
Folke Lemaitre 2024-11-11 10:50:57 +01:00 committed by GitHub
parent c22db72435
commit fbf881f80b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 230 additions and 99 deletions

View file

@ -180,3 +180,13 @@ map("n", "<leader><tab><tab>", "<cmd>tabnew<cr>", { desc = "New Tab" })
map("n", "<leader><tab>]", "<cmd>tabnext<cr>", { desc = "Next Tab" }) map("n", "<leader><tab>]", "<cmd>tabnext<cr>", { desc = "Next Tab" })
map("n", "<leader><tab>d", "<cmd>tabclose<cr>", { desc = "Close Tab" }) map("n", "<leader><tab>d", "<cmd>tabclose<cr>", { desc = "Close Tab" })
map("n", "<leader><tab>[", "<cmd>tabprevious<cr>", { desc = "Previous Tab" }) map("n", "<leader><tab>[", "<cmd>tabprevious<cr>", { desc = "Previous Tab" })
-- native snippets. only needed on < 0.11, as 0.11 creates these by default
if vim.fn.has("nvim-0.11") == 0 then
map("s", "<Tab>", function()
return vim.snippet.active({ direction = 1 }) and "<cmd>lua vim.snippet.jump(1)<cr>" or "<Tab>"
end, { expr = true, desc = "Jump Next" })
map({ "i", "s" }, "<S-Tab>", function()
return vim.snippet.active({ direction = -1 }) and "<cmd>lua vim.snippet.jump(-1)<cr>" or "<S-Tab>"
end, { expr = true, desc = "Jump Previous" })
end

View file

@ -11,6 +11,10 @@ vim.g.autoformat = true
-- enabled with `:LazyExtras` -- enabled with `:LazyExtras`
vim.g.lazyvim_picker = "auto" vim.g.lazyvim_picker = "auto"
-- if the completion engine supports the AI source,
-- use that instead of inline suggestions
vim.g.ai_cmp = true
-- LazyVim root dir detection -- LazyVim root dir detection
-- Each entry can be: -- Each entry can be:
-- * the name of a detector function like `lsp` or `cwd` -- * the name of a detector function like `lsp` or `cwd`

View file

@ -43,6 +43,9 @@ return {
cmp.abort() cmp.abort()
fallback() fallback()
end, end,
["<tab>"] = function(fallback)
return LazyVim.cmp.map({ "snippet_forward", "ai_accept" }, fallback)()
end,
}), }),
sources = cmp.config.sources({ sources = cmp.config.sources({
{ name = "nvim_lsp" }, { name = "nvim_lsp" },
@ -72,9 +75,10 @@ return {
end, end,
}, },
experimental = { experimental = {
ghost_text = { -- only show ghost text when we show ai completions
ghost_text = vim.g.ai_cmp and {
hl_group = "CmpGhostText", hl_group = "CmpGhostText",
}, } or false,
}, },
sorting = defaults.sorting, sorting = defaults.sorting,
} }
@ -105,17 +109,6 @@ return {
table.insert(opts.sources, { name = "snippets" }) table.insert(opts.sources, { name = "snippets" })
end end
end, end,
init = function()
-- Neovim enabled snippet navigation mappings by default in v0.11
if vim.fn.has("nvim-0.11") == 0 then
vim.keymap.set({ "i", "s" }, "<Tab>", function()
return vim.snippet.active({ direction = 1 }) and "<cmd>lua vim.snippet.jump(1)<cr>" or "<Tab>"
end, { expr = true, silent = true })
vim.keymap.set({ "i", "s" }, "<S-Tab>", function()
return vim.snippet.active({ direction = -1 }) and "<cmd>lua vim.snippet.jump(-1)<cr>" or "<S-Tab>"
end, { expr = true, silent = true })
end
end,
}, },
-- auto pairs -- auto pairs

View file

@ -1,18 +1,42 @@
return { return {
-- codeium cmp source
{
"nvim-cmp",
dependencies = {
-- codeium -- codeium
{ {
"Exafunction/codeium.nvim", "Exafunction/codeium.nvim",
cmd = "Codeium", cmd = "Codeium",
build = ":Codeium Auth", build = ":Codeium Auth",
opts = {}, opts = {
enable_cmp_source = vim.g.ai_cmp,
virtual_text = {
enabled = not vim.g.ai_cmp,
key_bindings = {
accept = false, -- handled by nvim-cmp / blink.cmp
next = "<M-]>",
prev = "<M-[>",
}, },
}, },
---@param opts cmp.ConfigSchema },
},
-- add ai_accept action
{
"Exafunction/codeium.nvim",
opts = function()
LazyVim.cmp.actions.ai_accept = function()
if require("codeium.virtual_text").get_current_completion_item() then
LazyVim.create_undo()
vim.api.nvim_input(require("codeium.virtual_text").accept())
return true
end
end
end,
},
-- codeium cmp source
{
"nvim-cmp",
optional = true,
dependencies = { "codeium.nvim" },
opts = function(_, opts) opts = function(_, opts)
table.insert(opts.sources, 1, { table.insert(opts.sources, 1, {
name = "codeium", name = "codeium",
@ -30,4 +54,18 @@ return {
table.insert(opts.sections.lualine_x, 2, LazyVim.lualine.cmp_source("codeium")) table.insert(opts.sections.lualine_x, 2, LazyVim.lualine.cmp_source("codeium"))
end, end,
}, },
{
"saghen/blink.cmp",
optional = true,
opts = {
sources = {
compat = vim.g.ai_cmp and { "codeium" } or nil,
},
},
dependencies = {
"codeium.nvim",
vim.g.ai_cmp and "saghen/blink.compat" or nil,
},
},
} }

View file

@ -5,8 +5,17 @@ return {
"zbirenbaum/copilot.lua", "zbirenbaum/copilot.lua",
cmd = "Copilot", cmd = "Copilot",
build = ":Copilot auth", build = ":Copilot auth",
event = "InsertEnter",
opts = { opts = {
suggestion = { enabled = false }, suggestion = {
enabled = not vim.g.ai_cmp,
auto_trigger = true,
keymap = {
accept = false, -- handled by nvim-cmp / blink.cmp
next = "<M-]>",
prev = "<M-[>",
},
},
panel = { enabled = false }, panel = { enabled = false },
filetypes = { filetypes = {
markdown = true, markdown = true,
@ -14,6 +23,22 @@ return {
}, },
}, },
}, },
-- add ai_accept action
{
"zbirenbaum/copilot.lua",
opts = function()
LazyVim.cmp.actions.ai_accept = function()
if require("copilot.suggestion").is_visible() then
LazyVim.create_undo()
require("copilot.suggestion").accept()
return true
end
end
end,
},
-- lualine
{ {
"nvim-lualine/lualine.nvim", "nvim-lualine/lualine.nvim",
optional = true, optional = true,
@ -55,22 +80,24 @@ return {
-- copilot cmp source -- copilot cmp source
{ {
"nvim-cmp", "nvim-cmp",
dependencies = { optional = true,
dependencies = { -- this will only be evaluated if nvim-cmp is enabled
{ {
"zbirenbaum/copilot-cmp", "zbirenbaum/copilot-cmp",
dependencies = "copilot.lua", enabled = vim.g.ai_cmp, -- only enable if wanted
opts = {}, opts = {},
config = function(_, opts) config = function(_, opts)
local copilot_cmp = require("copilot_cmp") local copilot_cmp = require("copilot_cmp")
copilot_cmp.setup(opts) copilot_cmp.setup(opts)
-- attach cmp source whenever copilot attaches -- attach cmp source whenever copilot attaches
-- fixes lazy-loading issues with the copilot cmp source -- fixes lazy-loading issues with the copilot cmp source
LazyVim.lsp.on_attach(function(client) LazyVim.lsp.on_attach(function()
copilot_cmp._on_insert_enter({}) copilot_cmp._on_insert_enter({})
end, "copilot") end, "copilot")
end, end,
}, specs = {
}, {
"nvim-cmp",
---@param opts cmp.ConfigSchema ---@param opts cmp.ConfigSchema
opts = function(_, opts) opts = function(_, opts)
table.insert(opts.sources, 1, { table.insert(opts.sources, 1, {
@ -80,45 +107,23 @@ return {
}) })
end, end,
}, },
},
},
},
},
-- blink.cmp
{ {
"saghen/blink.cmp", "saghen/blink.cmp",
optional = true, optional = true,
opts = {
windows = { ghost_text = { enabled = false } },
},
specs = { specs = {
-- blink has no copilot source, so force enable suggestions
{ {
"zbirenbaum/copilot.lua", "zbirenbaum/copilot.lua",
event = "InsertEnter", opts = { suggestion = { enabled = true } },
opts = {
suggestion = {
enabled = true,
auto_trigger = true,
keymap = { accept = false },
},
},
},
},
opts = {
windows = {
ghost_text = {
enabled = false,
},
},
keymap = {
["<Tab>"] = {
function(cmp)
if cmp.is_in_snippet() then
return cmp.accept()
elseif require("copilot.suggestion").is_visible() then
LazyVim.create_undo()
require("copilot.suggestion").accept()
return true
else
return cmp.select_and_accept()
end
end,
"snippet_forward",
"fallback",
},
}, },
}, },
}, },

View file

@ -1,3 +1,10 @@
if lazyvim_docs then
-- set to `true` to follow the main branch
-- you need to have a working rust toolchain to build the plugin
-- in this case.
vim.g.lazyvim_blink_main = false
end
return { return {
{ {
"hrsh7th/nvim-cmp", "hrsh7th/nvim-cmp",
@ -5,8 +12,12 @@ return {
}, },
{ {
"saghen/blink.cmp", "saghen/blink.cmp",
version = "*", version = not vim.g.lazyvim_blink_main and "*",
opts_extend = { "sources.completion.enabled_providers" }, build = vim.g.lazyvim_blink_main and "cargo build --release",
opts_extend = {
"sources.completion.enabled_providers",
"sources.compat",
},
dependencies = { dependencies = {
"rafamadriz/friendly-snippets", "rafamadriz/friendly-snippets",
-- add blink.compat to dependencies -- add blink.compat to dependencies
@ -35,7 +46,7 @@ return {
auto_show = true, auto_show = true,
}, },
ghost_text = { ghost_text = {
enabled = true, enabled = vim.g.ai_cmp,
}, },
}, },
@ -45,6 +56,9 @@ return {
-- experimental signature help support -- experimental signature help support
-- trigger = { signature_help = { enabled = true } } -- trigger = { signature_help = { enabled = true } }
sources = { sources = {
-- adding any nvim-cmp sources here will enable them
-- with blink.compat
compat = {},
completion = { completion = {
-- remember to enable your providers here -- remember to enable your providers here
enabled_providers = { "lsp", "path", "snippets", "buffer" }, enabled_providers = { "lsp", "path", "snippets", "buffer" },
@ -53,9 +67,29 @@ return {
keymap = { keymap = {
preset = "enter", preset = "enter",
["<Tab>"] = {
LazyVim.cmp.map({ "snippet_forward", "ai_accept" }),
"fallback",
}, },
}, },
}, },
---@param opts blink.cmp.Config | { sources: { compat: string[] } }
config = function(_, opts)
-- setup compat sources
local enabled = opts.sources.completion.enabled_providers
for _, source in ipairs(opts.sources.compat or {}) do
opts.sources.providers[source] = vim.tbl_deep_extend(
"force",
{ name = source, module = "blink.compat.source" },
opts.sources.providers[source] or {}
)
if type(enabled) == "table" and not vim.tbl_contains(enabled, source) then
table.insert(enabled, source)
end
end
require("blink.cmp").setup(opts)
end,
},
-- add icons -- add icons
{ {

View file

@ -1,4 +1,8 @@
return { return {
-- disable builtin snippet support
{ "garymjr/nvim-snippets", enabled = false },
-- add luasnip
{ {
"L3MON4D3/LuaSnip", "L3MON4D3/LuaSnip",
lazy = true, lazy = true,
@ -12,11 +16,31 @@ return {
require("luasnip.loaders.from_vscode").lazy_load() require("luasnip.loaders.from_vscode").lazy_load()
end, end,
}, },
},
opts = {
history = true,
delete_check_events = "TextChanged",
},
},
-- add snippet_forward action
{
"L3MON4D3/LuaSnip",
opts = function()
LazyVim.cmp.actions.snippet_forward = function()
if require("luasnip").jumpable(1) then
require("luasnip").jump(1)
return true
end
end
end,
},
-- nvim-cmp integration
{ {
"nvim-cmp", "nvim-cmp",
dependencies = { optional = true,
"saadparwaiz1/cmp_luasnip", dependencies = { "saadparwaiz1/cmp_luasnip" },
},
opts = function(_, opts) opts = function(_, opts)
opts.snippet = { opts.snippet = {
expand = function(args) expand = function(args)
@ -25,30 +49,23 @@ return {
} }
table.insert(opts.sources, { name = "luasnip" }) table.insert(opts.sources, { name = "luasnip" })
end, end,
},
},
opts = {
history = true,
delete_check_events = "TextChanged",
},
},
{
"nvim-cmp",
-- stylua: ignore -- stylua: ignore
keys = { keys = {
{
"<tab>",
function()
return require("luasnip").jumpable(1) and "<Plug>luasnip-jump-next" or "<tab>"
end,
expr = true, silent = true, mode = "i",
},
{ "<tab>", function() require("luasnip").jump(1) end, mode = "s" }, { "<tab>", function() require("luasnip").jump(1) end, mode = "s" },
{ "<s-tab>", function() require("luasnip").jump(-1) end, mode = { "i", "s" } }, { "<s-tab>", function() require("luasnip").jump(-1) end, mode = { "i", "s" } },
}, },
}, },
-- blink.cmp integration
{ {
"garymjr/nvim-snippets", "saghen/blink.cmp",
enabled = false, optional = true,
opts = {
accept = {
expand_snippet = function(...)
return require("luasnip").lsp_expand(...)
end,
},
},
}, },
} }

View file

@ -1,6 +1,36 @@
---@class lazyvim.util.cmp ---@class lazyvim.util.cmp
local M = {} local M = {}
---@alias lazyvim.util.cmp.Action fun():boolean?
---@type table<string, lazyvim.util.cmp.Action>
M.actions = {
-- Native Snippets
snippet_forward = function()
if vim.snippet.active({ direction = 1 }) then
vim.schedule(function()
vim.snippet.jump(1)
end)
return true
end
end,
}
---@param actions string[]
---@param fallback? string|fun()
function M.map(actions, fallback)
return function()
for _, name in ipairs(actions) do
if M.actions[name] then
local ret = M.actions[name]()
if ret then
return true
end
end
end
return type(fallback) == "function" and fallback() or fallback
end
end
---@alias Placeholder {n:number, text:string} ---@alias Placeholder {n:number, text:string}
---@param snippet string ---@param snippet string

View file

@ -7,7 +7,7 @@ function M.cmp_source(name, icon)
if not package.loaded["cmp"] then if not package.loaded["cmp"] then
return return
end end
for _, s in ipairs(require("cmp").core.sources) do for _, s in ipairs(require("cmp").core.sources or {}) do
if s.name == name then if s.name == name then
if s.source:is_available() then if s.source:is_available() then
started = true started = true