diff --git a/README.md b/README.md index 1ec0dd9..7adf64b 100644 --- a/README.md +++ b/README.md @@ -544,8 +544,8 @@ If you have the following structure: - [x] Edit the selected block - [x] Smart Tab (Cursor Flow) - [x] Chat with project (You can use `@codebase` to chat with the whole project) +- [x] Chat with selected files - [ ] CoT -- [ ] Chat with selected files ## Roadmap diff --git a/lua/avante/config.lua b/lua/avante/config.lua index 128fe61..ec27dca 100644 --- a/lua/avante/config.lua +++ b/lua/avante/config.lua @@ -246,6 +246,10 @@ M._defaults = { -- Options override for custom providers provider_opts = {}, }, + suggestion = { + debounce = 600, + throttle = 600, + }, } ---@type avante.Config diff --git a/lua/avante/highlights.lua b/lua/avante/highlights.lua index 49741ea..44a954a 100644 --- a/lua/avante/highlights.lua +++ b/lua/avante/highlights.lua @@ -15,6 +15,7 @@ local Highlights = { ANNOTATION = { name = "AvanteAnnotation", link = "Comment" }, POPUP_HINT = { name = "AvantePopupHint", link = "NormalFloat" }, INLINE_HINT = { name = "AvanteInlineHint", link = "Keyword" }, + TO_BE_DELETED = { name = "AvanteToBeDeleted", bg = "#ffcccc", strikethrough = true }, } Highlights.conflict = { @@ -46,7 +47,11 @@ M.setup = function() end) :each(function(_, hl) if not has_set_colors(hl.name) then - api.nvim_set_hl(0, hl.name, { fg = hl.fg or nil, bg = hl.bg or nil, link = hl.link or nil }) + api.nvim_set_hl( + 0, + hl.name, + { fg = hl.fg or nil, bg = hl.bg or nil, link = hl.link or nil, strikethrough = hl.strikethrough } + ) end end) end diff --git a/lua/avante/suggestion.lua b/lua/avante/suggestion.lua index a23a163..793ad72 100644 --- a/lua/avante/suggestion.lua +++ b/lua/avante/suggestion.lua @@ -21,11 +21,11 @@ local SUGGESTION_NS = api.nvim_create_namespace("avante_suggestion") ---@class avante.Suggestion ---@field id number ---@field augroup integer ----@field extmark_id integer ---@field ignore_patterns table ---@field negate_patterns table ---@field _timer? table ---@field _contexts table +---@field is_on_throttle boolean local Suggestion = {} Suggestion.__index = Suggestion @@ -37,11 +37,11 @@ function Suggestion:new(id) local gitignore_patterns, gitignore_negate_patterns = Utils.parse_gitignore(gitignore_path) instance.id = id - instance.extmark_id = 1 instance._timer = nil instance._contexts = {} instance.ignore_patterns = gitignore_patterns instance.negate_patterns = gitignore_negate_patterns + instance.is_on_throttle = false if Config.behaviour.auto_suggestions then if not vim.g.avante_login or vim.g.avante_login == false then api.nvim_exec_autocmds("User", { pattern = Providers.env.REQUEST_LOGIN_PATTERN }) @@ -80,7 +80,13 @@ function Suggestion:suggest() role = "user", content = [[ a.py -def fib + +L1: def fib +L2: +L3: if __name__ == "__main__": +L4: # just pass +L5: pass + ]], }, { @@ -95,11 +101,30 @@ function Suggestion:suggest() role = "assistant", content = [[ [ - { - "row": 1, - "col": 8, - "content": "(n):\n if n < 2:\n return n\n return fib(n - 1) + fib(n - 2)" - } + [ + { + "start_row": 1, + "end_row": 1, + "content": "def fib(n):\n if n < 2:\n return n\n return fib(n - 1) + fib(n - 2)" + }, + { + "start_row": 4, + "end_row": 5, + "content": " fib(int(input()))" + }, + ], + [ + { + "start_row": 1, + "end_row": 1, + "content": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n yield a\n a, b = b, a + b" + }, + { + "start_row": 4, + "end_row": 5, + "content": " list(fib(int(input())))" + }, + ] ] ]], }, @@ -128,21 +153,49 @@ function Suggestion:suggest() full_response = full_response:gsub("(.-)\n```\n?$", "%1") -- Remove everything before the first '[' to ensure we get just the JSON array full_response = full_response:gsub("^.-(%[.*)", "%1") - local ok, suggestions = pcall(vim.json.decode, full_response) + local ok, suggestions_list = pcall(vim.json.decode, full_response) if not ok then Utils.error("Error while decoding suggestions: " .. full_response, { once = true, title = "Avante" }) return end - if not suggestions then + if not suggestions_list then Utils.info("No suggestions found", { once = true, title = "Avante" }) return end - suggestions = vim - .iter(suggestions) - :map(function(s) return { row = s.row, col = s.col, content = Utils.trim_all_line_numbers(s.content) } end) + local current_lines = Utils.get_buf_lines(0, -1, bufnr) + suggestions_list = vim + .iter(suggestions_list) + :map(function(suggestions) + local new_suggestions = vim + .iter(suggestions) + :map(function(s) + local lines = vim.split(s.content, "\n") + local new_start_row = s.start_row + local new_content_lines = lines + for i = s.start_row, s.start_row + #lines - 1 do + if current_lines[i] == lines[1] then + new_start_row = i + 1 + new_content_lines = vim.list_slice(new_content_lines, 2) + else + break + end + end + return { + id = s.start_row, + original_start_row = s.start_row, + start_row = new_start_row, + end_row = s.end_row, + content = Utils.trim_all_line_numbers(table.concat(new_content_lines, "\n")), + } + end) + :totable() + --- sort the suggestions by start_row + table.sort(new_suggestions, function(a, b) return a.start_row < b.start_row end) + return new_suggestions + end) :totable() - ctx.suggestions = suggestions - ctx.current_suggestion_idx = 1 + ctx.suggestions_list = suggestions_list + ctx.current_suggestions_idx = 1 self:show() end) end, @@ -155,74 +208,92 @@ function Suggestion:show() if not fn.mode():match("^[iR]") then return end local ctx = self:ctx() - local suggestion = ctx.suggestions[ctx.current_suggestion_idx] - if not suggestion then return end - - local cursor_row, cursor_col = Utils.get_cursor_pos() - - if suggestion.row < cursor_row then return end local bufnr = api.nvim_get_current_buf() - local row = suggestion.row - local col = suggestion.col - local content = suggestion.content - local lines = vim.split(content, "\n") + local suggestions = ctx.suggestions_list and ctx.suggestions_list[ctx.current_suggestions_idx] or nil - local extmark_col = cursor_col + if not suggestions then return end - if cursor_row < row then extmark_col = 0 end + for _, suggestion in ipairs(suggestions) do + local start_row = suggestion.start_row + local end_row = suggestion.end_row + local content = suggestion.content - local current_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + local lines = vim.split(content, "\n") - if cursor_row == row then - local cursor_line_col = #current_lines[cursor_row] - 1 - if cursor_col ~= cursor_line_col then - local current_line = current_lines[cursor_row] - lines[1] = lines[1] .. current_line:sub(col + 1, -1) + local current_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local virt_text_win_col = 0 + + if + start_row == end_row + and current_lines[start_row] + and #lines > 0 + and vim.startswith(lines[1], current_lines[start_row]) + then + virt_text_win_col = #current_lines[start_row] + lines[1] = string.sub(lines[1], #current_lines[start_row] + 1) + end + + local virt_lines = {} + + for _, line in ipairs(lines) do + table.insert(virt_lines, { { line, Highlights.SUGGESTION } }) + end + + local extmark = { + id = suggestion.id, + virt_text_win_col = virt_text_win_col, + virt_lines = virt_lines, + } + + if virt_text_win_col > 0 then + extmark.virt_text = { { lines[1], Highlights.SUGGESTION } } + extmark.virt_lines = vim.list_slice(virt_lines, 2) + end + + extmark.hl_mode = "combine" + + local buf_lines = Utils.get_buf_lines(0, -1, bufnr) + local buf_lines_count = #buf_lines + + while buf_lines_count < end_row do + api.nvim_buf_set_lines(bufnr, buf_lines_count, -1, false, { "" }) + buf_lines_count = buf_lines_count + 1 + end + + if virt_text_win_col > 0 or start_row - 2 < 0 then + api.nvim_buf_set_extmark(bufnr, SUGGESTION_NS, start_row - 1, 0, extmark) + else + api.nvim_buf_set_extmark(bufnr, SUGGESTION_NS, start_row - 2, 0, extmark) + end + + for i = start_row, end_row do + if i == start_row and virt_text_win_col > 0 then goto continue end + Utils.debug("add highlight", i - 1) + api.nvim_buf_add_highlight(bufnr, SUGGESTION_NS, Highlights.TO_BE_DELETED, i - 1, 0, -1) + ::continue:: end end - - local extmark = { - id = self.extmark_id, - virt_text_win_col = col, - virt_text = { { lines[1], Highlights.SUGGESTION } }, - } - - if #lines > 1 then - extmark.virt_lines = {} - for i = 2, #lines do - extmark.virt_lines[i - 1] = { { lines[i], Highlights.SUGGESTION } } - end - end - - extmark.hl_mode = "combine" - - local buf_lines = Utils.get_buf_lines(0, -1, bufnr) - local buf_lines_count = #buf_lines - - while buf_lines_count < row do - api.nvim_buf_set_lines(bufnr, buf_lines_count, -1, false, { "" }) - buf_lines_count = buf_lines_count + 1 - end - - api.nvim_buf_set_extmark(bufnr, SUGGESTION_NS, row - 1, extmark_col, extmark) end function Suggestion:is_visible() - return not not api.nvim_buf_get_extmark_by_id(0, SUGGESTION_NS, self.extmark_id, { details = false })[1] + local extmarks = api.nvim_buf_get_extmarks(0, SUGGESTION_NS, 0, -1, { details = false }) + return #extmarks > 0 end -function Suggestion:hide() api.nvim_buf_del_extmark(0, SUGGESTION_NS, self.extmark_id) end +function Suggestion:hide() api.nvim_buf_clear_namespace(0, SUGGESTION_NS, 0, -1) end function Suggestion:ctx() local bufnr = api.nvim_get_current_buf() local ctx = self._contexts[bufnr] if not ctx then ctx = { - suggestions = {}, - current_suggestion_idx = 0, + suggestions_list = {}, + current_suggestions_idx = 0, prev_doc = {}, + internal_move = false, } self._contexts[bufnr] = ctx end @@ -244,15 +315,15 @@ end function Suggestion:next() local ctx = self:ctx() - if #ctx.suggestions == 0 then return end - ctx.current_suggestion_idx = (ctx.current_suggestion_idx % #ctx.suggestions) + 1 + if #ctx.suggestions_list == 0 then return end + ctx.current_suggestions_idx = (ctx.current_suggestions_idx % #ctx.suggestions_list) + 1 self:show() end function Suggestion:prev() local ctx = self:ctx() - if #ctx.suggestions == 0 then return end - ctx.current_suggestion_idx = ((ctx.current_suggestion_idx - 2 + #ctx.suggestions) % #ctx.suggestions) + 1 + if #ctx.suggestions_list == 0 then return end + ctx.current_suggestions_idx = ((ctx.current_suggestions_idx - 2 + #ctx.suggestions_list) % #ctx.suggestions_list) + 1 self:show() end @@ -262,56 +333,131 @@ function Suggestion:dismiss() self:reset() end +function Suggestion:get_current_suggestion() + local ctx = self:ctx() + local suggestions = ctx.suggestions_list and ctx.suggestions_list[ctx.current_suggestions_idx] or nil + if not suggestions then return nil end + local cursor_row, _ = Utils.get_cursor_pos(0) + Utils.debug("cursor row", cursor_row) + for _, suggestion in ipairs(suggestions) do + if suggestion.original_start_row - 1 <= cursor_row and suggestion.end_row >= cursor_row then return suggestion end + end +end + +function Suggestion:get_next_suggestion() + local ctx = self:ctx() + local suggestions = ctx.suggestions_list and ctx.suggestions_list[ctx.current_suggestions_idx] or nil + if not suggestions then return nil end + local cursor_row, _ = Utils.get_cursor_pos() + local new_suggestions = {} + for _, suggestion in ipairs(suggestions) do + table.insert(new_suggestions, suggestion) + end + --- sort the suggestions by cursor distance + table.sort( + new_suggestions, + function(a, b) return math.abs(a.start_row - cursor_row) < math.abs(b.start_row - cursor_row) end + ) + --- get the closest suggestion to the cursor + return new_suggestions[1] +end + function Suggestion:accept() -- Llm.cancel_inflight_request() - api.nvim_buf_del_extmark(0, SUGGESTION_NS, self.extmark_id) local ctx = self:ctx() - local suggestion = ctx.suggestions and ctx.suggestions[ctx.current_suggestion_idx] or nil - if not suggestion then + local suggestions = ctx.suggestions_list and ctx.suggestions_list[ctx.current_suggestions_idx] or nil + Utils.debug("suggestions", suggestions) + if not suggestions then if Config.mappings.suggestion and Config.mappings.suggestion.accept == "" then api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", true) end return end - local bufnr = api.nvim_get_current_buf() - local current_lines = Utils.get_buf_lines(0, -1, bufnr) - local row = suggestion.row - local col = suggestion.col - local content = suggestion.content - local lines = vim.split(content, "\n") - local cursor_row, cursor_col = Utils.get_cursor_pos() - if row > cursor_row then api.nvim_buf_set_lines(bufnr, row - 1, row - 1, false, { "" }) end - local line_count = #lines - if line_count > 0 then - if cursor_row == row then - local cursor_line_col = #current_lines[cursor_row] - 1 - if cursor_col ~= cursor_line_col then - local current_line_ = current_lines[cursor_row] - lines[1] = lines[1] .. current_line_:sub(col + 1, -1) - end - end - local current_line = current_lines[row] or "" - local current_line_max_col = #current_line - 1 - local start_col = col - if start_col > current_line_max_col then - lines[1] = string.rep(" ", start_col - current_line_max_col - 1) .. lines[1] - start_col = -1 - end - api.nvim_buf_set_text(bufnr, row - 1, start_col, row - 1, -1, { lines[1] }) - if #lines > 1 then - local insert_lines = vim.list_slice(lines, 2) - api.nvim_buf_set_lines(bufnr, row, row, true, insert_lines) + local suggestion = self:get_current_suggestion() + Utils.debug("current suggestion", suggestion) + if not suggestion then + suggestion = self:get_next_suggestion() + if suggestion then + Utils.debug("next suggestion", suggestion) + local lines = api.nvim_buf_get_lines(0, 0, -1, false) + local first_line_row = suggestion.start_row + if first_line_row > 1 then first_line_row = first_line_row - 1 end + local line = lines[first_line_row] + local col = 0 + if line ~= nil then col = #line end + self:set_internal_move(true) + api.nvim_win_set_cursor(0, { first_line_row, col }) + vim.cmd("normal! zz") + vim.cmd("startinsert") + self:set_internal_move(false) + return end end + if not suggestion then return end + api.nvim_buf_del_extmark(0, SUGGESTION_NS, suggestion.id) + local bufnr = api.nvim_get_current_buf() + local start_row = suggestion.start_row + local end_row = suggestion.end_row + local content = suggestion.content + local lines = vim.split(content, "\n") + local cursor_row, _ = Utils.get_cursor_pos() + + local replaced_line_count = end_row - start_row + 1 + + if replaced_line_count > #lines then + Utils.debug("delete lines") + api.nvim_buf_set_lines(bufnr, start_row + #lines - 1, end_row, false, {}) + api.nvim_buf_set_lines(bufnr, start_row - 1, start_row + #lines, false, lines) + else + Utils.debug("replace lines", start_row - 1, end_row, lines) + api.nvim_buf_set_lines(bufnr, start_row - 1, end_row, false, lines) + end + + local row_diff = #lines - replaced_line_count + + ctx.suggestions_list[ctx.current_suggestions_idx] = vim + .iter(suggestions) + :filter(function(s) return s.start_row ~= suggestion.start_row end) + :map(function(s) + if s.start_row > suggestion.start_row then + s.original_start_row = s.original_start_row + row_diff + s.start_row = s.start_row + row_diff + s.end_row = s.end_row + row_diff + end + return s + end) + :totable() + + local line_count = #lines local down_count = line_count - 1 - if row > cursor_row then down_count = down_count + 1 end + if start_row > cursor_row then down_count = down_count + 1 end local cursor_keys = string.rep("", down_count) .. "" - api.nvim_feedkeys(api.nvim_replace_termcodes(cursor_keys, true, false, true), "n", false) + suggestions = ctx.suggestions_list and ctx.suggestions_list[ctx.current_suggestions_idx] or {} - self:hide() - self:reset() + if #suggestions > 0 then self:set_internal_move(true) end + api.nvim_feedkeys(api.nvim_replace_termcodes(cursor_keys, true, false, true), "n", false) + if #suggestions > 0 then self:set_internal_move(false) end +end + +function Suggestion:is_internal_move() + local ctx = self:ctx() + Utils.debug("is internal move", ctx and ctx.internal_move) + return ctx and ctx.internal_move +end + +function Suggestion:set_internal_move(internal_move) + local ctx = self:ctx() + if not internal_move then + vim.schedule(function() + Utils.debug("set internal move", internal_move) + ctx.internal_move = internal_move + end) + else + Utils.debug("set internal move", internal_move) + ctx.internal_move = internal_move + end end function Suggestion:setup_autocmds() @@ -319,13 +465,20 @@ function Suggestion:setup_autocmds() local last_cursor_pos = {} local check_for_suggestion = Utils.debounce(function() + if self.is_on_throttle then return end local current_cursor_pos = api.nvim_win_get_cursor(0) if last_cursor_pos[1] == current_cursor_pos[1] and last_cursor_pos[2] == current_cursor_pos[2] then + self.is_on_throttle = true + vim.defer_fn(function() self.is_on_throttle = false end, Config.suggestion.throttle) self:suggest() end - end, 700) + end, Config.suggestion.debounce) local function suggest_callback() + if self.is_on_throttle then return end + + if self:is_internal_move() then return end + if not vim.bo.buflisted then return end if vim.bo.buftype ~= "" then return end diff --git a/lua/avante/templates/suggesting.avanterules b/lua/avante/templates/suggesting.avanterules index 44236ff..87d0148 100644 --- a/lua/avante/templates/suggesting.avanterules +++ b/lua/avante/templates/suggesting.avanterules @@ -5,22 +5,45 @@ Your task is to suggest code modifications at the cursor position. Follow these 2. You must follow this JSON format when suggesting modifications: {% raw %} [ - { - "row": ${row}, - "col": ${column}, - "content": "Your suggested code here" - } + [ + { + "start_row": ${start_row}, + "end_row": ${end_row}, + "content": "Your suggested code here" + }, + { + "start_row": ${start_row}, + "end_row": ${end_row}, + "content": "Your suggested code here" + } + ], + [ + { + "start_row": ${start_row}, + "end_row": ${end_row}, + "content": "Your suggested code here" + }, + { + "start_row": ${start_row}, + "end_row": ${end_row}, + "content": "Your suggested code here" + } ] {% endraw %} + JSON fields explanation: + start_row: The starting row of the code snippet you want to replace (1-indexed), inclusive + end_row: The ending row of the code snippet you want to replace (1-indexed), inclusive + content: The suggested code you want to replace the original code with + Guidelines: 1. Make sure you have maintained the user's existing whitespace and indentation. This is REALLY IMPORTANT! - 2. DO NOT include three backticks: {%raw%}```{%endraw%} in your suggestion. Treat the suggested code AS IS. - 3. Each element in the returned list is a COMPLETE and INDEPENDENT code snippet. - 4. MUST be a valid JSON format. DON NOT be lazy! - 5. Only return the new code to be inserted. - 6. Your returned code should not overlap with the original code in any way. Don't be lazy! - 7. Please strictly check the code around the position and ensure that the complete code after insertion is correct. Don't be lazy! + 2. Each segment in the returned list must be non-overlapping, and together they constitute this code modification. + 3. DO NOT include three backticks: {%raw%}```{%endraw%} in your suggestion. Treat the suggested code AS IS. + 4. Each element in the returned list is a COMPLETE code snippet. + 5. MUST be a valid JSON format. DO NOT be lazy! + 6. Only return the new code to be inserted. DON NOT be lazy! + 7. Please strictly check the code around the position and ensure that the complete code after insertion is correct. DO NOT be lazy! 8. Do not return the entire file content or any surrounding code. 9. Do not include any explanations, comments, or line numbers in your response. 10. Ensure the suggested code fits seamlessly with the existing code structure and indentation. diff --git a/tests/utils/init_spec.lua b/tests/utils/init_spec.lua index 6863e4a..295217e 100644 --- a/tests/utils/init_spec.lua +++ b/tests/utils/init_spec.lua @@ -120,4 +120,57 @@ describe("Utils", function() assert.equals("diagnostics", mentions[2].command) end) end) + + describe("debounce", function() + it("should debounce function calls", function() + local count = 0 + local debounced = Utils.debounce(function() count = count + 1 end, 100) + + -- Call multiple times in quick succession + debounced() + debounced() + debounced() + + -- Should not have executed yet + assert.equals(0, count) + + -- Wait for debounce timeout + vim.wait(200, function() return false end) + + -- Should have executed once + assert.equals(1, count) + end) + + it("should cancel previous timer on new calls", function() + local count = 0 + local debounced = Utils.debounce(function(c) count = c end, 100) + + -- First call + debounced(1) + + -- Wait partial time + vim.wait(50, function() return false end) + + -- Second call should cancel first + debounced(233) + + -- Wait for timeout + vim.wait(200, function() return false end) + + -- Should only execute the latest once + assert.equals(233, count) + end) + + it("should pass arguments correctly", function() + local result + local debounced = Utils.debounce(function(x, y) result = x + y end, 100) + + debounced(2, 3) + + -- Wait for timeout + vim.wait(200, function() return false end) + + assert.equals(5, result) + end) + end) end)