From 48667d58ef3df3ac6cc800c9db99540e5d5cca81 Mon Sep 17 00:00:00 2001 From: Jacob Scott Date: Thu, 19 Dec 2024 23:20:00 +0000 Subject: [PATCH] feat: terminal mode completions Remove debug line Use chansend() to set prompt text Add configuration options for terminal sources and keymaps Make a note about terminal completions in README (and fix a broken link) --- README.md | 3 +- lua/blink/cmp/completion/accept/init.lua | 5 +- lua/blink/cmp/completion/trigger/context.lua | 8 +- lua/blink/cmp/completion/trigger/init.lua | 21 ++++- lua/blink/cmp/config/keymap.lua | 11 ++- lua/blink/cmp/config/sources.lua | 10 +++ lua/blink/cmp/keymap/apply.lua | 38 +++++++- lua/blink/cmp/keymap/init.lua | 8 ++ lua/blink/cmp/lib/term_events.lua | 92 ++++++++++++++++++++ lua/blink/cmp/lib/text_edits.lua | 35 ++++++-- 10 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 lua/blink/cmp/lib/term_events.lua diff --git a/README.md b/README.md index fd8deed0..84ca3dd4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ - Auto-bracket support based on semantic tokens - Signature help (experimental, opt-in) - Command line completion -- [Comparison with nvim-cmp](#compared-to-nvim-cmp) +- Support for completions in Neovim's built-in terminal (although no source for shell completions exists yet) +- [Comparison with nvim-cmp](https://cmp.saghen.dev/#compared-to-nvim-cmp) ## Getting Started diff --git a/lua/blink/cmp/completion/accept/init.lua b/lua/blink/cmp/completion/accept/init.lua index 6c4df605..9995d4bf 100644 --- a/lua/blink/cmp/completion/accept/init.lua +++ b/lua/blink/cmp/completion/accept/init.lua @@ -75,7 +75,10 @@ local function accept(ctx, item, callback) table.insert(all_text_edits, item.textEdit) text_edits_lib.apply(all_text_edits) -- TODO: should move the cursor only by the offset since text edit handles everything else? - ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset }) + -- TODO: move the term check somewhere else + if ctx.get_mode() ~= 'term' then + ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset }) + end end -- Let the source execute the item itself diff --git a/lua/blink/cmp/completion/trigger/context.lua b/lua/blink/cmp/completion/trigger/context.lua index 24538a35..9109661a 100644 --- a/lua/blink/cmp/completion/trigger/context.lua +++ b/lua/blink/cmp/completion/trigger/context.lua @@ -1,5 +1,6 @@ -- TODO: remove the end_col field from ContextBounds + --- @class blink.cmp.ContextBounds --- @field line string --- @field line_number number @@ -73,7 +74,11 @@ function context:within_query_bounds(cursor) return row == bounds.line_number and col >= bounds.start_col and col <= bounds.end_col end -function context.get_mode() return vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' end +function context.get_mode() + return (vim.api.nvim_get_mode().mode == 'c' and 'cmdline') + or (vim.api.nvim_get_mode().mode == 't' and 'term') + or 'default' +end function context.get_cursor() return context.get_mode() == 'cmdline' and { 1, vim.fn.getcmdpos() - 1 } or vim.api.nvim_win_get_cursor(0) @@ -97,6 +102,7 @@ function context.get_line(num) return vim.fn.getcmdline() end + -- This method works for normal buffers and the terminal prompt if num == nil then num = context.get_cursor()[1] - 1 end return vim.api.nvim_buf_get_lines(0, num, num + 1, false)[1] end diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua index 4e24f4a0..b90d0bd8 100644 --- a/lua/blink/cmp/completion/trigger/init.lua +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -40,6 +40,9 @@ function trigger.activate() show_in_snippet = config.show_in_snippet, }) trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new() + trigger.term_events = require('blink.cmp.lib.term_events').new({ + has_context = function() return trigger.context ~= nil end, + }) local function on_char_added(char, is_ignored) -- we were told to ignore the text changed event, so we update the context @@ -77,7 +80,7 @@ function trigger.activate() local insert_enter_on_trigger_character = config.show_on_trigger_character and config.show_on_insert_on_trigger_character - and event == 'InsertEnter' + and (event == 'InsertEnter' or event == 'TermEnter') and trigger.is_trigger_character(char_under_cursor, true) -- check if we're still within the bounds of the query used for the context @@ -95,7 +98,7 @@ function trigger.activate() trigger.show() -- prefetch completions without opening window on InsertEnter - elseif event == 'InsertEnter' and config.prefetch_on_insert then + elseif (event == 'InsertEnter' or event == 'TermEnter') and config.prefetch_on_insert then trigger.show({ prefetch = true }) -- otherwise hide @@ -114,6 +117,10 @@ function trigger.activate() on_cursor_moved = on_cursor_moved, on_leave = function() trigger.hide() end, }) + trigger.term_events:listen({ + on_char_added = on_char_added, + on_term_leave = function() trigger.hide() end, + }) end function trigger.is_trigger_character(char, is_show_on_x) @@ -137,9 +144,15 @@ end --- Suppresses on_hide and on_show events for the duration of the callback function trigger.suppress_events_for_callback(cb) - local mode = vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' + local mode = vim.api.nvim_get_mode().mode + mode = (vim.api.nvim_get_mode().mode == 'c' and 'cmdline') + or (mode == 't' and 'term') + or 'default' + + local events = (mode == 'default' and trigger.buffer_events) + or (mode == 'term' and trigger.term_events) + or trigger.cmdline_events - local events = mode == 'default' and trigger.buffer_events or trigger.cmdline_events if not events then return cb() end events:suppress_events_for_callback(cb) diff --git a/lua/blink/cmp/config/keymap.lua b/lua/blink/cmp/config/keymap.lua index a7984596..d979151b 100644 --- a/lua/blink/cmp/config/keymap.lua +++ b/lua/blink/cmp/config/keymap.lua @@ -1,4 +1,5 @@ --- @alias blink.cmp.KeymapCommand + --- | 'fallback' Fallback to the built-in behavior --- | 'show' Show the completion window --- | 'hide' Hide the completion window @@ -102,6 +103,11 @@ --- cmdline = { --- preset = 'cmdline', --- } +--- +--- -- optionally, define different keymaps for Neovim's built-in terminal +--- term = { +--- preset = 'term', +--- } --- } --- ``` --- @@ -112,6 +118,7 @@ --- @class (exact) blink.cmp.KeymapConfig : blink.cmp.BaseKeymapConfig --- @field cmdline? blink.cmp.BaseKeymapConfig Optionally, define a separate keymap for cmdline +--- @field term? blink.cmp.BaseKeymapConfig Optionally, define a separate keymap for cmdline local keymap = { --- @type blink.cmp.KeymapConfig @@ -142,8 +149,8 @@ function keymap.validate(config) local validation_schema = {} for key, value in pairs(config) do - -- nested cmdline keymap - if key == 'cmdline' then + -- nested cmdline/term keymap + if key == 'cmdline' or key == 'term' then keymap.validate(value) -- preset diff --git a/lua/blink/cmp/config/sources.lua b/lua/blink/cmp/config/sources.lua index 358c5ab0..2d4e7026 100644 --- a/lua/blink/cmp/config/sources.lua +++ b/lua/blink/cmp/config/sources.lua @@ -17,6 +17,7 @@ --- @field default string[] | fun(): string[] --- @field per_filetype table --- @field cmdline string[] | fun(): string[] +--- @field term string[] | fun(): string[] --- --- @field transform_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] Function to transform the items before they're returned --- @field min_keyword_length number | fun(ctx: blink.cmp.Context): number Minimum number of characters in the keyword to trigger @@ -53,6 +54,7 @@ local sources = { if type == ':' then return { 'cmdline' } end return {} end, + term = { 'buffer', 'path' }, transform_items = function(_, items) return items end, min_keyword_length = 0, @@ -102,6 +104,13 @@ local sources = { name = 'cmdline', module = 'blink.cmp.sources.cmdline', }, + -- Note: in future we may want a built-in terminal source. For now + -- the infrastructure exists, e.g. so community terminal sources can be + -- added, but this functionality is not baked into blink.cmp. + -- term = { + -- name = 'term', + -- module = 'blink.cmp.sources.term', + -- }, }, }, } @@ -116,6 +125,7 @@ function sources.validate(config) default = { config.default, { 'function', 'table' } }, per_filetype = { config.per_filetype, 'table' }, cmdline = { config.cmdline, { 'function', 'table' } }, + term = { config.term, { 'function', 'table' } }, transform_items = { config.transform_items, 'function' }, min_keyword_length = { config.min_keyword_length, { 'number', 'function' } }, diff --git a/lua/blink/cmp/keymap/apply.lua b/lua/blink/cmp/keymap/apply.lua index a8d79ea8..b73b0f06 100644 --- a/lua/blink/cmp/keymap/apply.lua +++ b/lua/blink/cmp/keymap/apply.lua @@ -21,11 +21,11 @@ function apply.keymap_to_current_buffer(keys_to_commands) if command == 'fallback' then return fallback() - -- run user defined functions + -- run user defined functions elseif type(command) == 'function' then if command(require('blink.cmp')) then return end - -- otherwise, run the built-in command + -- otherwise, run the built-in command elseif require('blink.cmp')[command]() then return end @@ -66,6 +66,38 @@ function apply.keymap_to_current_buffer(keys_to_commands) end end +function apply.term_keymaps(keys_to_commands) + -- skip if we've already applied the keymaps + for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(0, 't')) do + if mapping.desc == 'blink.cmp' then return end + end + + -- terminal mode: uses insert commands only + for key, commands in pairs(keys_to_commands) do + if #commands == 0 then goto continue end + + local fallback = require('blink.cmp.keymap.fallback').wrap('i', key) + apply.set('t', key, function() + for _, command in ipairs(commands) do + -- special case for fallback + if command == 'fallback' then + return fallback() + + -- run user defined functions + elseif type(command) == 'function' then + if command(require('blink.cmp')) then return end + + -- otherwise, run the built-in command + elseif require('blink.cmp')[command]() then + return + end + end + end) + + ::continue:: + end +end + function apply.cmdline_keymaps(keys_to_commands) -- cmdline mode: uses only insert commands for key, commands in pairs(keys_to_commands) do @@ -102,7 +134,7 @@ end --- @param key string --- @param callback fun(): string | nil function apply.set(mode, key, callback) - if mode == 'c' then + if mode == 'c' or mode == 't' then vim.api.nvim_set_keymap(mode, key, '', { callback = callback, expr = true, diff --git a/lua/blink/cmp/keymap/init.lua b/lua/blink/cmp/keymap/init.lua index a5e7009e..60b5cce5 100644 --- a/lua/blink/cmp/keymap/init.lua +++ b/lua/blink/cmp/keymap/init.lua @@ -69,6 +69,14 @@ function keymap.setup() local cmdline_mappings = keymap.get_mappings(config.keymap.cmdline or config.keymap) require('blink.cmp.keymap.apply').cmdline_keymaps(cmdline_mappings) end + + + -- Apply term keymaps + local term_sources = require('blink.cmp.config').sources.term + if type(term_sources) ~= 'table' or #term_sources > 0 then + local term_mappings = keymap.get_mappings(config.keymap.term or config.keymap) + require('blink.cmp.keymap.apply').term_keymaps(term_mappings) + end end return keymap diff --git a/lua/blink/cmp/lib/term_events.lua b/lua/blink/cmp/lib/term_events.lua new file mode 100644 index 00000000..fa00c1d3 --- /dev/null +++ b/lua/blink/cmp/lib/term_events.lua @@ -0,0 +1,92 @@ +--- @class blink.cmp.TermEvents +--- @field has_context fun(): boolean +--- @field ignore_next_text_changed boolean +--- @field ignore_next_cursor_moved boolean +--- +--- @field new fun(opts: blink.cmp.TermEventsOptions): blink.cmp.TermEvents +--- @field listen fun(self: blink.cmp.TermEvents, opts: blink.cmp.TermEventsListener) +--- @field suppress_events_for_callback fun(self: blink.cmp.TermEvents, cb: fun()) + +--- @class blink.cmp.TermEventsOptions +--- @field has_context fun(): boolean + +--- @class blink.cmp.TermEventsListener +--- @field on_char_added fun(char: string, is_ignored: boolean) +--- @field on_term_leave fun() + +--- @type blink.cmp.TermEvents +--- @diagnostic disable-next-line: missing-fields +local term_events = {} + +function term_events.new(opts) + return setmetatable({ + has_context = opts.has_context, + ignore_next_text_changed = false, + ignore_next_cursor_moved = false, + }, { __index = term_events }) +end + +local term_on_key_ns = vim.api.nvim_create_namespace('blink-term-keypress') + +--- Normalizes the autocmds + ctrl+c into a common api and handles ignored events +function term_events:listen(opts) + local last_char = '' + -- There's no terminal equivalent to 'InsertCharPre', so we need to simulate + -- something similar to this by watching with `vim.on_key()` + vim.api.nvim_create_autocmd('TermEnter', { + callback = function() + vim.on_key(function(k) last_char = k end, term_on_key_ns) + end + }) + vim.api.nvim_create_autocmd('TermLeave', { + callback = function() + vim.on_key(nil, term_on_key_ns) + last_char = '' + end + }) + + vim.api.nvim_create_autocmd('TextChangedT', { + callback = function() + if not require('blink.cmp.config').enabled() then return end + + local is_ignored = self.ignore_next_text_changed + self.ignore_next_text_changed = false + + -- no characters added so let cursormoved handle it + if last_char == '' then return end + + opts.on_char_added(last_char, is_ignored) + + last_char = '' + end, + }) + + -- definitely leaving the context + vim.api.nvim_create_autocmd({ 'ModeChanged', 'TermLeave' }, { + callback = function() + last_char = '' + vim.schedule(function() opts.on_term_leave() end) + end, + }) +end + +--- Suppresses autocmd events for the duration of the callback +--- HACK: there's likely edge cases with this since we can't know for sure +--- if the autocmds will fire for cursor_moved afaik +function term_events:suppress_events_for_callback(cb) + local cursor_before = vim.api.nvim_win_get_cursor(0) + local changed_tick_before = vim.api.nvim_buf_get_changedtick(0) + + cb() + + local cursor_after = vim.api.nvim_win_get_cursor(0) + local changed_tick_after = vim.api.nvim_buf_get_changedtick(0) + + local is_term_mode = vim.api.nvim_get_mode().mode == 't' + self.ignore_next_text_changed = changed_tick_after ~= changed_tick_before and is_term_mode + -- TODO: does this guarantee that the CursorMovedI event will fire? + self.ignore_next_cursor_moved = (cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2]) + and is_term_mode +end + +return term_events diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lib/text_edits.lua index 0a412bd0..15899bf0 100644 --- a/lua/blink/cmp/lib/text_edits.lua +++ b/lua/blink/cmp/lib/text_edits.lua @@ -9,17 +9,36 @@ function text_edits.apply(edits) local mode = context.get_mode() if mode == 'default' then return vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end - assert(mode == 'cmdline', 'Unsupported mode for text edits: ' .. mode) - assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!') + assert(mode == 'cmdline' or mode == 'term', 'Unsupported mode for text edits: ' .. mode) - local edit = edits[1] - local line = context.get_line() - local edited_line = line:sub(1, edit.range.start.character) + if mode == 'cmdline' then + assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!') + + local edit = edits[1] + local line = context.get_line() + local edited_line = line:sub(1, edit.range.start.character) .. edit.newText .. line:sub(edit.range['end'].character + 1) - -- FIXME: for some reason, we have to set the cursor here, instead of later, - -- because this will override the cursor position set later - vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1) + -- FIXME: for some reason, we have to set the cursor here, instead of later, + -- because this will override the cursor position set later + vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1) + end + + if mode == 'term' then + assert(#edits == 1, 'Terminal mode only supports one text edit. Contributions welcome!') + + if vim.bo.channel and vim.bo.channel ~= 0 then + local edit = edits[1] + local cur_col = vim.api.nvim_win_get_cursor(0)[2] + local n_replaced = cur_col - edit.range.start.character + local backspace_keycode = '\8' + + vim.fn.chansend( + vim.bo.channel, + backspace_keycode:rep(n_replaced) .. edit.newText + ) + end + end end ------- Undo -------