From 0f8507fbf1fc726a1adcae480bab3fc6a8a932ac Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Tue, 7 Jan 2025 11:46:53 -0500 Subject: [PATCH 01/17] feat(chat): Adding buffer watcher --- lua/codecompanion/config.lua | 25 ++-- lua/codecompanion/strategies/chat/init.lua | 34 ++++++ lua/codecompanion/strategies/chat/keymaps.lua | 40 +++++++ .../strategies/chat/references.lua | 24 ++++ lua/codecompanion/strategies/chat/watcher.lua | 109 ++++++++++++++++++ lua/codecompanion/types.lua | 3 + 6 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 lua/codecompanion/strategies/chat/watcher.lua diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index 9f168c98..b66c8281 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -261,11 +261,19 @@ Points to note: callback = "keymaps.pin_reference", description = "Pin Reference", }, + watch = { + modes = { + n = "gw", + }, + index = 10, + callback = "keymaps.toggle_watch", + description = "Watch Buffer", + }, next_chat = { modes = { n = "}", }, - index = 10, + index = 11, callback = "keymaps.next_chat", description = "Next Chat", }, @@ -273,7 +281,7 @@ Points to note: modes = { n = "{", }, - index = 11, + index = 12, callback = "keymaps.previous_chat", description = "Previous Chat", }, @@ -281,7 +289,7 @@ Points to note: modes = { n = "]]", }, - index = 12, + index = 13, callback = "keymaps.next_header", description = "Next Header", }, @@ -289,7 +297,7 @@ Points to note: modes = { n = "[[", }, - index = 13, + index = 14, callback = "keymaps.previous_header", description = "Previous Header", }, @@ -297,7 +305,7 @@ Points to note: modes = { n = "ga", }, - index = 14, + index = 15, callback = "keymaps.change_adapter", description = "Change adapter", }, @@ -305,7 +313,7 @@ Points to note: modes = { n = "gf", }, - index = 14, + index = 15, callback = "keymaps.fold_code", description = "Fold code", }, @@ -313,7 +321,7 @@ Points to note: modes = { n = "gd", }, - index = 15, + index = 16, callback = "keymaps.debug", description = "View debug info", }, @@ -321,7 +329,7 @@ Points to note: modes = { n = "gs", }, - index = 16, + index = 17, callback = "keymaps.toggle_system_prompt", description = "Toggle the system prompt", }, @@ -817,6 +825,7 @@ This is the code, for context: chat = { icons = { pinned_buffer = " ", + watched_buffer = "👀 ", }, window = { layout = "vertical", -- float|vertical|horizontal|buffer diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index 54b1c7e7..3b8ca70d 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -184,6 +184,7 @@ function Chat.new(args) self.parser = parser self.References = require("codecompanion.strategies.chat.references").new({ chat = self }) + self.watcher = require("codecompanion.strategies.chat.watcher").new() self.tools = require("codecompanion.strategies.chat.tools").new({ bufnr = self.bufnr, messages = self.messages }) self.variables = require("codecompanion.strategies.chat.variables").new() @@ -577,6 +578,39 @@ function Chat:submit(opts) local bufnr = self.bufnr local message = ts_parse_messages(self, user_role, self.header_line) + + for _, ref in ipairs(self.refs) do + if ref.bufnr and ref.opts and ref.opts.watched then + local changes = self.watcher:get_changes(ref.bufnr) + log:debug("Checking watched buffer %d, found %d changes", ref.bufnr, changes and #changes or 0) + + if changes and #changes > 0 then + log:debug("Adding changes to messages") + -- Format changes message + local changes_text = string.format( + "Changes detected in `%s` (buffer %d):\n", + vim.fn.fnamemodify(api.nvim_buf_get_name(ref.bufnr), ":t"), + ref.bufnr + ) + + -- Add each change + for _, change in ipairs(changes) do + changes_text = changes_text + .. string.format( + "The file now contains:\n```%s\n%s\n```\n", + vim.bo[ref.bufnr].filetype, + table.concat(change.lines, "\n") + ) + end + + -- Add changes as a message + self:add_message({ + role = config.constants.USER_ROLE, + content = changes_text, + }, { visible = true }) + end + end + end if not self:has_user_messages(message) or message.content == "" then return log:warn("No messages to submit") end diff --git a/lua/codecompanion/strategies/chat/keymaps.lua b/lua/codecompanion/strategies/chat/keymaps.lua index 4ad050be..5ffff5bd 100644 --- a/lua/codecompanion/strategies/chat/keymaps.lua +++ b/lua/codecompanion/strategies/chat/keymaps.lua @@ -354,6 +354,46 @@ M.pin_reference = { end, } +M.toggle_watch = { + desc = "Toggle Watch Buffer", + callback = function(chat) + local current_line = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(chat.bufnr, current_line - 1, current_line, true)[1] + + if not vim.startswith(line, "> - ") then + return + end + + local id = line:gsub("^> %- ", "") + if not chat.References:can_be_watched(id) then + return util.notify("This reference type cannot be watched", vim.log.levels.WARN) + end + + -- Find the reference and toggle watch state + for _, ref in ipairs(chat.refs) do + local clean_id = id:gsub(config.display.chat.icons.pinned_buffer, "") + if ref.id == clean_id then + if not ref.opts then + ref.opts = {} + end + ref.opts.watched = not ref.opts.watched + + if ref.opts.watched then + chat.watcher:watch(ref.bufnr) + util.notify("Now watching buffer " .. ref.id) + else + chat.watcher:unwatch(ref.bufnr) + util.notify("Stopped watching buffer " .. ref.id) + end + + -- Force reference list refresh + chat.References:render() + break + end + end + end, +} + ---@param chat CodeCompanion.Chat ---@param direction number local function move_buffer(chat, direction) diff --git a/lua/codecompanion/strategies/chat/references.lua b/lua/codecompanion/strategies/chat/references.lua index df763be6..e6c1efe6 100644 --- a/lua/codecompanion/strategies/chat/references.lua +++ b/lua/codecompanion/strategies/chat/references.lua @@ -8,12 +8,17 @@ local config = require("codecompanion.config") local api = vim.api local user_role = config.strategies.chat.roles.user local pinned_icon = config.display.chat.icons.pinned_buffer +local watched_icon = config.display.chat.icons.watched_buffer local allowed_pins = { "", "", } +local allowed_watches = { + "", +} + ---Parse the chat buffer to find where to add the references ---@param chat CodeCompanion.Chat ---@return table|nil @@ -108,9 +113,14 @@ function References:add(ref) if not ref.opts then ref.opts = { pinned = false, + watched = false, } end table.insert(self.Chat.refs, ref) + -- If it's a buffer reference and it's being watched, start watching + if ref.bufnr and ref.opts.watched then + self.Chat.watcher:watch(ref.bufnr) + end end local parsed_buffer = ts_parse_buffer(self.Chat) @@ -176,6 +186,8 @@ function References:render() end if ref.opts and ref.opts.pinned then table.insert(lines, string.format("> - %s%s", pinned_icon, ref.id)) + elseif ref.opts and ref.opts.watched then + table.insert(lines, string.format("> - %s%s", watched_icon, ref.id)) else table.insert(lines, string.format("> - %s", ref.id)) end @@ -206,6 +218,18 @@ function References:can_be_pinned(ref) return false end +---Determine if a reference can be watched +---@param ref string +---@return boolean +function References:can_be_watched(ref) + for _, watch in ipairs(allowed_watches) do + if ref:find(watch) then + return true + end + end + return false +end + ---Get the references from the chat buffer ---@return table function References:get_from_chat() diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua new file mode 100644 index 00000000..d9c06047 --- /dev/null +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -0,0 +1,109 @@ +---@class CodeCompanion.BufferChange +---@field start_row number The starting row of the change +---@field end_row number The ending row of the change +---@field lines table The changed lines content +---@field changedtick number The buffer change tick when this change occurred +---@field timestamp number The timestamp when the change occurred +---@field reported boolean Whether this change has been reported to the LLM + +---@class CodeCompanion.BufferState +---@field changes CodeCompanion.BufferChange[] Array of changes for this buffer +---@field last_changedtick number The last recorded change tick + +---@class CodeCompanion.BufferWatcher +---@field buffers table Map of buffer numbers to their states +---@field watch fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Start watching a buffer +---@field unwatch fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Stop watching a buffer +---@field get_changes fun(self: CodeCompanion.BufferWatcher, bufnr: number): CodeCompanion.BufferChange[]|nil Get unreported changes +---@field clear_changes fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Clear all changes for a buffer +local Watcher = {} +local log = require("codecompanion.utils.log") + +function Watcher.new() + return setmetatable({ + buffers = {}, + }, { __index = Watcher }) +end + +function Watcher:watch(bufnr) + if self.buffers[bufnr] then + return + end + + log:debug("Starting to watch buffer: %d", bufnr) + + self.buffers[bufnr] = { + changes = {}, + last_changedtick = vim.api.nvim_buf_get_changedtick(bufnr), + } + + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, buf, changedtick, start_row, start_col, end_row, end_col, _, _) + if not self.buffers[buf] then + return + end + + -- Get the changed lines + local lines = vim.api.nvim_buf_get_lines(buf, start_row, end_row, false) + table.insert(self.buffers[buf].changes, { + start_row = start_row + 1, + end_row = end_row, + lines = lines, + changedtick = changedtick, + timestamp = vim.loop.now(), + reported = false, + }) + log:debug("Recording change in buffer %d: lines %d-%d: %s", buf, start_row + 1, end_row + 1, vim.inspect(lines)) + end, + on_detach = function(_, buf) + self.buffers[buf] = nil + log:debug("Detached from buffer: %d", buf) + end, + }) +end + +function Watcher:unwatch(bufnr) + if self.buffers[bufnr] then + vim.api.nvim_buf_attach(bufnr, false, {}) + end +end + +function Watcher:get_changes(bufnr) + if not self.buffers[bufnr] then + return nil + end + + -- Get unreported changes + local unreported = vim.tbl_filter(function(change) + return not change.reported + end, self.buffers[bufnr].changes) + + if #unreported == 0 then + return nil + end + + -- Sort changes by timestamp and line number + table.sort(unreported, function(a, b) + if a.start_row == b.start_row then + return a.timestamp < b.timestamp + end + return a.start_row < b.start_row + end) + + -- Mark changes as reported + for _, change in ipairs(unreported) do + change.reported = true + end + + log:debug("Found %d unreported changes in buffer %d", #unreported, bufnr) + return unreported +end + +function Watcher:clear_changes(bufnr) + if self.buffers[bufnr] then + self.buffers[bufnr].changes = {} + log:debug("Cleared changes for buffer %d", bufnr) + end +end + +return Watcher diff --git a/lua/codecompanion/types.lua b/lua/codecompanion/types.lua index 6bf9cef1..f203f664 100644 --- a/lua/codecompanion/types.lua +++ b/lua/codecompanion/types.lua @@ -85,6 +85,9 @@ ---@field source string The source of the reference e.g. slash_command ---@field id string The unique ID of the reference which links it to a message in the chat buffer and is displayed to the user ---@field opts? table +---@field opts.pinned? boolean Whether this reference is pinned +---@field opts.watched? boolean Whether this reference is being watched for changes +---@field bufnr? number The buffer number if this is a buffer reference ---@class CodeCompanion.Chat.UI ---@field adapter CodeCompanion.Adapter From 1cf8d60974f53fb6ec17a41d429c4f7d77eba7b5 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Thu, 9 Jan 2025 12:14:36 -0500 Subject: [PATCH 02/17] refactor(watcher): refactor how to consolidate changes --- lua/codecompanion/strategies/chat/init.lua | 6 ++- lua/codecompanion/strategies/chat/watcher.lua | 52 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index 3b8ca70d..5f3d13fd 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -585,7 +585,7 @@ function Chat:submit(opts) log:debug("Checking watched buffer %d, found %d changes", ref.bufnr, changes and #changes or 0) if changes and #changes > 0 then - log:debug("Adding changes to messages") + log:debug("Processing %d changes for buffer %d", #changes, ref.bufnr) -- Format changes message local changes_text = string.format( "Changes detected in `%s` (buffer %d):\n", @@ -597,7 +597,9 @@ function Chat:submit(opts) for _, change in ipairs(changes) do changes_text = changes_text .. string.format( - "The file now contains:\n```%s\n%s\n```\n", + "Lines %d-%d were changed to:\n```%s\n%s\n```\n", + change.start_row, + change.end_row, vim.bo[ref.bufnr].filetype, table.concat(change.lines, "\n") ) diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index d9c06047..b09fc542 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -27,7 +27,7 @@ end function Watcher:watch(bufnr) if self.buffers[bufnr] then - return + log:debug("Buffer %d is already being watched", bufnr) end log:debug("Starting to watch buffer: %d", bufnr) @@ -38,17 +38,24 @@ function Watcher:watch(bufnr) } vim.api.nvim_buf_attach(bufnr, false, { - on_lines = function(_, buf, changedtick, start_row, start_col, end_row, end_col, _, _) + on_lines = function(_, buf, changedtick, start_row, start_col, end_row, end_col, old_end_row, old_end_col) if not self.buffers[buf] then return end + -- Calculate if lines were added or deleted + local old_line_count = old_end_row - start_row + local new_line_count = end_row - start_row + local is_deletion = old_line_count > new_line_count + -- Get the changed lines local lines = vim.api.nvim_buf_get_lines(buf, start_row, end_row, false) table.insert(self.buffers[buf].changes, { start_row = start_row + 1, end_row = end_row, lines = lines, + is_deletion = is_deletion, + old_line_count = old_line_count, changedtick = changedtick, timestamp = vim.loop.now(), reported = false, @@ -70,6 +77,7 @@ end function Watcher:get_changes(bufnr) if not self.buffers[bufnr] then + log:debug("No buffer state found for buffer %d", bufnr) return nil end @@ -82,6 +90,8 @@ function Watcher:get_changes(bufnr) return nil end + log:debug("Found %d unreported changes in buffer %d", #unreported, bufnr) + -- Sort changes by timestamp and line number table.sort(unreported, function(a, b) if a.start_row == b.start_row then @@ -90,13 +100,45 @@ function Watcher:get_changes(bufnr) return a.start_row < b.start_row end) - -- Mark changes as reported + -- Get current buffer state for context + local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + log:debug("Current buffer state: %s", vim.inspect(current_lines)) + + -- Take the last change for each line range + local consolidated = {} + local seen_ranges = {} + + for i = #unreported, 1, -1 do + local change = unreported[i] + local range_key = string.format("%d-%d", change.start_row, change.end_row) + + if not seen_ranges[range_key] then + seen_ranges[range_key] = true + table.insert(consolidated, { + start_row = change.start_row, + end_row = change.end_row, + lines = change.lines, + is_deletion = change.is_deletion, + changedtick = change.changedtick, + timestamp = change.timestamp, + reported = false, + }) + end + end + + -- Sort consolidated changes by line number + table.sort(consolidated, function(a, b) + return a.start_row < b.start_row + end) + + log:debug("Found %d consolidated changes in buffer %d", #consolidated, bufnr) + + -- Mark original changes as reported for _, change in ipairs(unreported) do change.reported = true end - log:debug("Found %d unreported changes in buffer %d", #unreported, bufnr) - return unreported + return consolidated end function Watcher:clear_changes(bufnr) From cb907ee3e0ac895a54a2b8d4365d3f258b802343 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Thu, 9 Jan 2025 12:39:09 -0500 Subject: [PATCH 03/17] refactor(watcher): more reliable chnages report. --- lua/codecompanion/strategies/chat/init.lua | 5 +- lua/codecompanion/strategies/chat/watcher.lua | 55 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index 5f3d13fd..31ac546e 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -594,10 +594,11 @@ function Chat:submit(opts) ) -- Add each change - for _, change in ipairs(changes) do + for i, change in ipairs(changes) do + log:debug("Processing change %d/%d for buffer %d", i, #changes, ref.bufnr) changes_text = changes_text .. string.format( - "Lines %d-%d were changed to:\n```%s\n%s\n```\n", + "Lines %d-%d were modified to:\n```%s\n%s\n```\n", change.start_row, change.end_row, vim.bo[ref.bufnr].filetype, diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index b09fc542..10bd529c 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -71,7 +71,9 @@ end function Watcher:unwatch(bufnr) if self.buffers[bufnr] then + log:debug("Unwatching buffer %d", bufnr) vim.api.nvim_buf_attach(bufnr, false, {}) + self.buffers[bufnr] = nil end end @@ -92,38 +94,41 @@ function Watcher:get_changes(bufnr) log:debug("Found %d unreported changes in buffer %d", #unreported, bufnr) - -- Sort changes by timestamp and line number + -- Sort changes by timestamp table.sort(unreported, function(a, b) - if a.start_row == b.start_row then - return a.timestamp < b.timestamp - end - return a.start_row < b.start_row + return a.timestamp < b.timestamp end) + -- Group changes by line ranges + local changes_by_range = {} + for _, change in ipairs(unreported) do + local range_key = string.format("%d-%d", change.start_row, change.end_row) + if not changes_by_range[range_key] then + changes_by_range[range_key] = {} + end + table.insert(changes_by_range[range_key], change) + end + -- Get current buffer state for context local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) log:debug("Current buffer state: %s", vim.inspect(current_lines)) - -- Take the last change for each line range + -- Take significant changes from each range local consolidated = {} - local seen_ranges = {} - - for i = #unreported, 1, -1 do - local change = unreported[i] - local range_key = string.format("%d-%d", change.start_row, change.end_row) - - if not seen_ranges[range_key] then - seen_ranges[range_key] = true - table.insert(consolidated, { - start_row = change.start_row, - end_row = change.end_row, - lines = change.lines, - is_deletion = change.is_deletion, - changedtick = change.changedtick, - timestamp = change.timestamp, - reported = false, - }) - end + for _, changes in pairs(changes_by_range) do + -- Get first and last change in sequence + local first = changes[1] + local last = changes[#changes] + + table.insert(consolidated, { + start_row = first.start_row, + end_row = last.end_row, + lines = last.lines, + is_deletion = last.is_deletion, + changedtick = last.changedtick, + timestamp = first.timestamp, + reported = false, + }) end -- Sort consolidated changes by line number @@ -131,7 +136,7 @@ function Watcher:get_changes(bufnr) return a.start_row < b.start_row end) - log:debug("Found %d consolidated changes in buffer %d", #consolidated, bufnr) + log:debug("Consolidated %d changes into %d significant changes", #unreported, #consolidated) -- Mark original changes as reported for _, change in ipairs(unreported) do From f40d011f954c94e981c4614ea793fbaabf4c05e1 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Thu, 9 Jan 2025 12:39:42 -0500 Subject: [PATCH 04/17] fix(watcher): fix cleaning the watcher correcly --- lua/codecompanion/strategies/chat/keymaps.lua | 1 + lua/codecompanion/strategies/chat/references.lua | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/codecompanion/strategies/chat/keymaps.lua b/lua/codecompanion/strategies/chat/keymaps.lua index 5ffff5bd..1d7dcb34 100644 --- a/lua/codecompanion/strategies/chat/keymaps.lua +++ b/lua/codecompanion/strategies/chat/keymaps.lua @@ -372,6 +372,7 @@ M.toggle_watch = { -- Find the reference and toggle watch state for _, ref in ipairs(chat.refs) do local clean_id = id:gsub(config.display.chat.icons.pinned_buffer, "") + :gsub(config.display.chat.icons.watched_buffer, "") if ref.id == clean_id then if not ref.opts then ref.opts = {} diff --git a/lua/codecompanion/strategies/chat/references.lua b/lua/codecompanion/strategies/chat/references.lua index e6c1efe6..948f87c7 100644 --- a/lua/codecompanion/strategies/chat/references.lua +++ b/lua/codecompanion/strategies/chat/references.lua @@ -249,7 +249,8 @@ function References:get_from_chat() role = role:gsub("## ", "") elseif role == user_role and query.captures[id] == "ref" then local ref = vim.treesitter.get_node_text(node, chat.bufnr) - ref = ref:gsub("^> %- ", ""):gsub(pinned_icon, "") + -- Clean both pinned and watched icons + ref = ref:gsub("^> %- ", ""):gsub(pinned_icon, ""):gsub(watched_icon, "") table.insert(refs, vim.trim(ref)) end end From ab0266670a2f2f270929e81cc9b7335ec604e21f Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Thu, 9 Jan 2025 13:05:41 -0500 Subject: [PATCH 05/17] fix(watcher): fix unwatch --- lua/codecompanion/strategies/chat/watcher.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index 10bd529c..cd48463d 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -72,7 +72,9 @@ end function Watcher:unwatch(bufnr) if self.buffers[bufnr] then log:debug("Unwatching buffer %d", bufnr) - vim.api.nvim_buf_attach(bufnr, false, {}) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_detach(bufnr) + end self.buffers[bufnr] = nil end end From 1d328f1475bf65b8fe5865136e5ed480778fbd10 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Thu, 9 Jan 2025 23:36:15 -0500 Subject: [PATCH 06/17] refactor(watcher): change the whole mechanism --- lua/codecompanion/strategies/chat/init.lua | 36 ++-- lua/codecompanion/strategies/chat/watcher.lua | 194 ++++++++---------- 2 files changed, 102 insertions(+), 128 deletions(-) diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index 31ac546e..8d597bf9 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -585,28 +585,35 @@ function Chat:submit(opts) log:debug("Checking watched buffer %d, found %d changes", ref.bufnr, changes and #changes or 0) if changes and #changes > 0 then - log:debug("Processing %d changes for buffer %d", #changes, ref.bufnr) - -- Format changes message local changes_text = string.format( "Changes detected in `%s` (buffer %d):\n", vim.fn.fnamemodify(api.nvim_buf_get_name(ref.bufnr), ":t"), ref.bufnr ) - -- Add each change - for i, change in ipairs(changes) do - log:debug("Processing change %d/%d for buffer %d", i, #changes, ref.bufnr) - changes_text = changes_text - .. string.format( - "Lines %d-%d were modified to:\n```%s\n%s\n```\n", - change.start_row, - change.end_row, - vim.bo[ref.bufnr].filetype, - table.concat(change.lines, "\n") - ) + for _, change in ipairs(changes) do + if change.type == "delete" then + changes_text = changes_text + .. string.format( + "Lines %d-%d were deleted:\n```%s\n%s\n```\n", + change.start, + change.end_line, + vim.bo[ref.bufnr].filetype, + table.concat(change.lines, "\n") + ) + else + changes_text = changes_text + .. string.format( + "Lines %d-%d were %s:\n```%s\n%s\n```\n", + change.start, + change.end_line, + change.type == "add" and "added" or "modified", + vim.bo[ref.bufnr].filetype, + table.concat(change.lines, "\n") + ) + end end - -- Add changes as a message self:add_message({ role = config.constants.USER_ROLE, content = changes_text, @@ -614,6 +621,7 @@ function Chat:submit(opts) end end end + if not self:has_user_messages(message) or message.content == "" then return log:warn("No messages to submit") end diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index cd48463d..0a221e04 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -1,158 +1,124 @@ ----@class CodeCompanion.BufferChange ----@field start_row number The starting row of the change ----@field end_row number The ending row of the change ----@field lines table The changed lines content ----@field changedtick number The buffer change tick when this change occurred ----@field timestamp number The timestamp when the change occurred ----@field reported boolean Whether this change has been reported to the LLM - ---@class CodeCompanion.BufferState ----@field changes CodeCompanion.BufferChange[] Array of changes for this buffer ----@field last_changedtick number The last recorded change tick - ----@class CodeCompanion.BufferWatcher ----@field buffers table Map of buffer numbers to their states ----@field watch fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Start watching a buffer ----@field unwatch fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Stop watching a buffer ----@field get_changes fun(self: CodeCompanion.BufferWatcher, bufnr: number): CodeCompanion.BufferChange[]|nil Get unreported changes ----@field clear_changes fun(self: CodeCompanion.BufferWatcher, bufnr: number): nil Clear all changes for a buffer +---@field content table Complete buffer content +---@field changedtick number Last known changedtick + local Watcher = {} local log = require("codecompanion.utils.log") function Watcher.new() return setmetatable({ buffers = {}, + augroup = vim.api.nvim_create_augroup("CodeCompanionWatcher", { clear = true }), }, { __index = Watcher }) end function Watcher:watch(bufnr) if self.buffers[bufnr] then - log:debug("Buffer %d is already being watched", bufnr) + return end log:debug("Starting to watch buffer: %d", bufnr) + -- Store initial buffer state self.buffers[bufnr] = { - changes = {}, - last_changedtick = vim.api.nvim_buf_get_changedtick(bufnr), + content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), + changedtick = vim.api.nvim_buf_get_changedtick(bufnr), } - - vim.api.nvim_buf_attach(bufnr, false, { - on_lines = function(_, buf, changedtick, start_row, start_col, end_row, end_col, old_end_row, old_end_col) - if not self.buffers[buf] then - return - end - - -- Calculate if lines were added or deleted - local old_line_count = old_end_row - start_row - local new_line_count = end_row - start_row - local is_deletion = old_line_count > new_line_count - - -- Get the changed lines - local lines = vim.api.nvim_buf_get_lines(buf, start_row, end_row, false) - table.insert(self.buffers[buf].changes, { - start_row = start_row + 1, - end_row = end_row, - lines = lines, - is_deletion = is_deletion, - old_line_count = old_line_count, - changedtick = changedtick, - timestamp = vim.loop.now(), - reported = false, - }) - log:debug("Recording change in buffer %d: lines %d-%d: %s", buf, start_row + 1, end_row + 1, vim.inspect(lines)) - end, - on_detach = function(_, buf) - self.buffers[buf] = nil - log:debug("Detached from buffer: %d", buf) - end, - }) end function Watcher:unwatch(bufnr) if self.buffers[bufnr] then log:debug("Unwatching buffer %d", bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_detach(bufnr) - end self.buffers[bufnr] = nil end end -function Watcher:get_changes(bufnr) - if not self.buffers[bufnr] then - log:debug("No buffer state found for buffer %d", bufnr) - return nil +---Compare two arrays of lines and return their differences +---@param old_lines table +---@param new_lines table +---@return table changes +local function compare_contents(old_lines, new_lines) + local changes = {} + local old_size = #old_lines + local new_size = #new_lines + + -- Find first different line + local start_diff = 1 + while start_diff <= math.min(old_size, new_size) do + if old_lines[start_diff] ~= new_lines[start_diff] then + break + end + start_diff = start_diff + 1 end - -- Get unreported changes - local unreported = vim.tbl_filter(function(change) - return not change.reported - end, self.buffers[bufnr].changes) - - if #unreported == 0 then - return nil + -- Find last different line from the end + local old_end = old_size + local new_end = new_size + while old_end >= start_diff and new_end >= start_diff do + if old_lines[old_end] ~= new_lines[new_end] then + break + end + old_end = old_end - 1 + new_end = new_end - 1 end - log:debug("Found %d unreported changes in buffer %d", #unreported, bufnr) - - -- Sort changes by timestamp - table.sort(unreported, function(a, b) - return a.timestamp < b.timestamp - end) - - -- Group changes by line ranges - local changes_by_range = {} - for _, change in ipairs(unreported) do - local range_key = string.format("%d-%d", change.start_row, change.end_row) - if not changes_by_range[range_key] then - changes_by_range[range_key] = {} + -- Extract deleted lines + if old_end >= start_diff then + local deleted = {} + for i = start_diff, old_end do + table.insert(deleted, old_lines[i]) + end + if #deleted > 0 then + table.insert(changes, { + type = "delete", + start = start_diff, + end_line = old_end, + lines = deleted, + }) end - table.insert(changes_by_range[range_key], change) end - -- Get current buffer state for context - local current_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - log:debug("Current buffer state: %s", vim.inspect(current_lines)) - - -- Take significant changes from each range - local consolidated = {} - for _, changes in pairs(changes_by_range) do - -- Get first and last change in sequence - local first = changes[1] - local last = changes[#changes] - - table.insert(consolidated, { - start_row = first.start_row, - end_row = last.end_row, - lines = last.lines, - is_deletion = last.is_deletion, - changedtick = last.changedtick, - timestamp = first.timestamp, - reported = false, - }) + -- Extract added/modified lines + if new_end >= start_diff then + local added = {} + for i = start_diff, new_end do + table.insert(added, new_lines[i]) + end + if #added > 0 then + table.insert(changes, { + type = "add", + start = start_diff, + end_line = new_end, + lines = added, + }) + end end - -- Sort consolidated changes by line number - table.sort(consolidated, function(a, b) - return a.start_row < b.start_row - end) - - log:debug("Consolidated %d changes into %d significant changes", #unreported, #consolidated) + return changes +end - -- Mark original changes as reported - for _, change in ipairs(unreported) do - change.reported = true +function Watcher:get_changes(bufnr) + if not self.buffers[bufnr] then + return nil end - return consolidated -end + local buffer = self.buffers[bufnr] + local current_content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) -function Watcher:clear_changes(bufnr) - if self.buffers[bufnr] then - self.buffers[bufnr].changes = {} - log:debug("Cleared changes for buffer %d", bufnr) + -- If no changes, return nil + if current_tick == buffer.changedtick then + return nil end + + -- Compare old and new content + local changes = compare_contents(buffer.content, current_content) + + -- Update stored state + buffer.content = current_content + buffer.changedtick = current_tick + + return changes end return Watcher From ae6313fb8b6d10ee857585b7df951511fc5c65ad Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Fri, 10 Jan 2025 00:09:10 -0500 Subject: [PATCH 07/17] tests: add watched to references test --- tests/strategies/chat/test_references.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/strategies/chat/test_references.lua b/tests/strategies/chat/test_references.lua index de6b30c7..f80e5612 100644 --- a/tests/strategies/chat/test_references.lua +++ b/tests/strategies/chat/test_references.lua @@ -162,6 +162,7 @@ T["References"]["Can be pinned"] = function() id = "pinned example", opts = { pinned = true, + watched = false, }, path = "tests.stubs.file.txt", source = "tests.strategies.chat.slash_commands.basic", @@ -170,6 +171,7 @@ T["References"]["Can be pinned"] = function() id = "unpinned example", opts = { pinned = false, + watched = false, }, path = "test2", source = "test", From 5d092ba38e829f40b6473d4a428050387d9d6ac8 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Fri, 10 Jan 2025 00:17:49 -0500 Subject: [PATCH 08/17] fix(references): ensure bother options have defaults --- lua/codecompanion/strategies/chat/references.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/codecompanion/strategies/chat/references.lua b/lua/codecompanion/strategies/chat/references.lua index 948f87c7..626ba6e8 100644 --- a/lua/codecompanion/strategies/chat/references.lua +++ b/lua/codecompanion/strategies/chat/references.lua @@ -111,11 +111,11 @@ function References:add(ref) if ref then if not ref.opts then - ref.opts = { - pinned = false, - watched = false, - } + ref.opts = {} end + -- Ensure both properties exist with defaults + ref.opts.pinned = ref.opts.pinned or false + ref.opts.watched = ref.opts.watched or false table.insert(self.Chat.refs, ref) -- If it's a buffer reference and it's being watched, start watching if ref.bufnr and ref.opts.watched then From 539d31224200840a1903c35f2d57f7257cf258cc Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Fri, 10 Jan 2025 14:46:52 -0500 Subject: [PATCH 09/17] tests: Adding watcher feature tests --- tests/strategies/chat/test_watcher.lua | 370 +++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/strategies/chat/test_watcher.lua diff --git a/tests/strategies/chat/test_watcher.lua b/tests/strategies/chat/test_watcher.lua new file mode 100644 index 00000000..759bd5c2 --- /dev/null +++ b/tests/strategies/chat/test_watcher.lua @@ -0,0 +1,370 @@ +local Watcher = require("codecompanion.strategies.chat.watcher") +local h = require("tests.helpers") + +local T = MiniTest.new_set() + +T["Watcher"] = MiniTest.new_set({ + hooks = { + pre_case = function() + -- Create a new buffer for each test + vim.cmd("new") + vim.bo.buftype = "nofile" + end, + post_case = function() + -- Clean up after each test + vim.cmd("bdelete!") + end, + }, +}) + +T["Watcher"]["creates new instance"] = function() + local watcher = Watcher.new() + h.eq(type(watcher.buffers), "table") + h.eq(vim.tbl_count(watcher.buffers), 0) +end + +T["Watcher"]["watches buffer"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + watcher:watch(bufnr) + h.eq(type(watcher.buffers[bufnr]), "table") + h.eq(type(watcher.buffers[bufnr].content), "table") + h.eq(type(watcher.buffers[bufnr].changedtick), "number") +end + +T["Watcher"]["detects line modification"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + "line 3", + }) + + watcher:watch(bufnr) + + -- Modify line 2 + vim.api.nvim_buf_set_lines(bufnr, 1, 2, false, { "modified line 2" }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + -- Check that we have the change, don't care about internal representation + local found_change = false + for _, change in ipairs(changes) do + if vim.tbl_contains(change.lines, "modified line 2") then + found_change = true + break + end + end + h.eq(found_change, true) +end + +T["Watcher"]["detects line deletion"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + "line 3", + "line 4", + }) + + watcher:watch(bufnr) + + -- Delete middle lines + vim.api.nvim_buf_set_lines(bufnr, 1, 3, false, {}) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + -- Verify deleted content is captured + local found_deletion = false + for _, change in ipairs(changes) do + if + change.type == "delete" + and vim.tbl_contains(change.lines, "line 2") + and vim.tbl_contains(change.lines, "line 3") + then + found_deletion = true + break + end + end + h.eq(found_deletion, true) +end + +T["Watcher"]["detects multiple line deletion"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + }) + + watcher:watch(bufnr) + + -- Delete lines 2-4 + vim.api.nvim_buf_set_lines(bufnr, 1, 4, false, {}) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + -- Verify all deleted lines are captured + local found_deletion = false + for _, change in ipairs(changes) do + if + change.type == "delete" + and vim.tbl_contains(change.lines, "line 2") + and vim.tbl_contains(change.lines, "line 3") + and vim.tbl_contains(change.lines, "line 4") + then + found_deletion = true + h.eq(#change.lines, 3, "Should have captured exactly 3 deleted lines") + h.eq(change.start, 2, "Deletion should start at line 2") + h.eq(change.end_line, 4, "Deletion should end at line 4") + break + end + end + h.eq(found_deletion, true, "Should have found the deletion of multiple lines") +end + +T["Watcher"]["detects multiple line insertion"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + }) + + watcher:watch(bufnr) + + -- Insert new lines between 1 and 2 + vim.api.nvim_buf_set_lines(bufnr, 1, 1, false, { + "new line 1", + "new line 2", + "new line 3", + }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + -- Verify all inserted lines are captured + local found_insertion = false + for _, change in ipairs(changes) do + if + change.type == "add" + and vim.tbl_contains(change.lines, "new line 1") + and vim.tbl_contains(change.lines, "new line 2") + and vim.tbl_contains(change.lines, "new line 3") + then + found_insertion = true + h.eq(#change.lines, 3, "Should have captured exactly 3 inserted lines") + h.eq(change.start, 2, "Insertion should start at line 2") + h.eq(change.end_line, 4, "Insertion should end at line 4") + break + end + end + h.eq(found_insertion, true, "Should have found the insertion of multiple lines") +end + +T["Watcher"]["handles mixed operations"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + "line 3", + "line 4", + }) + + watcher:watch(bufnr) + + -- Replace lines 2-3 with three new lines + vim.api.nvim_buf_set_lines(bufnr, 1, 3, false, { + "new line 1", + "new line 2", + "new line 3", + }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + -- Verify both deletion and insertion are captured + local found_deletion = false + local found_insertion = false + + for _, change in ipairs(changes) do + if + change.type == "delete" + and vim.tbl_contains(change.lines, "line 2") + and vim.tbl_contains(change.lines, "line 3") + then + found_deletion = true + elseif + change.type == "add" + and vim.tbl_contains(change.lines, "new line 1") + and vim.tbl_contains(change.lines, "new line 2") + and vim.tbl_contains(change.lines, "new line 3") + then + found_insertion = true + end + end + + h.eq(found_deletion, true, "Should have found the deletion") + h.eq(found_insertion, true, "Should have found the insertion") +end + +T["Watcher"]["handles unwatching buffer"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + watcher:watch(bufnr) + h.not_eq(watcher.buffers[bufnr], nil) + + watcher:unwatch(bufnr) + h.eq(watcher.buffers[bufnr], nil) +end + +T["Watcher"]["ignores changes after unwatching"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line 1" }) + watcher:watch(bufnr) + watcher:unwatch(bufnr) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "modified line 1" }) + + local changes = watcher:get_changes(bufnr) + h.eq(changes, nil) +end + +T["Watcher"]["handles empty buffer"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + watcher:watch(bufnr) + + -- Add content to empty buffer + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "new line" }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + local found_addition = false + for _, change in ipairs(changes) do + if vim.tbl_contains(change.lines, "new line") then + found_addition = true + break + end + end + h.eq(found_addition, true) +end + +T["Watcher"]["handles prepending to start of buffer"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + }) + + watcher:watch(bufnr) + + -- Prepend to start + vim.api.nvim_buf_set_lines(bufnr, 0, 0, false, { "new first line" }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + local found_addition = false + for _, change in ipairs(changes) do + if change.type == "add" and change.lines[1] == "new first line" then + found_addition = true + h.eq(change.start, 1, "Should be added at beginning") + break + end + end + h.eq(found_addition, true, "Should detect addition at buffer start") +end + +T["Watcher"]["handles appending to end of buffer"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", + "line 2", + }) + + watcher:watch(bufnr) + + -- Append to end + vim.api.nvim_buf_set_lines(bufnr, 2, 2, false, { "new last line" }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + local found_addition = false + for _, change in ipairs(changes) do + if change.type == "add" and change.lines[1] == "new last line" then + found_addition = true + h.eq(change.start, 3, "Should be added at correct line number") + break + end + end + h.eq(found_addition, true, "Should detect addition at buffer end") +end + +T["Watcher"]["handles complete buffer replacement"] = function() + local watcher = Watcher.new() + local bufnr = vim.api.nvim_get_current_buf() + + -- Set initial content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "old line 1", + "old line 2", + }) + + watcher:watch(bufnr) + + -- Replace everything + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "new line 1", + "new line 2", + "new line 3", + }) + + local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + + local found_deletion = false + local found_addition = false + + for _, change in ipairs(changes) do + if change.type == "delete" and vim.tbl_contains(change.lines, "old line 1") then + found_deletion = true + elseif change.type == "add" and vim.tbl_contains(change.lines, "new line 1") then + found_addition = true + end + end + + h.eq(found_deletion, true, "Should detect deletion of old content") + h.eq(found_addition, true, "Should detect addition of new content") +end + +return T From ec3d7a8294298479276d23c0affc9014f586c5b0 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Fri, 10 Jan 2025 22:40:40 -0500 Subject: [PATCH 10/17] fix(watcher): fix the duplicate printing of reference --- lua/codecompanion/strategies/chat/keymaps.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/codecompanion/strategies/chat/keymaps.lua b/lua/codecompanion/strategies/chat/keymaps.lua index 1d7dcb34..0306a5be 100644 --- a/lua/codecompanion/strategies/chat/keymaps.lua +++ b/lua/codecompanion/strategies/chat/keymaps.lua @@ -364,6 +364,7 @@ M.toggle_watch = { return end + local icons = config.display.chat.icons local id = line:gsub("^> %- ", "") if not chat.References:can_be_watched(id) then return util.notify("This reference type cannot be watched", vim.log.levels.WARN) @@ -371,24 +372,27 @@ M.toggle_watch = { -- Find the reference and toggle watch state for _, ref in ipairs(chat.refs) do - local clean_id = id:gsub(config.display.chat.icons.pinned_buffer, "") - :gsub(config.display.chat.icons.watched_buffer, "") + local clean_id = id:gsub(icons.pinned_buffer, ""):gsub(icons.watched_buffer, "") if ref.id == clean_id then if not ref.opts then ref.opts = {} end ref.opts.watched = not ref.opts.watched + -- Update the UI for just this line + local new_line if ref.opts.watched then chat.watcher:watch(ref.bufnr) + new_line = string.format("> - %s%s", icons.watched_buffer, clean_id) util.notify("Now watching buffer " .. ref.id) else chat.watcher:unwatch(ref.bufnr) + new_line = string.format("> - %s", clean_id) util.notify("Stopped watching buffer " .. ref.id) end - -- Force reference list refresh - chat.References:render() + -- Update only the current line + vim.api.nvim_buf_set_lines(chat.bufnr, current_line - 1, current_line, true, { new_line }) break end end From ea76cdb276e84f62393d9e6f0163dcf55e9350fb Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sat, 11 Jan 2025 00:37:23 -0500 Subject: [PATCH 11/17] refactor(watcher): add more edge cases to be robust --- lua/codecompanion/strategies/chat/init.lua | 11 +++ lua/codecompanion/strategies/chat/watcher.lua | 94 ++++++++++++++----- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index 8d597bf9..a9886ae5 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -601,6 +601,17 @@ function Chat:submit(opts) vim.bo[ref.bufnr].filetype, table.concat(change.lines, "\n") ) + elseif change.type == "modify" then + changes_text = changes_text + .. string.format( + "Lines %d-%d were modified from:\n```%s\n%s\n```\nto:\n```%s\n%s\n```\n", + change.start, + change.end_line, + vim.bo[ref.bufnr].filetype, + table.concat(change.old_lines, "\n"), + vim.bo[ref.bufnr].filetype, + table.concat(change.new_lines, "\n") + ) else changes_text = changes_text .. string.format( diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index 0a221e04..1d85cf96 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -42,6 +42,19 @@ local function compare_contents(old_lines, new_lines) local old_size = #old_lines local new_size = #new_lines + -- Special case: empty buffer getting content + -- NOTE: An "empty" buffer in Neovim actually contains one empty line ("") + if (old_size == 0 or (old_size == 1 and old_lines[1] == "")) and new_size > 0 then + return { + { + type = "add", + start = 1, + end_line = new_size, + lines = new_lines, + }, + } + end + -- Find first different line local start_diff = 1 while start_diff <= math.min(old_size, new_size) do @@ -62,36 +75,60 @@ local function compare_contents(old_lines, new_lines) new_end = new_end - 1 end - -- Extract deleted lines - if old_end >= start_diff then - local deleted = {} - for i = start_diff, old_end do - table.insert(deleted, old_lines[i]) + --TODO: maybe we can ensure there are changes before doing this, like : + -- start_diff <= math.min(old_size, new_size) or old_size ~= new_size + -- need more testing. + if start_diff > math.min(old_size, new_size) and old_size == new_size then + return changes + end + + -- Compare lines within the differing range to separate modifications from deletions/additions + local i = start_diff + local j = start_diff + + while i <= old_end and j <= new_end do + if old_lines[i] ~= new_lines[j] then + -- If we have lines on both sides, it's a modification + if i <= old_end and j <= new_end then + table.insert(changes, { + type = "modify", + start = i, + end_line = i, + old_lines = { old_lines[i] }, + new_lines = { new_lines[j] }, + }) + end end - if #deleted > 0 then - table.insert(changes, { - type = "delete", - start = start_diff, - end_line = old_end, - lines = deleted, - }) + i = i + 1 + j = j + 1 + end + + -- Handle remaining deletions + if i <= old_end then + local deleted = {} + for k = i, old_end do + table.insert(deleted, old_lines[k]) end + table.insert(changes, { + type = "delete", + start = i, + end_line = old_end, + lines = deleted, + }) end - -- Extract added/modified lines - if new_end >= start_diff then + -- Handle remaining additions + if j <= new_end then local added = {} - for i = start_diff, new_end do - table.insert(added, new_lines[i]) - end - if #added > 0 then - table.insert(changes, { - type = "add", - start = start_diff, - end_line = new_end, - lines = added, - }) + for k = j, new_end do + table.insert(added, new_lines[k]) end + table.insert(changes, { + type = "add", + start = j, + end_line = new_end, + lines = added, + }) end return changes @@ -102,10 +139,19 @@ function Watcher:get_changes(bufnr) return nil end + -- Check if buffer still exists + if not vim.api.nvim_buf_is_valid(bufnr) then + -- Buffer was deleted, clean up our state + self.buffers[bufnr] = nil + return nil + end + local buffer = self.buffers[bufnr] local current_content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) + -- TODO: we can compare buffer content to be more robus as well, maybe using + -- vim.deep_equal -- If no changes, return nil if current_tick == buffer.changedtick then return nil From f9e465fb3f971d90f6a513c041e2989f09960fa8 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sat, 11 Jan 2025 00:37:55 -0500 Subject: [PATCH 12/17] tests: Added more edge cases tests --- tests/strategies/chat/test_watcher.lua | 170 +++++++++++++++++++------ 1 file changed, 132 insertions(+), 38 deletions(-) diff --git a/tests/strategies/chat/test_watcher.lua b/tests/strategies/chat/test_watcher.lua index 759bd5c2..8ae970b7 100644 --- a/tests/strategies/chat/test_watcher.lua +++ b/tests/strategies/chat/test_watcher.lua @@ -50,15 +50,19 @@ T["Watcher"]["detects line modification"] = function() local changes = watcher:get_changes(bufnr) h.not_eq(changes, nil) - -- Check that we have the change, don't care about internal representation - local found_change = false + + -- Check for modification type and content + local found_modification = false for _, change in ipairs(changes) do - if vim.tbl_contains(change.lines, "modified line 2") then - found_change = true + if change.type == "modify" then + h.eq(change.old_lines[1], "line 2", "Old line content should match") + h.eq(change.new_lines[1], "modified line 2", "New line content should match") + h.eq(change.start, 2, "Modification should be at line 2") + found_modification = true break end end - h.eq(found_change, true) + h.eq(found_modification, true, "Should detect line modification") end T["Watcher"]["detects line deletion"] = function() @@ -200,29 +204,28 @@ T["Watcher"]["handles mixed operations"] = function() local changes = watcher:get_changes(bufnr) h.not_eq(changes, nil) - -- Verify both deletion and insertion are captured - local found_deletion = false - local found_insertion = false + -- We expect modifications for existing line positions and additions for new lines + local modifications = 0 + local additions = 0 for _, change in ipairs(changes) do - if - change.type == "delete" - and vim.tbl_contains(change.lines, "line 2") - and vim.tbl_contains(change.lines, "line 3") - then - found_deletion = true - elseif - change.type == "add" - and vim.tbl_contains(change.lines, "new line 1") - and vim.tbl_contains(change.lines, "new line 2") - and vim.tbl_contains(change.lines, "new line 3") - then - found_insertion = true + if change.type == "modify" then + if modifications == 0 then + h.eq(change.old_lines[1], "line 2") + h.eq(change.new_lines[1], "new line 1") + elseif modifications == 1 then + h.eq(change.old_lines[1], "line 3") + h.eq(change.new_lines[1], "new line 2") + end + modifications = modifications + 1 + elseif change.type == "add" then + h.eq(change.lines[1], "new line 3") + additions = additions + 1 end end - h.eq(found_deletion, true, "Should have found the deletion") - h.eq(found_insertion, true, "Should have found the insertion") + h.eq(modifications, 2, "Should detect two modifications") + h.eq(additions, 1, "Should detect one addition") end T["Watcher"]["handles unwatching buffer"] = function() @@ -254,22 +257,22 @@ T["Watcher"]["handles empty buffer"] = function() local watcher = Watcher.new() local bufnr = vim.api.nvim_get_current_buf() + -- Ensure buffer is empty + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) watcher:watch(bufnr) -- Add content to empty buffer vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "new line" }) local changes = watcher:get_changes(bufnr) + h.not_eq(changes, nil) + h.eq(#changes, 1, "Should have one change") - local found_addition = false - for _, change in ipairs(changes) do - if vim.tbl_contains(change.lines, "new line") then - found_addition = true - break - end - end - h.eq(found_addition, true) + local change = changes[1] + h.eq(change.type, "add", "Should be an addition") + h.eq(change.lines[1], "new line", "Content should match") + h.eq(change.start, 1, "Should start at line 1") end T["Watcher"]["handles prepending to start of buffer"] = function() @@ -352,19 +355,110 @@ T["Watcher"]["handles complete buffer replacement"] = function() local changes = watcher:get_changes(bufnr) h.not_eq(changes, nil) + local modifications = 0 + local additions = 0 + + for _, change in ipairs(changes) do + if change.type == "modify" then + if modifications == 0 then + h.eq(change.old_lines[1], "old line 1") + h.eq(change.new_lines[1], "new line 1") + elseif modifications == 1 then + h.eq(change.old_lines[1], "old line 2") + h.eq(change.new_lines[1], "new line 2") + end + modifications = modifications + 1 + elseif change.type == "add" then + h.eq(change.lines[1], "new line 3") + additions = additions + 1 + end + end + + h.eq(modifications, 2, "Should detect modifications of existing lines") + h.eq(additions, 1, "Should detect addition of new line") +end + +T["Watcher"]["handles modifications after buffer switching"] = function() + local watcher = Watcher.new() + local main_buf = vim.api.nvim_get_current_buf() + + -- Initial state + vim.api.nvim_buf_set_lines(main_buf, 0, -1, false, { + "line 1", + "line 2", + "line 3", + "line 4", + }) + + watcher:watch(main_buf) + + -- First modification + vim.api.nvim_buf_set_lines(main_buf, 1, 3, false, { + "modified line 2", + "new line between", + "modified line 3", + }) + + -- Get and process first changes + local first_changes = watcher:get_changes(main_buf) + + -- Switch buffers + vim.cmd("new") + local temp_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(temp_buf, 0, -1, false, { "temporary buffer" }) + vim.api.nvim_set_current_buf(main_buf) + + -- Make new changes + vim.api.nvim_buf_set_lines(main_buf, 1, 4, false, { + "modified again line 2", + "modified new line between", + }) + + local changes = watcher:get_changes(main_buf) + h.not_eq(changes, nil) + + local found_modifications = 0 local found_deletion = false - local found_addition = false for _, change in ipairs(changes) do - if change.type == "delete" and vim.tbl_contains(change.lines, "old line 1") then - found_deletion = true - elseif change.type == "add" and vim.tbl_contains(change.lines, "new line 1") then - found_addition = true + if change.type == "modify" then + if change.old_lines[1] == "modified line 2" and change.new_lines[1] == "modified again line 2" then + found_modifications = found_modifications + 1 + elseif change.old_lines[1] == "new line between" and change.new_lines[1] == "modified new line between" then + found_modifications = found_modifications + 1 + end + elseif change.type == "delete" then + if vim.tbl_contains(change.lines, "modified line 3") then + found_deletion = true + end end end - h.eq(found_deletion, true, "Should detect deletion of old content") - h.eq(found_addition, true, "Should detect addition of new content") + h.eq(found_modifications, 2, "Should detect both line modifications from last known state") + h.eq(found_deletion, true, "Should detect deletion from last known state") + + -- Clean up + vim.api.nvim_buf_delete(temp_buf, { force = true }) +end + +T["Watcher"]["handles buffer deletion"] = function() + local watcher = Watcher.new() + + -- Create a temporary buffer + vim.cmd("new") + local temp_buf = vim.api.nvim_get_current_buf() + + watcher:watch(temp_buf) + h.not_eq(watcher.buffers[temp_buf], nil) + + vim.api.nvim_buf_delete(temp_buf, { force = true }) + + -- Try to get changes (should return nil) + local changes = watcher:get_changes(temp_buf) + h.eq(changes, nil) + + -- Verify the buffer is no longer being watched + h.eq(watcher.buffers[temp_buf], nil) end return T From 8d6944f22c63e3b66f42b1fa8117238b6c8a7059 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sat, 11 Jan 2025 01:20:38 -0500 Subject: [PATCH 13/17] fix(watcher): handle closing watched buffer --- lua/codecompanion/strategies/chat/keymaps.lua | 14 +++++++++++--- lua/codecompanion/strategies/chat/watcher.lua | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lua/codecompanion/strategies/chat/keymaps.lua b/lua/codecompanion/strategies/chat/keymaps.lua index 0306a5be..f3f13534 100644 --- a/lua/codecompanion/strategies/chat/keymaps.lua +++ b/lua/codecompanion/strategies/chat/keymaps.lua @@ -382,9 +382,17 @@ M.toggle_watch = { -- Update the UI for just this line local new_line if ref.opts.watched then - chat.watcher:watch(ref.bufnr) - new_line = string.format("> - %s%s", icons.watched_buffer, clean_id) - util.notify("Now watching buffer " .. ref.id) + -- Check if buffer is still valid before watching + if vim.api.nvim_buf_is_valid(ref.bufnr) then + chat.watcher:watch(ref.bufnr) + new_line = string.format("> - %s%s", icons.watched_buffer, clean_id) + util.notify("Now watching buffer " .. ref.id) + else + -- Buffer is invalid, can't watch it + ref.opts.watched = false + new_line = string.format("> - %s", clean_id) + util.notify("Cannot watch invalid buffer " .. ref.id, vim.log.levels.WARN) + end else chat.watcher:unwatch(ref.bufnr) new_line = string.format("> - %s", clean_id) diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index 1d85cf96..a1b9b79e 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -24,6 +24,15 @@ function Watcher:watch(bufnr) content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), changedtick = vim.api.nvim_buf_get_changedtick(bufnr), } + -- Add autocmd for buffer deletion + vim.api.nvim_create_autocmd("BufDelete", { + group = self.augroup, + buffer = bufnr, + callback = function() + -- Unwatch before the buffer content becomes invalid + self:unwatch(bufnr) + end, + }) end function Watcher:unwatch(bufnr) @@ -139,10 +148,12 @@ function Watcher:get_changes(bufnr) return nil end - -- Check if buffer still exists + -- Check if buffer still exists first if not vim.api.nvim_buf_is_valid(bufnr) then - -- Buffer was deleted, clean up our state - self.buffers[bufnr] = nil + -- Buffer was deleted, clean up our state and return nil + if self.buffers[bufnr] then + self.buffers[bufnr] = nil + end return nil end From 35d967b815f4a83b1b1dbc82ec3cae464272c63d Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sat, 11 Jan 2025 22:10:57 -0500 Subject: [PATCH 14/17] fix(watcher): handle unloaded buffers --- lua/codecompanion/strategies/chat/keymaps.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/codecompanion/strategies/chat/keymaps.lua b/lua/codecompanion/strategies/chat/keymaps.lua index f3f13534..3ad94abc 100644 --- a/lua/codecompanion/strategies/chat/keymaps.lua +++ b/lua/codecompanion/strategies/chat/keymaps.lua @@ -383,7 +383,7 @@ M.toggle_watch = { local new_line if ref.opts.watched then -- Check if buffer is still valid before watching - if vim.api.nvim_buf_is_valid(ref.bufnr) then + if vim.api.nvim_buf_is_valid(ref.bufnr) and vim.api.nvim_buf_is_loaded(ref.bufnr) then chat.watcher:watch(ref.bufnr) new_line = string.format("> - %s%s", icons.watched_buffer, clean_id) util.notify("Now watching buffer " .. ref.id) @@ -391,7 +391,7 @@ M.toggle_watch = { -- Buffer is invalid, can't watch it ref.opts.watched = false new_line = string.format("> - %s", clean_id) - util.notify("Cannot watch invalid buffer " .. ref.id, vim.log.levels.WARN) + util.notify("Cannot watch invalid or unloaded buffer " .. ref.id, vim.log.levels.WARN) end else chat.watcher:unwatch(ref.bufnr) From 926ee7808f935ec89e92931c80497376a42b686b Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sat, 11 Jan 2025 22:12:45 -0500 Subject: [PATCH 15/17] fix(watcher): don't send too much to the llm --- lua/codecompanion/strategies/chat/init.lua | 5 +- lua/codecompanion/strategies/chat/watcher.lua | 214 +++++++++--------- 2 files changed, 114 insertions(+), 105 deletions(-) diff --git a/lua/codecompanion/strategies/chat/init.lua b/lua/codecompanion/strategies/chat/init.lua index a9886ae5..61427a19 100644 --- a/lua/codecompanion/strategies/chat/init.lua +++ b/lua/codecompanion/strategies/chat/init.lua @@ -612,13 +612,12 @@ function Chat:submit(opts) vim.bo[ref.bufnr].filetype, table.concat(change.new_lines, "\n") ) - else + else -- type == "add" changes_text = changes_text .. string.format( - "Lines %d-%d were %s:\n```%s\n%s\n```\n", + "Lines %d-%d were added:\n```%s\n%s\n```\n", change.start, change.end_line, - change.type == "add" and "added" or "modified", vim.bo[ref.bufnr].filetype, table.concat(change.lines, "\n") ) diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index a1b9b79e..baf9265c 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -1,6 +1,7 @@ ---@class CodeCompanion.BufferState ---@field content table Complete buffer content ---@field changedtick number Last known changedtick +---@field last_sent table Last content sent to LLM local Watcher = {} local log = require("codecompanion.utils.log") @@ -17,19 +18,24 @@ function Watcher:watch(bufnr) return end + if not vim.api.nvim_buf_is_valid(bufnr) then + log:debug("Cannot watch invalid buffer: %d", bufnr) + return + end + log:debug("Starting to watch buffer: %d", bufnr) + local initial_content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - -- Store initial buffer state self.buffers[bufnr] = { - content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), + content = initial_content, + last_sent = initial_content, changedtick = vim.api.nvim_buf_get_changedtick(bufnr), } - -- Add autocmd for buffer deletion + vim.api.nvim_create_autocmd("BufDelete", { group = self.augroup, buffer = bufnr, callback = function() - -- Unwatch before the buffer content becomes invalid self:unwatch(bufnr) end, }) @@ -42,102 +48,118 @@ function Watcher:unwatch(bufnr) end end ----Compare two arrays of lines and return their differences ----@param old_lines table ----@param new_lines table ----@return table changes -local function compare_contents(old_lines, new_lines) +local function find_line_match(line, lines, start_idx) + for i = start_idx or 1, #lines do + if lines[i] == line then + return i + end + end + return nil +end + +local function detect_changes(old_lines, new_lines) local changes = {} local old_size = #old_lines local new_size = #new_lines - -- Special case: empty buffer getting content - -- NOTE: An "empty" buffer in Neovim actually contains one empty line ("") - if (old_size == 0 or (old_size == 1 and old_lines[1] == "")) and new_size > 0 then - return { - { - type = "add", - start = 1, - end_line = new_size, - lines = new_lines, - }, - } - end - - -- Find first different line - local start_diff = 1 - while start_diff <= math.min(old_size, new_size) do - if old_lines[start_diff] ~= new_lines[start_diff] then - break - end - start_diff = start_diff + 1 - end - - -- Find last different line from the end - local old_end = old_size - local new_end = new_size - while old_end >= start_diff and new_end >= start_diff do - if old_lines[old_end] ~= new_lines[new_end] then + local i = 1 -- old lines index + local j = 1 -- new lines index + + while i <= old_size or j <= new_size do + if i > old_size then + -- Remaining lines are new additions + local added = {} + local start = j + while j <= new_size do + table.insert(added, new_lines[j]) + j = j + 1 + end + if #added > 0 then + table.insert(changes, { + type = "add", + start = start, + end_line = new_size, + lines = added, + }) + end break end - old_end = old_end - 1 - new_end = new_end - 1 - end - - --TODO: maybe we can ensure there are changes before doing this, like : - -- start_diff <= math.min(old_size, new_size) or old_size ~= new_size - -- need more testing. - if start_diff > math.min(old_size, new_size) and old_size == new_size then - return changes - end - - -- Compare lines within the differing range to separate modifications from deletions/additions - local i = start_diff - local j = start_diff - while i <= old_end and j <= new_end do - if old_lines[i] ~= new_lines[j] then - -- If we have lines on both sides, it's a modification - if i <= old_end and j <= new_end then + if j > new_size then + -- Remaining lines are deletions + local deleted = {} + local start = i + while i <= old_size do + table.insert(deleted, old_lines[i]) + i = i + 1 + end + if #deleted > 0 then table.insert(changes, { - type = "modify", - start = i, - end_line = i, - old_lines = { old_lines[i] }, - new_lines = { new_lines[j] }, + type = "delete", + start = start, + end_line = old_size, + lines = deleted, }) end + break end - i = i + 1 - j = j + 1 - end - - -- Handle remaining deletions - if i <= old_end then - local deleted = {} - for k = i, old_end do - table.insert(deleted, old_lines[k]) - end - table.insert(changes, { - type = "delete", - start = i, - end_line = old_end, - lines = deleted, - }) - end - -- Handle remaining additions - if j <= new_end then - local added = {} - for k = j, new_end do - table.insert(added, new_lines[k]) + if old_lines[i] == new_lines[j] then + -- Lines match, move both forward + i = i + 1 + j = j + 1 + else + -- Look ahead for matches + local next_match = find_line_match(old_lines[i], new_lines, j) + if next_match then + -- Found the line later - everything before is new + local added = {} + local start = j + while j < next_match do + table.insert(added, new_lines[j]) + j = j + 1 + end + if #added > 0 then + table.insert(changes, { + type = "add", + start = start, + end_line = next_match - 1, + lines = added, + }) + end + else + -- Line was deleted or modified + local next_old_match = find_line_match(new_lines[j], old_lines, i) + if next_old_match then + -- Found matching line later in old content - report deletions + local deleted = {} + local start = i + while i < next_old_match do + table.insert(deleted, old_lines[i]) + i = i + 1 + end + if #deleted > 0 then + table.insert(changes, { + type = "delete", + start = start, + end_line = next_old_match - 1, + lines = deleted, + }) + end + else + -- Modified line + table.insert(changes, { + type = "modify", + start = i, + end_line = i, + old_lines = { old_lines[i] }, + new_lines = { new_lines[j] }, + }) + i = i + 1 + j = j + 1 + end + end end - table.insert(changes, { - type = "add", - start = j, - end_line = new_end, - lines = added, - }) end return changes @@ -148,31 +170,19 @@ function Watcher:get_changes(bufnr) return nil end - -- Check if buffer still exists first - if not vim.api.nvim_buf_is_valid(bufnr) then - -- Buffer was deleted, clean up our state and return nil - if self.buffers[bufnr] then - self.buffers[bufnr] = nil - end - return nil - end - local buffer = self.buffers[bufnr] local current_content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) - -- TODO: we can compare buffer content to be more robus as well, maybe using - -- vim.deep_equal - -- If no changes, return nil if current_tick == buffer.changedtick then return nil end - -- Compare old and new content - local changes = compare_contents(buffer.content, current_content) + local changes = detect_changes(buffer.last_sent, current_content) - -- Update stored state + -- Update states buffer.content = current_content + buffer.last_sent = current_content buffer.changedtick = current_tick return changes From c4eaf9d759f0597729e6e42a2ffda05b38b26907 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sun, 12 Jan 2025 22:27:15 -0500 Subject: [PATCH 16/17] chore(watcher): clean up the code --- README.md | 5 +++++ doc/codecompanion.txt | 10 ++++++++- .../strategies/chat/references.lua | 19 ++++++++++------- lua/codecompanion/strategies/chat/watcher.lua | 9 ++++---- lua/codecompanion/types.lua | 21 +++++++++++++++++++ 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bcb9f654..7908b55b 100644 --- a/README.md +++ b/README.md @@ -649,6 +649,7 @@ When in the chat buffer, press `?` to bring up a menu that lists the available k - `gs` to toggle the system prompt on/off - `gx` to clear the chat buffer's contents - `gy` to yank the last codeblock in the chat buffer +- `gw` to monitor attched buffer for any chnages and send it to the LLM - `[[` to move to the previous header - `]]` to move to the next header - `{` to move to the previous chat @@ -658,6 +659,10 @@ and in insert mode: - `` to open the completion menu (if nvim-cmp isn't installed) +**Buffer Watcher** + +The buffer watcher feature allows you to monitor changes in specific buffers during your conversation with CodeCompanion. When activated, any changes in the watched buffer will be automatically sent to the LLM in your next conversation turn. + **Settings** You can display your selected adapter's schema at the top of the buffer, if `display.chat.show_settings` is set to `true`. This allows you to vary the response from the LLM. diff --git a/doc/codecompanion.txt b/doc/codecompanion.txt index 4a9215de..07a8ad5c 100644 --- a/doc/codecompanion.txt +++ b/doc/codecompanion.txt @@ -1,4 +1,4 @@ -*codecompanion.txt* For NVIM v0.10.0 Last change: 2025 January 05 +*codecompanion.txt* For NVIM v0.10.0 Last change: 2025 January 12 ============================================================================== Table of Contents *codecompanion-table-of-contents* @@ -713,6 +713,7 @@ to you in normal mode are: - `gs` to toggle the system prompt on/off - `gx` to clear the chat buffer’s contents - `gy` to yank the last codeblock in the chat buffer +- `gw` to monitor attched buffer for any chnages and send it to the LLM - `[[` to move to the previous header - `]]` to move to the next header - `{` to move to the previous chat @@ -722,6 +723,13 @@ and in insert mode: - `` to open the completion menu (if nvim-cmp isn’t installed) +**Buffer Watcher** + +The buffer watcher feature allows you to monitor changes in specific buffers +during your conversation with CodeCompanion. When activated, any changes in the +watched buffer will be automatically sent to the LLM in your next conversation +turn. + **Settings** You can display your selected adapter’s schema at the top of the buffer, if diff --git a/lua/codecompanion/strategies/chat/references.lua b/lua/codecompanion/strategies/chat/references.lua index 626ba6e8..7ad2d348 100644 --- a/lua/codecompanion/strategies/chat/references.lua +++ b/lua/codecompanion/strategies/chat/references.lua @@ -7,15 +7,18 @@ local config = require("codecompanion.config") local api = vim.api local user_role = config.strategies.chat.roles.user -local pinned_icon = config.display.chat.icons.pinned_buffer -local watched_icon = config.display.chat.icons.watched_buffer +local icons_path = config.display.chat.icons +local icons = { + pinned = icons_path.pinned_buffer, + watched = icons_path.watched_buffer, +} local allowed_pins = { "", "", } -local allowed_watches = { +local allowed_watchers = { "", } @@ -185,9 +188,9 @@ function References:render() goto continue end if ref.opts and ref.opts.pinned then - table.insert(lines, string.format("> - %s%s", pinned_icon, ref.id)) + table.insert(lines, string.format("> - %s%s", icons.pinned, ref.id)) elseif ref.opts and ref.opts.watched then - table.insert(lines, string.format("> - %s%s", watched_icon, ref.id)) + table.insert(lines, string.format("> - %s%s", icons.watched, ref.id)) else table.insert(lines, string.format("> - %s", ref.id)) end @@ -222,7 +225,7 @@ end ---@param ref string ---@return boolean function References:can_be_watched(ref) - for _, watch in ipairs(allowed_watches) do + for _, watch in ipairs(allowed_watchers) do if ref:find(watch) then return true end @@ -250,7 +253,9 @@ function References:get_from_chat() elseif role == user_role and query.captures[id] == "ref" then local ref = vim.treesitter.get_node_text(node, chat.bufnr) -- Clean both pinned and watched icons - ref = ref:gsub("^> %- ", ""):gsub(pinned_icon, ""):gsub(watched_icon, "") + ref = vim.iter(vim.tbl_values(icons)):fold(select(1, ref:gsub("^> %- ", "")), function(acc, icon) + return select(1, acc:gsub(icon, "")) + end) table.insert(refs, vim.trim(ref)) end end diff --git a/lua/codecompanion/strategies/chat/watcher.lua b/lua/codecompanion/strategies/chat/watcher.lua index baf9265c..16289471 100644 --- a/lua/codecompanion/strategies/chat/watcher.lua +++ b/lua/codecompanion/strategies/chat/watcher.lua @@ -1,7 +1,8 @@ ----@class CodeCompanion.BufferState ----@field content table Complete buffer content ----@field changedtick number Last known changedtick ----@field last_sent table Last content sent to LLM +--[[ +Watcher tracks changes in Neovim buffers by comparing buffer content over time. It maintains +a state for each watched buffer, recording the current content and last sent content. When +checked, it compares states to detect line additions, deletions, and modifications. +]] local Watcher = {} local log = require("codecompanion.utils.log") diff --git a/lua/codecompanion/types.lua b/lua/codecompanion/types.lua index f203f664..a2ba275e 100644 --- a/lua/codecompanion/types.lua +++ b/lua/codecompanion/types.lua @@ -42,6 +42,26 @@ ---@field context table The context of the buffer that the chat was initiated from ---@field prompts table Any prompts to be sent to the LLM +---@class CodeCompanion.Change +---@field type "add"|"delete"|"modify" The type of change +---@field start number Starting line number +---@field end_line number Ending line number +---@field lines? string[] Added or deleted lines +---@field old_lines? string[] Original lines (for modify type) +---@field new_lines? string[] New lines (for modify type) + +---@class CodeCompanion.Watcher +---@field buffers table Map of buffer numbers to their states +---@field augroup integer The autocmd group ID +---@field watch fun(self: CodeCompanion.Watcher, bufnr: number): nil Start watching a buffer +---@field unwatch fun(self: CodeCompanion.Watcher, bufnr: number): nil Stop watching a buffer +---@field get_changes fun(self: CodeCompanion.Watcher, bufnr: number): CodeCompanion.Change[]|nil Get changes since last check + +---@class CodeCompanion.WatcherState +---@field content string[] Complete buffer content +---@field changedtick number Last known changedtick +---@field last_sent string[] Last content sent to LLM + ---@class CodeCompanion.Chat ---@field opts CodeCompanion.ChatArgs Store all arguments in this table ---@field adapter CodeCompanion.Adapter The adapter to use for the chat @@ -67,6 +87,7 @@ ---@field tools_in_use? nil|table The tools that are currently being used in the chat ---@field ui CodeCompanion.Chat.UI The UI of the chat buffer ---@field variables? CodeCompanion.Variables The variables available to the user +---@field watcher CodeCompanion.Watcher The buffer watcher instance ---@class CodeCompanion.ChatArgs Arguments that can be injected into the chat ---@field adapter? CodeCompanion.Adapter The adapter used in this chat buffer From 1255a1ab38a2c62f7cc35844406ca1ba7caa26b7 Mon Sep 17 00:00:00 2001 From: Bassam Data Date: Sun, 12 Jan 2025 22:27:45 -0500 Subject: [PATCH 17/17] tests: update watcher tests --- tests/strategies/chat/test_watcher.lua | 48 +++++++++++--------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/tests/strategies/chat/test_watcher.lua b/tests/strategies/chat/test_watcher.lua index 8ae970b7..114b51a9 100644 --- a/tests/strategies/chat/test_watcher.lua +++ b/tests/strategies/chat/test_watcher.lua @@ -253,28 +253,6 @@ T["Watcher"]["ignores changes after unwatching"] = function() h.eq(changes, nil) end -T["Watcher"]["handles empty buffer"] = function() - local watcher = Watcher.new() - local bufnr = vim.api.nvim_get_current_buf() - - -- Ensure buffer is empty - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) - watcher:watch(bufnr) - - -- Add content to empty buffer - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "new line" }) - - local changes = watcher:get_changes(bufnr) - - h.not_eq(changes, nil) - h.eq(#changes, 1, "Should have one change") - - local change = changes[1] - h.eq(change.type, "add", "Should be an addition") - h.eq(change.lines[1], "new line", "Content should match") - h.eq(change.start, 1, "Should start at line 1") -end - T["Watcher"]["handles prepending to start of buffer"] = function() local watcher = Watcher.new() local bufnr = vim.api.nvim_get_current_buf() @@ -441,24 +419,38 @@ T["Watcher"]["handles modifications after buffer switching"] = function() vim.api.nvim_buf_delete(temp_buf, { force = true }) end -T["Watcher"]["handles buffer deletion"] = function() +T["Watcher"]["handles buffer deletion properly"] = function() local watcher = Watcher.new() - -- Create a temporary buffer vim.cmd("new") local temp_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(temp_buf, 0, -1, false, { "test line 1", "test line 2" }) watcher:watch(temp_buf) h.not_eq(watcher.buffers[temp_buf], nil) + local initial_changes = watcher:get_changes(temp_buf) + h.eq(initial_changes, nil) + vim.api.nvim_buf_delete(temp_buf, { force = true }) - -- Try to get changes (should return nil) - local changes = watcher:get_changes(temp_buf) - h.eq(changes, nil) + h.eq(watcher.buffers[temp_buf], nil) +end + +T["Watcher"]["doesn't watch invalid buffers"] = function() + local watcher = Watcher.new() + + -- Create and immediately delete a buffer + vim.cmd("new") + local temp_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_delete(temp_buf, { force = true }) + + watcher:watch(temp_buf) - -- Verify the buffer is no longer being watched h.eq(watcher.buffers[temp_buf], nil) + + local changes = watcher:get_changes(temp_buf) + h.eq(changes, nil) end return T