From fbf881f80b8f0fb2d9e4bc69d04f66323b25c4b9 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Mon, 11 Nov 2024 10:50:57 +0100 Subject: [PATCH] feat(ai): better completion/suggestions of AI engines (#4752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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`, `` 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 ## Checklist - [ ] I've read the [CONTRIBUTING](https://github.com/LazyVim/LazyVim/blob/main/CONTRIBUTING.md) guidelines. --- lua/lazyvim/config/keymaps.lua | 10 ++ lua/lazyvim/config/options.lua | 4 + lua/lazyvim/plugins/coding.lua | 19 +--- lua/lazyvim/plugins/extras/ai/codeium.lua | 58 ++++++++-- lua/lazyvim/plugins/extras/ai/copilot.lua | 103 +++++++++--------- lua/lazyvim/plugins/extras/coding/blink.lua | 40 ++++++- lua/lazyvim/plugins/extras/coding/luasnip.lua | 63 +++++++---- lua/lazyvim/util/cmp.lua | 30 +++++ lua/lazyvim/util/lualine.lua | 2 +- 9 files changed, 230 insertions(+), 99 deletions(-) diff --git a/lua/lazyvim/config/keymaps.lua b/lua/lazyvim/config/keymaps.lua index e12ff3f7..5a9fbc80 100644 --- a/lua/lazyvim/config/keymaps.lua +++ b/lua/lazyvim/config/keymaps.lua @@ -180,3 +180,13 @@ map("n", "", "tabnew", { desc = "New Tab" }) map("n", "]", "tabnext", { desc = "Next Tab" }) map("n", "d", "tabclose", { desc = "Close Tab" }) map("n", "[", "tabprevious", { 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", "", function() + return vim.snippet.active({ direction = 1 }) and "lua vim.snippet.jump(1)" or "" + end, { expr = true, desc = "Jump Next" }) + map({ "i", "s" }, "", function() + return vim.snippet.active({ direction = -1 }) and "lua vim.snippet.jump(-1)" or "" + end, { expr = true, desc = "Jump Previous" }) +end diff --git a/lua/lazyvim/config/options.lua b/lua/lazyvim/config/options.lua index bfd77056..bc504675 100644 --- a/lua/lazyvim/config/options.lua +++ b/lua/lazyvim/config/options.lua @@ -11,6 +11,10 @@ vim.g.autoformat = true -- enabled with `:LazyExtras` 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 -- Each entry can be: -- * the name of a detector function like `lsp` or `cwd` diff --git a/lua/lazyvim/plugins/coding.lua b/lua/lazyvim/plugins/coding.lua index 8f1d7968..5b2901e3 100644 --- a/lua/lazyvim/plugins/coding.lua +++ b/lua/lazyvim/plugins/coding.lua @@ -43,6 +43,9 @@ return { cmp.abort() fallback() end, + [""] = function(fallback) + return LazyVim.cmp.map({ "snippet_forward", "ai_accept" }, fallback)() + end, }), sources = cmp.config.sources({ { name = "nvim_lsp" }, @@ -72,9 +75,10 @@ return { end, }, experimental = { - ghost_text = { + -- only show ghost text when we show ai completions + ghost_text = vim.g.ai_cmp and { hl_group = "CmpGhostText", - }, + } or false, }, sorting = defaults.sorting, } @@ -105,17 +109,6 @@ return { table.insert(opts.sources, { name = "snippets" }) 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" }, "", function() - return vim.snippet.active({ direction = 1 }) and "lua vim.snippet.jump(1)" or "" - end, { expr = true, silent = true }) - vim.keymap.set({ "i", "s" }, "", function() - return vim.snippet.active({ direction = -1 }) and "lua vim.snippet.jump(-1)" or "" - end, { expr = true, silent = true }) - end - end, }, -- auto pairs diff --git a/lua/lazyvim/plugins/extras/ai/codeium.lua b/lua/lazyvim/plugins/extras/ai/codeium.lua index b95ad433..6d2646a1 100644 --- a/lua/lazyvim/plugins/extras/ai/codeium.lua +++ b/lua/lazyvim/plugins/extras/ai/codeium.lua @@ -1,18 +1,42 @@ return { + -- codeium + { + "Exafunction/codeium.nvim", + cmd = "Codeium", + build = ":Codeium Auth", + 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 = "", + prev = "", + }, + }, + }, + }, + + -- 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", - dependencies = { - -- codeium - { - "Exafunction/codeium.nvim", - cmd = "Codeium", - build = ":Codeium Auth", - opts = {}, - }, - }, - ---@param opts cmp.ConfigSchema + optional = true, + dependencies = { "codeium.nvim" }, opts = function(_, opts) table.insert(opts.sources, 1, { name = "codeium", @@ -30,4 +54,18 @@ return { table.insert(opts.sections.lualine_x, 2, LazyVim.lualine.cmp_source("codeium")) 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, + }, + }, } diff --git a/lua/lazyvim/plugins/extras/ai/copilot.lua b/lua/lazyvim/plugins/extras/ai/copilot.lua index 22ba0c64..69429e33 100644 --- a/lua/lazyvim/plugins/extras/ai/copilot.lua +++ b/lua/lazyvim/plugins/extras/ai/copilot.lua @@ -5,8 +5,17 @@ return { "zbirenbaum/copilot.lua", cmd = "Copilot", build = ":Copilot auth", + event = "InsertEnter", opts = { - suggestion = { enabled = false }, + suggestion = { + enabled = not vim.g.ai_cmp, + auto_trigger = true, + keymap = { + accept = false, -- handled by nvim-cmp / blink.cmp + next = "", + prev = "", + }, + }, panel = { enabled = false }, filetypes = { 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", optional = true, @@ -55,70 +80,50 @@ return { -- copilot cmp source { "nvim-cmp", - dependencies = { + optional = true, + dependencies = { -- this will only be evaluated if nvim-cmp is enabled { "zbirenbaum/copilot-cmp", - dependencies = "copilot.lua", + enabled = vim.g.ai_cmp, -- only enable if wanted opts = {}, config = function(_, opts) local copilot_cmp = require("copilot_cmp") copilot_cmp.setup(opts) -- attach cmp source whenever copilot attaches -- 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({}) end, "copilot") end, - }, - }, - ---@param opts cmp.ConfigSchema - opts = function(_, opts) - table.insert(opts.sources, 1, { - name = "copilot", - group_index = 1, - priority = 100, - }) - end, - }, - - { - "saghen/blink.cmp", - optional = true, - specs = { - { - "zbirenbaum/copilot.lua", - event = "InsertEnter", - opts = { - suggestion = { - enabled = true, - auto_trigger = true, - keymap = { accept = false }, + specs = { + { + "nvim-cmp", + ---@param opts cmp.ConfigSchema + opts = function(_, opts) + table.insert(opts.sources, 1, { + name = "copilot", + group_index = 1, + priority = 100, + }) + end, }, }, }, }, + }, + + -- blink.cmp + { + "saghen/blink.cmp", + optional = true, opts = { - windows = { - ghost_text = { - enabled = false, - }, - }, - keymap = { - [""] = { - 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", - }, + windows = { ghost_text = { enabled = false } }, + }, + specs = { + -- blink has no copilot source, so force enable suggestions + { + "zbirenbaum/copilot.lua", + opts = { suggestion = { enabled = true } }, }, }, }, diff --git a/lua/lazyvim/plugins/extras/coding/blink.lua b/lua/lazyvim/plugins/extras/coding/blink.lua index eb5d5471..9542db8d 100644 --- a/lua/lazyvim/plugins/extras/coding/blink.lua +++ b/lua/lazyvim/plugins/extras/coding/blink.lua @@ -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 { { "hrsh7th/nvim-cmp", @@ -5,8 +12,12 @@ return { }, { "saghen/blink.cmp", - version = "*", - opts_extend = { "sources.completion.enabled_providers" }, + version = not vim.g.lazyvim_blink_main and "*", + build = vim.g.lazyvim_blink_main and "cargo build --release", + opts_extend = { + "sources.completion.enabled_providers", + "sources.compat", + }, dependencies = { "rafamadriz/friendly-snippets", -- add blink.compat to dependencies @@ -35,7 +46,7 @@ return { auto_show = true, }, ghost_text = { - enabled = true, + enabled = vim.g.ai_cmp, }, }, @@ -45,6 +56,9 @@ return { -- experimental signature help support -- trigger = { signature_help = { enabled = true } } sources = { + -- adding any nvim-cmp sources here will enable them + -- with blink.compat + compat = {}, completion = { -- remember to enable your providers here enabled_providers = { "lsp", "path", "snippets", "buffer" }, @@ -53,8 +67,28 @@ return { keymap = { preset = "enter", + [""] = { + 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 diff --git a/lua/lazyvim/plugins/extras/coding/luasnip.lua b/lua/lazyvim/plugins/extras/coding/luasnip.lua index 47bd80c4..178cdd0a 100644 --- a/lua/lazyvim/plugins/extras/coding/luasnip.lua +++ b/lua/lazyvim/plugins/extras/coding/luasnip.lua @@ -1,4 +1,8 @@ return { + -- disable builtin snippet support + { "garymjr/nvim-snippets", enabled = false }, + + -- add luasnip { "L3MON4D3/LuaSnip", lazy = true, @@ -12,43 +16,56 @@ return { require("luasnip.loaders.from_vscode").lazy_load() end, }, - { - "nvim-cmp", - dependencies = { - "saadparwaiz1/cmp_luasnip", - }, - opts = function(_, opts) - opts.snippet = { - expand = function(args) - require("luasnip").lsp_expand(args.body) - end, - } - table.insert(opts.sources, { name = "luasnip" }) - 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", + optional = true, + dependencies = { "saadparwaiz1/cmp_luasnip" }, + opts = function(_, opts) + opts.snippet = { + expand = function(args) + require("luasnip").lsp_expand(args.body) + end, + } + table.insert(opts.sources, { name = "luasnip" }) + end, -- stylua: ignore keys = { - { - "", - function() - return require("luasnip").jumpable(1) and "luasnip-jump-next" or "" - end, - expr = true, silent = true, mode = "i", - }, { "", function() require("luasnip").jump(1) end, mode = "s" }, { "", function() require("luasnip").jump(-1) end, mode = { "i", "s" } }, }, }, + + -- blink.cmp integration { - "garymjr/nvim-snippets", - enabled = false, + "saghen/blink.cmp", + optional = true, + opts = { + accept = { + expand_snippet = function(...) + return require("luasnip").lsp_expand(...) + end, + }, + }, }, } diff --git a/lua/lazyvim/util/cmp.lua b/lua/lazyvim/util/cmp.lua index 5eff81fa..ebe0209b 100644 --- a/lua/lazyvim/util/cmp.lua +++ b/lua/lazyvim/util/cmp.lua @@ -1,6 +1,36 @@ ---@class lazyvim.util.cmp local M = {} +---@alias lazyvim.util.cmp.Action fun():boolean? +---@type table +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} ---@param snippet string diff --git a/lua/lazyvim/util/lualine.lua b/lua/lazyvim/util/lualine.lua index db8311c4..5df789fa 100644 --- a/lua/lazyvim/util/lualine.lua +++ b/lua/lazyvim/util/lualine.lua @@ -7,7 +7,7 @@ function M.cmp_source(name, icon) if not package.loaded["cmp"] then return 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.source:is_available() then started = true