diff --git a/README.md b/README.md index a9977c8..5349b01 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,24 @@ in your default browser using the xdg-open command. - `enterprise_mode`: enable enterprise mode - `detect_proxy`: enable or disable proxy detection - `enable_chat`: enable chat functionality +- `enable_cmp_source`: defaults to true. Set `false` to disable registering a `cmp` source +- `virtual_text`: configuration for showing completions in virtual text + - `enabled`: defaults to `false`. Set `true` to enable the virtual text feature + - `filetypes`: A mapping of filetype to true or false, to enable virtual text + - `default_filetype_enabled`: Whether to enable virtual text of not for types not listed in `filetypes`. + - `manual`: Set `true` to only trigger Codeium using a manual Lua function call + - `idle_delay`: defaults to `75`. Time in ms to wait before requesting completions after typing stops. + - `virtual_text_priority`: defaults to `65535`. Priority of the virtual text + - `map_keys`: defaults to `true`. Set `false` to not set any key bindings for completions + - `accept_fallback`: Emulate pressing this key when using the accept key binding but there is no completion. Defaults + to "\t" + - `key_bindings`: key bindings for accepting and cycling through completions + - `accept`: key binding for accepting a completion, default is `` + - `accept_word`: key binding for accepting only the next word, default is not set + - `accept_line`: key binding for accepting only the next line, default is not set + - `clear`: key binding for clearing the virtual text, default is not set + - `next`: key binding for cycling to the next completion, default is `` + - `prev`: key binding for cycling to the previous completion, default is `` - `workspace_root`: - `use_lsp`: Use Neovim's LSP support to find the workspace root, if possible. - `paths`: paths to files that indicate a workspace root when not using the LSP support @@ -133,6 +151,143 @@ cmp.setup({ }) ``` +### Virtual Text + +The plugin supports showing completions in virtual text. Set `virtual_text.enabled` in the options to `true` to enable it. + +```lua +require("codeium").setup({ + -- Optionally disable cmp source if using virtual text only + enable_cmp_source = false, + virtual_text = { + enabled = true, + + -- These are the defaults + + -- Set to true if you never want completions to be shown automatically. + manual = false, + -- A mapping of filetype to true or false, to enable virtual text. + filetypes = {}, + -- Whether to enable virtual text of not for filetypes not specifically listed above. + default_filetype_enabled = true, + -- How long to wait (in ms) before requesting completions after typing stops. + idle_delay = 75, + -- Priority of the virtual text. This usually ensures that the completions appear on top of + -- other plugins that also add virtual text, such as LSP inlay hints, but can be modified if + -- desired. + virtual_text_priority = 65535, + -- Set to false to disable all key bindings for managing completions. + map_keys = true, + -- The key to press when hitting the accept keybinding but no completion is showing. + -- Defaults to \t normally or when a popup is showing. + accept_fallback = nil, + -- Key bindings for managing completions in virtual text mode. + key_bindings = { + -- Accept the current completion. + accept = "", + -- Accept the next word. + accept_word = false, + -- Accept the next line. + accept_line = false, + -- Clear the virtual text. + clear = false, + -- Cycle to the next completion. + next = "", + -- Cycle to the previous completion. + prev = "", + } + } +}) +``` + +#### Virtual Text Keybindings + +The plugin defines a number of key bindings for managing completion in virtual text mode. You can override these by +setting `virtual_text.key_bindings`. If you don't want any key bindings, set `virtual_text.map_keys` to `false`, or +you can set specific bindings to `false`. + +When `manual` mode is enabled, you can call any of these functions to show completions: + +```lua +-- Request completions immediately. +require('codeium.virtual_text').complete() + +-- Request a completion, or cycle to the next if we already have some +require('codeium.virtual_text').cycle_or_complete() + +-- Complete only after idle_delay has passed with no other calls to debounced_complete(). +require('codeium.virtual_text').debounced_complete() +``` + +#### Virtual Text Filetypes + +You can set the `filetypes` and `default_filetype_enabled` options in the `virtual_text` table to configure which filetypes +should use virtual text. + +```lua +require('codeium.virtual_text').setup({ + virtual_text = { + filetypes = { + python = true, + markdown = false + }, + default_filetype_enabled = true + } +}) +``` + +### Show Codeium status in statusline + +When using virtual text, Codeium status can be generated by calling `require('codeium.virtual_text').status_string()`. +It produces a 3 char long string with Codeium status: + +- `'3/8'` - third suggestion out of 8 +- `'0'` - Codeium returned no suggestions +- `'*'` - waiting for Codeium response + +In order to show it in status line add following line to your `.vimrc`: + +```set statusline+=%3{v:lua.require('codeium.virtual_text').status_string()}``` + +Please check `:help statusline` for further information about building statusline in VIM. + +The `status_string` function can also be used with other statusline plugins. +You can call the `set_statusbar_refresh` function to customize how the plugin refreshes the +status bar. + +For example, this sets up the plugin with lualine: + +```lua +require('codeium.virtual_text').set_statusbar_refresh(function() + require('lualine').refresh() +end) +``` + +For more customization, you can also call the `status` function which returns an object that can be used to create a +status string. + +```lua +function custom_status() + local status = require('codeium.virtual_text').status() + + if status.state == 'idle' then + -- Output was cleared, for example when leaving insert mode + return ' ' + end + + if status.state == 'waiting' then + -- Waiting for response + return "Waiting..." + end + + if status.state == 'completions' and status.total > 0 then + return string.format('%d/%d', status.current, status.total) + end + + return ' 0 ' +end +``` + ### Workspace Root Directory The plugin uses a few techniques to find the workspace root directory, which helps to inform the autocomplete and chat context. @@ -173,8 +328,6 @@ require('codeium').setup({ }) ``` - - ## Troubleshooting The plugin log is written to `~/.cache/nvim/codeium/codeium.log`. diff --git a/lua/codeium/config.lua b/lua/codeium/config.lua index 4d35cf6..a1cc447 100644 --- a/lua/codeium/config.lua +++ b/lua/codeium/config.lua @@ -23,6 +23,25 @@ function M.defaults() enable_index_service = true, search_max_workspace_file_count = 5000, file_watch_max_dir_count = 50000, + enable_cmp_source = true, + virtual_text = { + enabled = false, + filetypes = {}, + default_filetype_enabled = true, + manual = false, + idle_delay = 75, + virtual_text_priority = 65535, + map_keys = true, + accept_fallback = nil, + key_bindings = { + accept = "", + accept_word = false, + accept_line = false, + clear = false, + next = "", + prev = "", + }, + }, workspace_root = { use_lsp = true, find_root = nil, diff --git a/lua/codeium/init.lua b/lua/codeium/init.lua index 4d180c1..cca87ad 100644 --- a/lua/codeium/init.lua +++ b/lua/codeium/init.lua @@ -38,7 +38,11 @@ function M.setup(options) }) local source = Source:new(s) - require("cmp").register_source("codeium", source) + if options.enable_cmp_source then + require("cmp").register_source("codeium", source) + end + + require("codeium.virtual_text").setup(s, require("codeium.config").options.virtual_text) end return M diff --git a/lua/codeium/source.lua b/lua/codeium/source.lua index 9bbeb16..dcbee60 100644 --- a/lua/codeium/source.lua +++ b/lua/codeium/source.lua @@ -72,33 +72,6 @@ local function codeium_to_cmp(comp, offset, right) } end -local function buf_to_codeium(bufnr) - local filetype = enums.filetype_aliases[vim.bo[bufnr].filetype] or vim.bo[bufnr].filetype or "text" - local language = enums.languages[filetype] or enums.languages.unspecified - local line_ending = util.get_newline(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) - table.insert(lines, "") - local text = table.concat(lines, line_ending) - return { - editor_language = filetype, - language = language, - text = text, - line_ending = line_ending, - absolute_uri = util.get_uri(vim.api.nvim_buf_get_name(bufnr)), - } -end - -local function get_other_documents(bufnr) - local other_documents = {} - - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype ~= "" and buf ~= bufnr then - table.insert(other_documents, buf_to_codeium(buf)) - end - end - return other_documents -end - local Source = { server = nil, } @@ -120,19 +93,23 @@ function Source:get_position_encoding_kind() return "utf-8" end -require("cmp").event:on("confirm_done", function(event) - if - event.entry - and event.entry.source - and event.entry.source.name == "codeium" - and event.entry.completion_item - and event.entry.completion_item.codeium_completion_id - and event.entry.source.source - and event.entry.source.source.server - then - event.entry.source.source.server.accept_completion(event.entry.completion_item.codeium_completion_id) - end -end) +-- Import `cmp` but don't error if it is not installed, as it might be when only using virtual text +local imported_cmp, cmp = pcall(require, "cmp") +if imported_cmp then + cmp.event:on("confirm_done", function(event) + if + event.entry + and event.entry.source + and event.entry.source.name == "codeium" + and event.entry.completion_item + and event.entry.completion_item.codeium_completion_id + and event.entry.source.source + and event.entry.source.source.server + then + event.entry.source.source.server.accept_completion(event.entry.completion_item.codeium_completion_id) + end + end) +end function Source:complete(params, callback) local context = params.context @@ -178,7 +155,7 @@ function Source:complete(params, callback) callback(completions) end - local other_documents = get_other_documents(bufnr) + local other_documents = util.get_other_documents(bufnr) self.server.request_completion( { diff --git a/lua/codeium/util.lua b/lua/codeium/util.lua index dcf2829..d953a15 100644 --- a/lua/codeium/util.lua +++ b/lua/codeium/util.lua @@ -93,4 +93,31 @@ function M.get_uri(path) return "file://" .. path end +local function buf_to_codeium(bufnr) + local filetype = enums.filetype_aliases[vim.bo[bufnr].filetype] or vim.bo[bufnr].filetype or "text" + local language = enums.languages[filetype] or enums.languages.unspecified + local line_ending = M.get_newline(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + table.insert(lines, "") + local text = table.concat(lines, line_ending) + return { + editor_language = filetype, + language = language, + text = text, + line_ending = line_ending, + absolute_uri = M.get_uri(vim.api.nvim_buf_get_name(bufnr)), + } +end + +function M.get_other_documents(bufnr) + local other_documents = {} + + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype ~= "" and buf ~= bufnr then + table.insert(other_documents, buf_to_codeium(buf)) + end + end + return other_documents +end + return M diff --git a/lua/codeium/virtual_text.lua b/lua/codeium/virtual_text.lua new file mode 100644 index 0000000..8488146 --- /dev/null +++ b/lua/codeium/virtual_text.lua @@ -0,0 +1,554 @@ +local config = require("codeium.config") +local enums = require("codeium.enums") +local notify = require("codeium.notify") +local util = require("codeium.util") + +local M = {} + +local hlgroup = "CodeiumSuggestion" +local request_nonce = 0 +local using_status_line = false + +--- @type "idle" | "waiting" | "completions" +local codeium_status = "idle" + +--- @class Completions +--- @field items table[] | nil +--- @field index number | nil +--- @field cancel function +--- @field request_id number +--- @field request_data table + +--- @type Completions | nil +local completions +local idle_timer + +local server = { + --- This will be replaced by the actual server when setup is called. + is_healthy = function() + return false + end, +} + +function M.setup(_server) + server = _server + + local augroup = vim.api.nvim_create_augroup("codeium_virtual_text", { clear = true }) + + if not config.options.virtual_text.enabled then + return + end + + vim.api.nvim_create_autocmd({ "InsertEnter", "CursorMovedI", "CompleteChanged" }, { + group = augroup, + callback = function() + M.debounced_complete() + end, + }) + + vim.api.nvim_create_autocmd("BufEnter", { + group = augroup, + callback = function() + if vim.fn.mode():match("^[iR]") then + M.debounced_complete() + end + end, + }) + + vim.api.nvim_create_autocmd("InsertLeave", { + group = augroup, + callback = function() + M.clear() + end, + }) + + vim.api.nvim_create_autocmd("BufLeave", { + group = augroup, + callback = function() + if vim.fn.mode():match("^[iR]") then + M.clear() + end + end, + }) + + if config.options.virtual_text.map_keys then + local bindings = config.options.virtual_text.key_bindings + if bindings.clear and bindings.clear ~= "" then + vim.keymap.set("i", bindings.clear, function() + M.clear() + end, { silent = true }) + end + + if bindings.next and bindings.next ~= "" then + vim.keymap.set("i", bindings.next, function() + M.cycle_completions(1) + end, { silent = true }) + end + + if bindings.prev and bindings.prev ~= "" then + vim.keymap.set("i", bindings.prev, function() + M.cycle_completions(-1) + end, { silent = true }) + end + + if bindings.accept and bindings.accept ~= "" then + vim.keymap.set("i", bindings.accept, M.accept, { silent = true, expr = true, script = true, nowait = true }) + end + + if bindings.accept_word and bindings.accept_word ~= "" then + vim.keymap.set( + "i", + bindings.accept_word, + M.accept_next_word, + { silent = true, expr = true, script = true, nowait = true } + ) + end + + if bindings.accept_line and bindings.accept_line ~= "" then + vim.keymap.set( + "i", + bindings.accept_line, + M.accept_next_line, + { silent = true, expr = true, script = true, nowait = true } + ) + end + end + + vim.api.nvim_create_autocmd({ "ColorScheme", "VimEnter" }, { + group = augroup, + callback = function() + M.set_style() + end, + }) +end + +function M.set_style() + if vim.fn.has("termguicolors") == 1 and vim.o.termguicolors then + vim.api.nvim_set_hl(0, hlgroup, { fg = "#808080", default = true }) + else + vim.api.nvim_set_hl(0, hlgroup, { ctermfg = 244, default = true }) + end +end + +function M.get_completion_text() + local completion_text = M.completion_text + M.completion_text = nil + return completion_text or "" +end + +local function completion_inserter(current_completion, insert_text) + local default = config.options.virtual_text.accept_fallback or (vim.fn.pumvisible() == 1 and "" or "\t") + + if not (vim.fn.mode():match("^[iR]")) then + return default + end + + if current_completion == nil then + return default + end + + local range = current_completion.range + local suffix = current_completion.suffix or {} + local suffix_text = suffix.text or "" + local delta = suffix.deltaCursorOffset or 0 + local start_offset = range.startOffset or 0 + local end_offset = range.endOffset or 0 + + local text = insert_text .. suffix_text + if text == "" then + return default + end + + local delete_range = "" + if end_offset - start_offset > 0 then + local delete_bytes = end_offset - start_offset + local delete_chars = vim.fn.strchars(vim.fn.strpart(vim.fn.getline("."), 0, delete_bytes)) + delete_range = ' "_x0"_d' .. delete_chars .. "li" + end + + local insert_text = '=v:lua.require("codeium.virtual_text").get_completion_text()' + M.completion_text = text + + local cursor_text = delta == 0 and "" or ':exe "go" line2byte(line("."))+col(".")+(' .. delta .. ")" + + server.accept_completion(current_completion.completion.completionId) + + return delete_range .. insert_text .. cursor_text +end + +function M.accept() + local current_completion = M.get_current_completion_item() + return completion_inserter(current_completion, current_completion and current_completion.completion.text or "") +end + +function M.accept_next_word() + local current_completion = M.get_current_completion_item() + local completion_parts = current_completion and (current_completion.completionParts or {}) or {} + if #completion_parts == 0 then + return "" + end + local prefix_text = completion_parts[1].prefix or "" + local completion_text = completion_parts[1].text or "" + local next_word = completion_text:match("^%W*%w*") + return completion_inserter(current_completion, prefix_text .. next_word) +end + +function M.accept_next_line() + local current_completion = M.get_current_completion_item() + local text = current_completion and current_completion.completion.text:gsub("\n.*$", "") or "" + return completion_inserter(current_completion, text) +end + +function M.get_current_completion_item() + if completions and completions.items and completions.index and completions.index < #completions.items then + return completions.items[completions.index + 1] + end + return nil +end + +local nvim_extmark_ids = {} + +local function clear_completion() + local namespace = vim.api.nvim_create_namespace("codeium") + for _, id in ipairs(nvim_extmark_ids) do + vim.api.nvim_buf_del_extmark(0, namespace, id) + end + nvim_extmark_ids = {} +end + +local function render_current_completion() + clear_completion() + M.redraw_status_line() + + if not vim.fn.mode():match("^[iR]") then + return "" + end + + local current_completion = M.get_current_completion_item() + if current_completion == nil then + return "" + end + + local parts = current_completion.completionParts or {} + + local inline_cumulative_cols = 0 + local diff = 0 + for idx, part in ipairs(parts) do + local row = (part.line or 0) + 1 + if row ~= vim.fn.line(".") then + notify.debug("Ignoring completion, line number is not the current line.") + goto continue + end + local _col + if part.type == "COMPLETION_PART_TYPE_INLINE" then + _col = inline_cumulative_cols + #(part.prefix or "") + 1 + inline_cumulative_cols = _col - 1 + else + _col = #(part.prefix or "") + 1 + end + local text = part.text + + if + (part.type == "COMPLETION_PART_TYPE_INLINE" and idx == 1) + or part.type == "COMPLETION_PART_TYPE_INLINE_MASK" + then + local completion_prefix = part.prefix or "" + local completion_line = completion_prefix .. text + local full_line = vim.fn.getline(row) + local cursor_prefix = full_line:sub(1, vim.fn.col(".") - 1) + local matching_prefix = 0 + for i = 1, #completion_line do + if i <= #full_line and completion_line:sub(i, i) == full_line:sub(i, i) then + matching_prefix = matching_prefix + 1 + else + break + end + end + if #cursor_prefix > #completion_prefix then + diff = #cursor_prefix - #completion_prefix + elseif #cursor_prefix < #completion_prefix then + if matching_prefix >= #completion_prefix then + diff = matching_prefix - #completion_prefix + else + diff = #cursor_prefix - #completion_prefix + end + end + if diff > 0 then + diff = 0 + end + if diff < 0 then + text = completion_prefix:sub(diff + 1) .. text + elseif diff > 0 then + text = text:sub(diff + 1) + end + end + + local priority = config.options.virtual_text.virtual_text_priority + local _virtcol = vim.fn.virtcol({ row, _col + diff }) + local data = { id = idx + 1, hl_mode = "combine", virt_text_win_col = _virtcol - 1, priority = priority } + if part.type == "COMPLETION_PART_TYPE_INLINE_MASK" then + data.virt_text = { { text, hlgroup } } + elseif part.type == "COMPLETION_PART_TYPE_BLOCK" then + local lines = vim.split(text, "\n") + if lines[#lines] == "" then + table.remove(lines) + end + data.virt_lines = vim.tbl_map(function(l) + return { { l, hlgroup } } + end, lines) + else + goto continue + end + + table.insert(nvim_extmark_ids, data.id) + vim.api.nvim_buf_set_extmark(0, vim.api.nvim_create_namespace("codeium"), row - 1, 0, data) + + ::continue:: + end +end + +function M.clear() + codeium_status = "idle" + M.redraw_status_line() + if idle_timer then + vim.fn.timer_stop(idle_timer) + idle_timer = nil + end + + if completions then + if completions.cancel then + completions.cancel() + end + render_current_completion() + completions = nil + end + + render_current_completion() + return "" +end + +--- @param n number +function M.cycle_completions(n) + if not completions or M.get_current_completion_item() == nil then + return + end + + completions.index = completions.index + n + local n_items = #completions.items + + if completions.index < 0 then + completions.index = completions.index + n_items + end + + completions.index = completions.index % n_items + + render_current_completion() +end + +local warn_filetype_missing = true +--- @param buf_id number +--- @param cur_line number +--- @param cur_col number +--- @return table | nil +local function get_document(buf_id, cur_line, cur_col) + local lines = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false) + if vim.bo[buf_id].eol then + table.insert(lines, "") + end + + local filetype = vim.bo[buf_id].filetype:gsub("%..*", "") + local language = enums.filetype_aliases[filetype == "" and "text" or filetype] or filetype + if filetype == "" and warn_filetype_missing ~= false then + notify.debug("No filetype detected. This will affect completion quality.") + warn_filetype_missing = false + end + local editor_language = vim.bo[buf_id].filetype == "" and "unspecified" or vim.bo[buf_id].filetype + + local buf_name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf_id), ":p") + -- If it's already any sort of URI, this might be a special buffer for some plugins, so we ignore it to + -- avoid an LS error. + if buf_name:match("^%w+://") ~= nil then + return nil + end + + local line_ending = util.get_newline(buf_id) + local doc = { + text = table.concat(lines, line_ending), + editor_language = editor_language, + language = enums.languages[language] or enums.languages.unspecified, + cursor_position = { row = cur_line - 1, col = cur_col - 1 }, + absolute_uri = util.get_uri(vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf_id), ":p")), + workspace_uri = util.get_uri(util.get_project_root()), + line_ending = line_ending, + } + + return doc +end + +--- @param opts { bufnr: number, timer: any }? +function M.complete(opts) + if opts then + if opts.timer ~= idle_timer then + return + end + + idle_timer = nil + + if vim.fn.mode() ~= "i" or opts.bufnr ~= vim.fn.bufnr("") then + return + end + end + + if idle_timer then + vim.fn.timer_stop(idle_timer) + idle_timer = nil + end + + if vim.o.encoding ~= "latin1" and vim.o.encoding ~= "utf-8" then + error("Only latin1 and utf-8 are supported") + return + end + + local bufnr = vim.fn.bufnr("") + + if not M.filetype_enabled(bufnr) then + return + end + + local document = get_document(bufnr, vim.fn.line("."), vim.fn.col(".")) + if document == nil then + return + end + + local other_documents = util.get_other_documents(bufnr) + local data = { + document = document, + editor_options = util.get_editor_options(bufnr), + other_documents = other_documents, + } + + if completions and completions.request_data == data then + return + end + + local request_data = vim.deepcopy(data) + + request_nonce = request_nonce + 1 + local request_id = request_nonce + + codeium_status = "waiting" + + local cancel = server.request_completion( + data.document, + data.editor_options, + data.other_documents, + function(success, json) + if completions and completions.request_id == request_id then + completions.cancel = nil + codeium_status = "idle" + end + if not success then + return + end + + if json and json.state and json.state.state == "CODEIUM_STATE_SUCCESS" and json.completionItems then + M.handle_completions(json.completionItems) + end + end + ) + completions = { + cancel = cancel, + request_data = request_data, + request_id = request_id, + } +end + +function M.handle_completions(completion_items) + if not completions then + return + end + completions.items = completion_items + completions.index = 0 + codeium_status = "completions" + render_current_completion() +end + +function M.filetype_enabled(bufnr) + local filetype = vim.bo[bufnr].filetype + local enabled = config.options.virtual_text.filetypes[filetype] + if enabled == nil then + return config.options.virtual_text.default_filetype_enabled + end + return enabled +end + +function M.debounced_complete() + M.clear() + if config.options.virtual_text.manual or not server.is_healthy() or not M.filetype_enabled(vim.fn.bufnr("")) then + return + end + local current_buf = vim.fn.bufnr("") + idle_timer = vim.fn.timer_start(config.options.virtual_text.idle_delay, function(timer) + M.complete({ bufnr = current_buf, timer = timer }) + end) +end + +function M.cycle_or_complete() + if M.get_current_completion_item() == nil then + M.complete() + else + M.cycle_completions(1) + end +end + +function M.status() + if codeium_status == "completions" then + if completions and completions.items and completions.index then + return { + state = "completions", + current = completions.index + 1, + total = #completions.items, + } + else + return { state = "idle" } + end + else + return { state = codeium_status } + end +end + +function M.status_string() + using_status_line = true + local status = M.status() + + if status.state == "completions" then + if status.total > 0 then + return string.format("%d/%d", status.current, status.total) + else + return " 0 " + end + elseif status.state == "waiting" then + return " * " + elseif status.state == "idle" then + return " 0 " + else + return " " + end +end + +local refresh_fn = function() + vim.cmd("redrawstatus") +end + +function M.set_statusbar_refresh(refresh) + using_status_line = true + refresh_fn = refresh +end + +function M.redraw_status_line() + if using_status_line then + refresh_fn() + end +end + +return M