diff --git a/lua/avante/history/render.lua b/lua/avante/history/render.lua index 8d863d2..797763d 100644 --- a/lua/avante/history/render.lua +++ b/lua/avante/history/render.lua @@ -362,6 +362,9 @@ function M.get_tool_display_name(message) local pieces = vim.split(param, "\n") if #pieces > 1 then param = pieces[1] .. "..." end end + if native_tool_name == "execute" and not param then + if message.acp_tool_call and message.acp_tool_call.title then param = message.acp_tool_call.title end + end if not param and path then local relative_path = Utils.relative_path(path) param = relative_path @@ -466,7 +469,13 @@ local function tool_to_lines(item, message, messages, expanded) end end end - if #lines <= 1 then table.insert(lines, Line:new({ { decoration }, { "completed" } })) end + if #lines <= 1 then + if state == "generating" then + table.insert(lines, Line:new({ { decoration }, { "...", Highlights.AVANTE_COMMENT_FG } })) + else + table.insert(lines, Line:new({ { decoration }, { "completed" } })) + end + end local last_line = lines[#lines] last_line.sections[1][1] = "╰─ " return lines diff --git a/lua/avante/llm.lua b/lua/avante/llm.lua index 7bdb21d..e41ad8a 100644 --- a/lua/avante/llm.lua +++ b/lua/avante/llm.lua @@ -14,7 +14,7 @@ local Providers = require("avante.providers") local LLMToolHelpers = require("avante.llm_tools.helpers") local LLMTools = require("avante.llm_tools") local History = require("avante.history") -local Selector = require("avante.ui.selector") +local Highlights = require("avante.highlights") ---@class avante.LLM local M = {} @@ -801,9 +801,10 @@ end ---@param opts AvanteLLMStreamOptions function M._stream_acp(opts) Utils.debug("use ACP", Config.provider) - local Render = require("avante.history.render") ---@type table local tool_call_messages = {} + ---@type avante.HistoryMessage + local last_tool_call_message = nil local acp_provider = Config.acp_providers[Config.provider] local on_messages_add = function(messages) if opts.on_messages_add then opts.on_messages_add(messages) end @@ -816,6 +817,7 @@ function M._stream_acp(opts) name = update.kind, input = update.rawInput or {}, }) + last_tool_call_message = message message.acp_tool_call = update if update.status == "pending" or update.status == "in_progress" then message.is_calling = true end tool_call_messages[update.toolCallId] = message @@ -908,6 +910,7 @@ function M._stream_acp(opts) tool_call_message.acp_tool_call = update end if tool_call_message.acp_tool_call then + if update.content and next(update.content) == nil then update.content = nil end tool_call_message.acp_tool_call = vim.tbl_deep_extend("force", tool_call_message.acp_tool_call, update) end tool_call_message.tool_use_logs = tool_call_message.tool_use_logs or {} @@ -933,83 +936,76 @@ function M._stream_acp(opts) end end, on_request_permission = function(tool_call, options, callback) - local message = tool_call_messages[tool_call.toolCallId] - if not message then - add_tool_call_message(tool_call) - else - if message.acp_tool_call then - message.acp_tool_call = vim.tbl_deep_extend("force", message.acp_tool_call, tool_call) - tool_call = message.acp_tool_call - end + local sidebar = require("avante").get() + if not sidebar then + Utils.error("Avante sidebar not found") + return end ---@cast tool_call avante.acp.ToolCall local items = vim .iter(options) - :map( - function(item) - return { - id = item.optionId, - title = item.name, - } + :map(function(item) + local icon = item.kind == "allow_once" and "" or "" + if item.kind == "allow_always" then icon = "" end + local hl = nil + if item.kind == "reject_once" or item.kind == "reject_always" then + hl = Highlights.BUTTON_DANGER_HOVER end - ) + return { + id = item.optionId, + name = item.name, + icon = icon, + hl = hl, + } + end) :totable() - local default_item = vim.iter(items):find(function(item) return item.id == options[1].optionId end) - - local function on_select(item_ids) - if not item_ids then return end - local choice = vim.iter(items):find(function(item) return item.id == item_ids[1] end) - if not choice then return end - Utils.debug("on_select", choice.id) - callback(choice.id) + sidebar.permission_button_options = items + sidebar.permission_handler = function(id) + callback(id) + sidebar.scroll = true + sidebar.permission_button_options = nil + sidebar.permission_handler = nil + sidebar._history_cache_invalidated = true + sidebar:update_content("") end - - local tool_name, error = Render.get_tool_display_name(message) - if error then - Utils.error(error) - tool_name = message.message.content[1].name + local message = tool_call_messages[tool_call.toolCallId] + if not message then + message = add_tool_call_message(tool_call) + else + if message.acp_tool_call then + if tool_call.content and next(tool_call.content) == nil then tool_call.content = nil end + message.acp_tool_call = vim.tbl_deep_extend("force", message.acp_tool_call, tool_call) + end end - - local selector = Selector:new({ - title = tool_name, - items = items, - default_item_id = default_item and default_item.name or nil, - provider = Config.selector.provider, - provider_opts = Config.selector.provider_opts, - on_select = on_select, - get_preview_content = function(_) - local file_content = "" - local filetype = "text" - local content = tool_call.content - if type(content) == "table" then - for _, item in ipairs(content) do - if item.type == "content" then - if type(item.content) == "table" then - if item.content.type == "text" then - file_content = file_content .. item.content.text .. "\n\n" - end - end - end - if item.type == "diff" then - local unified_diff = Utils.get_unified_diff(item.oldText, item.newText, { algorithm = "myers" }) - local result = "--- a/" .. item.path .. "\n+++ b/" .. item.path .. "\n" .. unified_diff .. "\n\n" - filetype = "diff" - file_content = file_content .. result - end - end - end - return file_content, filetype - end, - }) - - selector:open() + on_messages_add({ message }) end, on_read_file = function(path, line, limit, callback) local abs_path = Utils.to_absolute_path(path) local lines = Utils.read_file_from_buf_or_disk(abs_path) lines = lines or {} if line ~= nil and limit ~= nil then lines = vim.list_slice(lines, line, line + limit) end - callback(table.concat(lines, "\n")) + local content = table.concat(lines, "\n") + if + last_tool_call_message + and last_tool_call_message.acp_tool_call + and last_tool_call_message.acp_tool_call.kind == "read" + then + if + last_tool_call_message.acp_tool_call.content + and next(last_tool_call_message.acp_tool_call.content) == nil + then + last_tool_call_message.acp_tool_call.content = { + { + type = "content", + content = { + type = "text", + text = content, + }, + }, + } + end + end + callback(content) end, on_write_file = function(path, content, callback) local abs_path = Utils.to_absolute_path(path) diff --git a/lua/avante/sidebar.lua b/lua/avante/sidebar.lua index 128c293..1e5049b 100644 --- a/lua/avante/sidebar.lua +++ b/lua/avante/sidebar.lua @@ -21,6 +21,7 @@ local Render = require("avante.history.render") local Line = require("avante.ui.line") local LRUCache = require("avante.utils.lru_cache") local logo = require("avante.utils.logo") +local ButtonGroupLine = require("avante.ui.button_group_line") local RESULT_BUF_NAME = "AVANTE_RESULT" local VIEW_BUFFER_UPDATED_PATTERN = "AvanteViewBufferUpdated" @@ -79,7 +80,8 @@ Sidebar.__index = Sidebar ---@field acp_client avante.acp.ACPClient | nil ---@field acp_session_id string | nil ---@field post_render? fun(sidebar: avante.Sidebar) ----@field message_button_handlers table> +---@field permission_handler fun(id: string) | nil +---@field permission_button_options ({ id: string, icon: string|nil, name: string }[]) | nil ---@field expanded_message_uuids table ---@field tool_message_positions table ---@field skip_line_count integer | nil @@ -116,7 +118,6 @@ function Sidebar:new(id) _cached_history_lines = nil, _history_cache_invalidated = true, post_render = nil, - message_handlers = {}, tool_message_positions = {}, expanded_message_ids = {}, current_tool_use_extmark_id = nil, @@ -156,7 +157,6 @@ function Sidebar:reset() self.scroll = true self.old_result_lines = {} self.token_count = nil - self.message_button_handlers = {} self.tool_message_positions = {} self.expanded_message_uuids = {} self.current_tool_use_extmark_id = nil @@ -868,7 +868,20 @@ function Sidebar:handle_expand_message(message_uuid, expanded) Utils.debug("handle_expand_message", message_uuid, expanded) self.expanded_message_uuids[message_uuid] = expanded self._history_cache_invalidated = true + local old_scroll = self.scroll + self.scroll = false self:update_content("") + self.scroll = old_scroll + vim.defer_fn(function() + local cursor_line = api.nvim_win_get_cursor(self.containers.result.winid)[1] + local positions = self.tool_message_positions[message_uuid] + if positions then + local skip_line_count = self.skip_line_count or 0 + if cursor_line > positions[2] + skip_line_count then + api.nvim_win_set_cursor(self.containers.result.winid, { positions[2] + skip_line_count, 0 }) + end + end + end, 100) end function Sidebar:edit_user_request() @@ -1760,6 +1773,16 @@ function Sidebar:update_content(content, opts) api.nvim_set_option_value("filetype", "Avante", { buf = bufnr }) Utils.lock_buf(bufnr) + vim.defer_fn(function() + if self.permission_button_options and self.permission_handler then + local cur_winid = api.nvim_get_current_win() + if cur_winid == self.containers.result.winid then + local line_count = api.nvim_buf_line_count(bufnr) + api.nvim_win_set_cursor(cur_winid, { line_count - 3, 0 }) + end + end + end, 100) + if opts.focus and not self:is_focused_on_result() then xpcall(function() api.nvim_set_current_win(self.containers.result.winid) end, function(err) Utils.debug("Failed to set current win:", err) @@ -1840,13 +1863,13 @@ function Sidebar:get_layout() return vim.tbl_contains({ "left", "right" }, calculate_config_window_position()) and "vertical" or "horizontal" end +---@param ctx table ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ----@param ctx table ---@param ignore_record_prefix boolean | nil ----@param expanded boolean | nil ---@return avante.ui.Line[] -local function _get_message_lines(message, messages, ctx, ignore_record_prefix, expanded) +function Sidebar:_get_message_lines(ctx, message, messages, ignore_record_prefix) + local expanded = self.expanded_message_uuids[message.uuid] if message.visible == false then return {} end local lines = Render.message_to_lines(message, messages, expanded) if message.is_user_submission and not ignore_record_prefix then @@ -1892,15 +1915,24 @@ end local _message_to_lines_lru_cache = LRUCache:new(100) +---@param ctx table ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ----@param ctx table ---@param ignore_record_prefix boolean | nil ----@param expanded boolean | nil ---@return avante.ui.Line[] -local function get_message_lines(message, messages, ctx, ignore_record_prefix, expanded) +function Sidebar:get_message_lines(ctx, message, messages, ignore_record_prefix) + local expanded = self.expanded_message_uuids[message.uuid] if message.state == "generating" or message.is_calling then - return _get_message_lines(message, messages, ctx, ignore_record_prefix, expanded) + local lines = self:_get_message_lines(ctx, message, messages, ignore_record_prefix) + if self.permission_handler and self.permission_button_options then + local button_group_line = ButtonGroupLine:new(self.permission_button_options, { + on_click = self.permission_handler, + group_label = "Waiting for Confirmation... ", + }) + table.insert(lines, Line:new({ { "" } })) + table.insert(lines, button_group_line) + end + return lines end local text_len = 0 local content = message.message.content @@ -1920,7 +1952,7 @@ local function get_message_lines(message, messages, ctx, ignore_record_prefix, e local cache_key = message.uuid .. ":" .. tostring(text_len) .. ":" .. tostring(expanded == true) local cached_lines = _message_to_lines_lru_cache:get(cache_key) if cached_lines then return cached_lines end - local lines = _get_message_lines(message, messages, ctx, ignore_record_prefix, expanded) + local lines = self:_get_message_lines(ctx, message, messages, ignore_record_prefix) _message_to_lines_lru_cache:set(cache_key, lines) return lines end @@ -1937,8 +1969,7 @@ function Sidebar:get_history_lines(history, ignore_record_prefix) local tool_message_positions = {} local is_first_user_submission = true for _, message in ipairs(history_messages) do - local expanded = self.expanded_message_uuids[message.uuid] - local lines = get_message_lines(message, history_messages, ctx, ignore_record_prefix, expanded) + local lines = self:get_message_lines(ctx, message, history_messages, ignore_record_prefix) if #lines == 0 then goto continue end if message.is_user_submission then if not is_first_user_submission then @@ -1961,6 +1992,8 @@ function Sidebar:get_history_lines(history, ignore_record_prefix) ::continue:: end table.insert(res, Line:new({ { "" } })) + table.insert(res, Line:new({ { "" } })) + table.insert(res, Line:new({ { "" } })) return res, tool_message_positions end @@ -2188,7 +2221,6 @@ function Sidebar:new_chat(args, cb) self:reload_chat_history() self.current_state = nil self.acp_session_id = nil - self.message_button_handlers = {} self.expanded_message_uuids = {} self.tool_message_positions = {} self.current_tool_use_extmark_id = nil diff --git a/lua/avante/ui/button_group_line.lua b/lua/avante/ui/button_group_line.lua new file mode 100644 index 0000000..e46e129 --- /dev/null +++ b/lua/avante/ui/button_group_line.lua @@ -0,0 +1,241 @@ +local Highlights = require("avante.highlights") +local Line = require("avante.ui.line") +local Utils = require("avante.utils") + +---@class avante.ui.ButtonGroupLine +---@field _line avante.ui.Line +---@field _button_options { id: string, icon?: string, name: string, hl?: string }[] +---@field _focus_index integer +---@field _group_label string|nil +---@field _start_col integer +---@field _button_pos integer[][] +---@field _ns_id integer|nil +---@field _bufnr integer|nil +---@field _line_1b integer|nil +---@field on_click? fun(id: string) +local ButtonGroupLine = {} +ButtonGroupLine.__index = ButtonGroupLine + +-- per-buffer registry for dispatching shared keymaps/autocmds +local registry ---@type table, mapped: boolean, autocmd: integer|nil }> +registry = {} + +local function ensure_dispatch(bufnr) + local entry = registry[bufnr] + if not entry then + entry = { lines = {}, mapped = false, autocmd = nil } + registry[bufnr] = entry + end + if not entry.mapped then + -- Tab: next button if on a group line; otherwise fall back to sidebar switch_windows + vim.keymap.set("n", "", function() + local row, _ = unpack(vim.api.nvim_win_get_cursor(0)) + local group = entry.lines[row] + if not group then + local ok, sidebar = pcall(require, "avante") + if ok and sidebar and sidebar.get then + local sb = sidebar.get() + if sb and sb.switch_window_focus then + sb:switch_window_focus("next") + return + end + end + -- Fallback to raw if sidebar is unavailable + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + return + end + group._focus_index = group._focus_index + 1 + if group._focus_index > #group._button_options then group._focus_index = 1 end + group:_refresh_highlights() + group:_move_cursor_to_focus() + end, { buffer = bufnr, nowait = true }) + + vim.keymap.set("n", "", function() + local row, _ = unpack(vim.api.nvim_win_get_cursor(0)) + local group = entry.lines[row] + if not group then + local ok, sidebar = pcall(require, "avante") + if ok and sidebar and sidebar.get then + local sb = sidebar.get() + if sb and sb.switch_window_focus then + sb:switch_window_focus("previous") + return + end + end + -- Fallback to raw if sidebar is unavailable + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + return + end + group._focus_index = group._focus_index - 1 + if group._focus_index < 1 then group._focus_index = #group._button_options end + group:_refresh_highlights() + group:_move_cursor_to_focus() + end, { buffer = bufnr, nowait = true }) + + vim.keymap.set("n", "", function() + local row, _ = unpack(vim.api.nvim_win_get_cursor(0)) + local group = entry.lines[row] + if not group then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + return + end + group:_click_focused() + end, { buffer = bufnr, nowait = true }) + + -- Mouse click to activate + vim.api.nvim_buf_set_keymap(bufnr, "n", "", "", { + callback = function() + local pos = vim.fn.getmousepos() + local row, col = pos.winrow, pos.wincol + local group = entry.lines[row] + if not group then return end + group:_update_focus_by_col(col) + group:_click_focused() + end, + noremap = true, + silent = true, + }) + + -- CursorMoved hover highlight + entry.autocmd = vim.api.nvim_create_autocmd("CursorMoved", { + buffer = bufnr, + callback = function() + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + local group = entry.lines[row] + if not group then return end + group:_update_focus_by_col(col) + end, + }) + + entry.mapped = true + end +end + +local function cleanup_dispatch_if_empty(bufnr) + local entry = registry[bufnr] + if not entry then return end + -- Do not delete keymaps when no button lines remain. + -- Deleting buffer-local mappings would not restore any previous mapping, + -- which breaks the original Tab behavior in the sidebar. + -- We intentionally keep the keymaps and autocmds; they safely no-op or + -- fall back when not on a button group line. +end + +---@param button_options { id: string, icon: string|nil, name: string, hl?: string }[] +---@param opts? { on_click: fun(id: string), start_col?: integer, group_label?: string } +function ButtonGroupLine:new(button_options, opts) + opts = opts or {} + local o = setmetatable({}, ButtonGroupLine) + o._button_options = vim.deepcopy(button_options) + o._focus_index = 1 + o._start_col = opts.start_col or 0 + o._group_label = opts.group_label + + local BUTTON_NORMAL = Highlights.BUTTON_DEFAULT + local BUTTON_FOCUS = Highlights.BUTTON_DEFAULT_HOVER + + local sections = {} + if o._group_label and #o._group_label > 0 then table.insert(sections, { o._group_label .. " " }) end + local btn_sep = " " + for i, opt in ipairs(o._button_options) do + local label + if opt.icon and #opt.icon > 0 then + label = string.format(" %s %s ", opt.icon, opt.name) + else + label = string.format(" %s ", opt.name) + end + local focus_hl = opt.hl or BUTTON_FOCUS + table.insert(sections, { label, function() return (o._focus_index == i) and focus_hl or BUTTON_NORMAL end }) + if i < #o._button_options then table.insert(sections, { btn_sep }) end + end + o._line = Line:new(sections) + + -- precalc positions for quick hover/click checks + o._button_pos = {} + local sec_idx = (o._group_label and #o._group_label > 0) and 2 or 1 + for i = 1, #o._button_options do + local start_end = o._line:get_section_pos(sec_idx, o._start_col) + o._button_pos[i] = { start_end[1], start_end[2] } + if i < #o._button_options then + sec_idx = sec_idx + 2 + else + sec_idx = sec_idx + 1 + end + end + + if opts.on_click then o.on_click = opts.on_click end + + return o +end + +function ButtonGroupLine:__tostring() return string.rep(" ", self._start_col) .. tostring(self._line) end + +---@param ns_id integer +---@param bufnr integer +---@param line_0b integer +---@param _offset integer|nil -- ignored; offset handled in __tostring and pos precalc +function ButtonGroupLine:set_highlights(ns_id, bufnr, line_0b, _offset) + _offset = _offset or 0 + self._ns_id = ns_id + self._bufnr = bufnr + self._line_1b = line_0b + 1 + self._line:set_highlights(ns_id, bufnr, line_0b, self._start_col + _offset) +end + +-- called by utils.update_buffer_lines after content is written +---@param _ns_id integer +---@param bufnr integer +---@param line_1b integer +function ButtonGroupLine:bind_events(_ns_id, bufnr, line_1b) + self._bufnr = bufnr + self._line_1b = line_1b + ensure_dispatch(bufnr) + local entry = registry[bufnr] + entry.lines[line_1b] = self +end + +---@param bufnr integer +---@param line_1b integer +function ButtonGroupLine:unbind_events(bufnr, line_1b) + local entry = registry[bufnr] + if not entry then return end + entry.lines[line_1b] = nil + cleanup_dispatch_if_empty(bufnr) +end + +function ButtonGroupLine:_refresh_highlights() + if not (self._ns_id and self._bufnr and self._line_1b) then return end + --- refresh content + Utils.unlock_buf(self._bufnr) + vim.api.nvim_buf_set_lines(self._bufnr, self._line_1b - 1, self._line_1b, false, { tostring(self) }) + Utils.lock_buf(self._bufnr) + self._line:set_highlights(self._ns_id, self._bufnr, self._line_1b - 1, self._start_col) +end + +function ButtonGroupLine:_move_cursor_to_focus() + local pos = self._button_pos[self._focus_index] + if not pos then return end + local winid = require("avante.utils").get_winid(self._bufnr) + if winid and vim.api.nvim_win_is_valid(winid) then vim.api.nvim_win_set_cursor(winid, { self._line_1b, pos[1] }) end +end + +---@param col integer 0-based column +function ButtonGroupLine:_update_focus_by_col(col) + for i, rng in ipairs(self._button_pos) do + if col >= rng[1] and col <= rng[2] then + if self._focus_index ~= i then + self._focus_index = i + self:_refresh_highlights() + end + return + end + end +end + +function ButtonGroupLine:_click_focused() + local opt = self._button_options[self._focus_index] + if not opt then return end + if self.on_click then pcall(self.on_click, opt.id) end +end + +return ButtonGroupLine diff --git a/lua/avante/ui/line.lua b/lua/avante/ui/line.lua index 4a06c26..675ddf2 100644 --- a/lua/avante/ui/line.lua +++ b/lua/avante/ui/line.lua @@ -39,10 +39,13 @@ function M:get_section_pos(section_index, offset) for i = 1, section_index - 1 do if i == section_index then break end local section = self.sections[i] - col_start = col_start + #section + local text = type(section) == "table" and section[1] or section + col_start = col_start + #text end - return { offset + col_start, offset + col_start + #self.sections[section_index] } + local current = self.sections[section_index] + local text = type(current) == "table" and current[1] or current + return { offset + col_start, offset + col_start + #text } end function M:__tostring() @@ -59,4 +62,8 @@ function M:__eq(other) return vim.deep_equal(self.sections, other.sections) end +function M:bind_events(ns_id, bufnr, line) end + +function M:unbind_events(bufnr, line) end + return M diff --git a/lua/avante/utils/init.lua b/lua/avante/utils/init.lua index e972b2c..1a1cf38 100644 --- a/lua/avante/utils/init.lua +++ b/lua/avante/utils/init.lua @@ -397,7 +397,7 @@ function M.notify(msg, opts) local n = opts.once and vim.notify_once or vim.notify n(msg, opts.level or vim.log.levels.INFO, { on_open = function(win) - local ok = pcall(function() vim.treesitter.language.add("markdown") end) + pcall(function() vim.treesitter.language.add("markdown") end) vim.wo[win].conceallevel = 3 vim.wo[win].concealcursor = "" vim.wo[win].spell = false @@ -1210,47 +1210,6 @@ function M.open_buffer(path, set_current_buf) return bufnr end ----@param old_lines avante.ui.Line[] ----@param new_lines avante.ui.Line[] ----@return { start_line: integer, end_line: integer, content: avante.ui.Line[] }[] -local function get_lines_diff(old_lines, new_lines) - local remaining_lines = 100 - local start_line = 0 - if #new_lines >= #old_lines then - start_line = math.max(#old_lines - remaining_lines, 0) - old_lines = vim.list_slice(old_lines, start_line + 1) - new_lines = vim.list_slice(new_lines, start_line + 1) - end - local diffs = {} - local prev_diff_idx = nil - for i, line in ipairs(new_lines) do - if line ~= old_lines[i] then - if prev_diff_idx == nil then prev_diff_idx = i end - else - if prev_diff_idx ~= nil then - local content = vim.list_slice(new_lines, prev_diff_idx, i - 1) - table.insert(diffs, { start_line = start_line + prev_diff_idx, end_line = start_line + i, content = content }) - prev_diff_idx = nil - end - end - end - if prev_diff_idx ~= nil then - table.insert(diffs, { - start_line = start_line + prev_diff_idx, - end_line = start_line + #new_lines + 1, - content = vim.list_slice(new_lines, prev_diff_idx), - }) - end - if #new_lines < #old_lines then - table.insert( - diffs, - { start_line = start_line + #new_lines + 1, end_line = start_line + #old_lines + 1, content = {} } - ) - end - table.sort(diffs, function(a, b) return a.start_line > b.start_line end) - return diffs -end - ---@param bufnr integer ---@param new_lines string[] ---@return { start_line: integer, end_line: integer, content: string[] }[] @@ -1309,6 +1268,15 @@ function M.update_buffer_lines(ns_id, bufnr, old_lines, new_lines, skip_line_cou end end if diff_start_idx > 0 then + -- Unbind events on old lines that will be replaced/moved + for i = diff_start_idx, #old_lines do + local old_line = old_lines[i] + if old_line and type(old_line.unbind_events) == "function" then + local line_1b = skip_line_count + i + pcall(old_line.unbind_events, old_line, bufnr, line_1b) + end + end + local changed_lines = vim.list_slice(new_lines, diff_start_idx) local text_lines = vim.tbl_map(function(line) return tostring(line) end, changed_lines) vim.api.nvim_buf_set_lines( @@ -1319,10 +1287,26 @@ function M.update_buffer_lines(ns_id, bufnr, old_lines, new_lines, skip_line_cou text_lines ) for i, line in ipairs(changed_lines) do - line:set_highlights(ns_id, bufnr, skip_line_count + diff_start_idx + i - 2) + -- Apply highlights + if type(line.set_highlights) == "function" then + line:set_highlights(ns_id, bufnr, skip_line_count + diff_start_idx + i - 2) + end + -- Bind events if provided by the line + if type(line.bind_events) == "function" then + local line_1b = skip_line_count + diff_start_idx + i - 1 + pcall(line.bind_events, line, ns_id, bufnr, line_1b) + end end end if #old_lines > #new_lines then + -- Unbind events on removed trailing lines + for i = #new_lines + 1, #old_lines do + local old_line = old_lines[i] + if old_line and type(old_line.unbind_events) == "function" then + local line_1b = skip_line_count + i + pcall(old_line.unbind_events, old_line, bufnr, line_1b) + end + end vim.api.nvim_buf_set_lines(bufnr, skip_line_count + #new_lines, skip_line_count + #old_lines, false, {}) end vim.cmd("redraw")