Skip to content

Commit f08d411

Browse files
authored
feat: improve how buffers are watched (#2897)
1 parent 4c381c2 commit f08d411

File tree

5 files changed

+171
-120
lines changed

5 files changed

+171
-120
lines changed

lua/codecompanion/config.lua

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ local defaults = {
5757
},
5858
constants = constants,
5959
interactions = {
60+
opts = {
61+
watcher = {
62+
enabled = true, -- Reload buffers when an agent modifies files on disk
63+
debounce = 500, -- Debounce time in milliseconds
64+
},
65+
},
6066
-- BACKGROUND INTERACTION -------------------------------------------------
6167
background = {
6268
adapter = {
@@ -753,7 +759,6 @@ The user is working on a %s machine. Please respond with system specific command
753759
agents = {},
754760
opts = {
755761
auto_insert = false, -- Enter insert mode when focusing the CLI terminal
756-
reload = true, -- Reload buffers when an agent modifies files on disk
757762
},
758763
providers = {
759764
terminal = {

lua/codecompanion/interactions/chat/acp/handler.lua

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local config = require("codecompanion.config")
22
local formatter = require("codecompanion.interactions.chat.acp.formatters")
33
local log = require("codecompanion.utils.log")
44
local utils = require("codecompanion.utils")
5+
local watch = require("codecompanion.interactions.shared.watch")
56

67
-- Keep a record of UI changes in the chat buffer
78

@@ -19,7 +20,6 @@ local ACPHandlerUI = {} -- Cache of tool call UI states by chat buffer
1920
function ACPHandler.new(chat)
2021
local self = setmetatable({
2122
chat = chat,
22-
modified_paths = {},
2323
output = {},
2424
reasoning = {},
2525
tools = {},
@@ -83,6 +83,8 @@ function ACPHandler:ensure_connection()
8383
end
8484

8585
self.chat:update_metadata()
86+
87+
watch.enable()
8688
end
8789
return true
8890
end
@@ -209,24 +211,6 @@ function ACPHandler:process_tool_call(tool_call)
209211
content = "[Error formatting tool output]"
210212
end
211213

212-
-- Track file paths from edit/write tool calls for targeted buffer reload
213-
if tool_call.kind == "edit" then
214-
if type(tool_call.locations) == "table" then
215-
for _, loc in ipairs(tool_call.locations) do
216-
if type(loc.path) == "string" then
217-
self.modified_paths[loc.path] = true
218-
end
219-
end
220-
end
221-
if type(tool_call.content) == "table" then
222-
for _, c in ipairs(tool_call.content) do
223-
if c.type == "diff" and type(c.path) == "string" then
224-
self.modified_paths[c.path] = true
225-
end
226-
end
227-
end
228-
end
229-
230214
-- Cache or cleanup
231215
if tool_call.status == "completed" then
232216
self.tools[id] = nil
@@ -324,21 +308,6 @@ function ACPHandler:handle_completion(stop_reason)
324308
self.chat.status = "success"
325309
end
326310

327-
-- Reload buffers that the agent modified on disk
328-
if next(self.modified_paths) then
329-
local modified = self.modified_paths
330-
self.modified_paths = {}
331-
vim.schedule(function()
332-
local buf_utils = require("codecompanion.utils.buffers")
333-
for path, _ in pairs(modified) do
334-
local bufnr = buf_utils.get_bufnr_from_path(path)
335-
if bufnr then
336-
vim.cmd.checktime(bufnr)
337-
end
338-
end
339-
end)
340-
end
341-
342311
self.chat:done(self.output, self.reasoning, {})
343312
end
344313

lua/codecompanion/interactions/cli/init.lua

Lines changed: 2 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
-- File watching logic inspired and adapted from sidekick.nvim
2-
-- https://github.com/folke/sidekick.nvim
3-
41
local config = require("codecompanion.config")
52
local keymaps = require("codecompanion.utils.keymaps")
63
local log = require("codecompanion.utils.log")
74
local registry = require("codecompanion.interactions.shared.registry")
85
local utils = require("codecompanion.utils")
6+
local watch = require("codecompanion.interactions.shared.watch")
97

108
local api = vim.api
119

12-
local uv = vim.uv or vim.loop
13-
1410
---@type table
1511
_G.codecompanion_cli_metadata = {}
1612

@@ -22,20 +18,11 @@ _G.codecompanion_cli_metadata = {}
2218
---@field id number
2319
---@field provider CodeCompanion.CLI.Provider
2420
---@field ui CodeCompanion.CLI.UI
25-
---@field watch_cwd string|nil
2621
local CLI = {}
2722

2823
local clis = {} ---@type table<number, CodeCompanion.CLI>
2924
local last_cli = nil ---@type CodeCompanion.CLI|nil
3025

31-
---@class CodeCompanion.CLI.Watcher
32-
---@field watcher uv.uv_fs_event_t
33-
---@field timer uv.uv_timer_t
34-
---@field count number
35-
36-
---Shared watchers keyed by cwd — one watcher per directory, ref-counted
37-
local watchers = {} ---@type table<string, CodeCompanion.CLI.Watcher>
38-
3926
---Keymap callbacks for the CLI buffer
4027
local keymap_callbacks = {
4128
next_chat = {
@@ -128,10 +115,7 @@ function CLI.create(args)
128115
})
129116
end
130117

131-
if config.interactions.cli.opts.reload then
132-
self.watch_cwd = vim.fn.getcwd()
133-
self._start_watcher(self.watch_cwd)
134-
end
118+
watch.enable()
135119

136120
clis[bufnr] = self
137121
last_cli = self
@@ -251,9 +235,6 @@ end
251235
---Close the CLI instance and clean up
252236
---@return nil
253237
function CLI:close()
254-
if self.watch_cwd then
255-
self._stop_watcher(self.watch_cwd)
256-
end
257238
self.provider:stop()
258239

259240
pcall(api.nvim_del_augroup_by_id, self.aug)
@@ -274,70 +255,6 @@ function CLI:close()
274255
utils.fire("CLIClosed", { bufnr = self.bufnr })
275256
end
276257

277-
--=============================================================================
278-
-- Watchers - Monitor files in the cwd for any changes
279-
--=============================================================================
280-
281-
---Start a watcher for a given cwd
282-
---@param cwd string
283-
---@return nil
284-
function CLI._start_watcher(cwd)
285-
if watchers[cwd] then
286-
watchers[cwd].count = watchers[cwd].count + 1
287-
return
288-
end
289-
290-
local watcher = uv.new_fs_event()
291-
if not watcher then
292-
return log:warn("Could not create file watcher for %s", cwd)
293-
end
294-
295-
local timer = uv.new_timer()
296-
if not timer then
297-
watcher:close()
298-
return log:warn("Could not create debounce timer for %s", cwd)
299-
end
300-
301-
watcher:start(cwd, { recursive = true }, function()
302-
if not timer:is_closing() then
303-
timer:stop()
304-
timer:start(100, 0, function()
305-
vim.schedule(vim.cmd.checktime)
306-
end)
307-
end
308-
end)
309-
310-
watchers[cwd] = { watcher = watcher, timer = timer, count = 1 }
311-
log:debug("File watcher started on %s", cwd)
312-
end
313-
314-
---Stop a watcher for the given cwd
315-
---@param cwd string
316-
---@return nil
317-
function CLI._stop_watcher(cwd)
318-
local entry = watchers[cwd]
319-
if not entry then
320-
return
321-
end
322-
323-
entry.count = entry.count - 1
324-
if entry.count > 0 then
325-
return
326-
end
327-
328-
if not entry.timer:is_closing() then
329-
entry.timer:stop()
330-
entry.timer:close()
331-
end
332-
if not entry.watcher:is_closing() then
333-
entry.watcher:stop()
334-
entry.watcher:close()
335-
end
336-
337-
watchers[cwd] = nil
338-
log:debug("File watcher stopped for %s", cwd)
339-
end
340-
341258
--=============================================================================
342259
-- Public API
343260
-- =============================================================================
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
-- File watching logic inspired and adapted from sidekick.nvim
2+
-- https://github.com/folke/sidekick.nvim/blob/main/lua/sidekick/cli/watch.lua
3+
4+
local config = require("codecompanion.config")
5+
local log = require("codecompanion.utils.log")
6+
7+
local api = vim.api
8+
local uv = vim.uv or vim.loop
9+
10+
local M = {}
11+
12+
M.watches = {} ---@type table<string, uv.uv_fs_event_t>
13+
M.enabled = false
14+
15+
---Debounce a function using a libuv timer
16+
---@generic T
17+
---@param fn T
18+
---@param ms? number
19+
---@return T
20+
local function debounce(fn, ms)
21+
local timer = assert(uv.new_timer())
22+
return function(...)
23+
local args = { ... }
24+
timer:start(
25+
ms or 20,
26+
0,
27+
vim.schedule_wrap(function()
28+
pcall(fn, unpack(args))
29+
end)
30+
)
31+
end
32+
end
33+
34+
---Return the parent directory of a buffer's file, or nil if ineligible
35+
---@param buf number
36+
---@return string|nil
37+
local function dirname(buf)
38+
local fname = api.nvim_buf_get_name(buf)
39+
if
40+
api.nvim_buf_is_loaded(buf)
41+
and vim.bo[buf].buftype == ""
42+
and vim.bo[buf].buflisted
43+
and fname ~= ""
44+
and uv.fs_stat(fname) ~= nil
45+
then
46+
local path = vim.fs.dirname(fname)
47+
return path and path ~= "" and path or nil
48+
end
49+
end
50+
51+
---Refresh checktime
52+
---@return nil
53+
function M.refresh()
54+
vim.cmd.checktime()
55+
end
56+
57+
---Start watching a specific directory path
58+
---@param path string
59+
---@return nil
60+
function M.start(path)
61+
if M.watches[path] then
62+
return
63+
end
64+
65+
local watch = uv.new_fs_event()
66+
if not watch then
67+
return log:warn("Could not create file watcher for %s", path)
68+
end
69+
70+
local ok, err = watch:start(path, {}, function()
71+
M.refresh()
72+
end)
73+
if not ok then
74+
log:warn("Failed to watch %s: %s", path, err)
75+
if not watch:is_closing() then
76+
watch:close()
77+
end
78+
return
79+
end
80+
81+
M.watches[path] = watch
82+
log:debug("File watcher started on %s", path)
83+
end
84+
85+
---Stop watching a specific directory path
86+
---@param path string
87+
---@return nil
88+
function M.stop(path)
89+
local watch = M.watches[path]
90+
if not watch then
91+
return
92+
end
93+
94+
M.watches[path] = nil
95+
if not watch:is_closing() then
96+
watch:close()
97+
end
98+
log:debug("File watcher stopped for %s", path)
99+
end
100+
101+
---Update watches based on currently loaded buffers.
102+
---Starts watches for new buffer directories and stops watches for removed ones.
103+
---@return nil
104+
function M.update()
105+
local dirs = {} ---@type table<string, boolean>
106+
for _, buf in pairs(api.nvim_list_bufs()) do
107+
local dir = dirname(buf)
108+
if dir then
109+
dirs[dir] = true
110+
M.start(dir)
111+
end
112+
end
113+
for path in pairs(M.watches) do
114+
if not dirs[path] then
115+
M.stop(path)
116+
end
117+
end
118+
end
119+
120+
---Enable file system watching for all loaded buffers
121+
---@return nil
122+
function M.enable()
123+
local watcher = config.interactions.opts.watcher
124+
if M.enabled or not watcher.enabled then
125+
return
126+
end
127+
128+
M.enabled = true
129+
M.refresh = debounce(M.refresh, watcher.debounce)
130+
M.update = debounce(M.update, watcher.debounce)
131+
132+
api.nvim_create_autocmd({ "BufAdd", "BufDelete", "BufWipeout", "BufReadPost" }, {
133+
group = api.nvim_create_augroup("codecompanion.watch", { clear = true }),
134+
callback = M.update,
135+
})
136+
M.update()
137+
end
138+
139+
---Disable file system watching and stop all active watches
140+
---@return nil
141+
function M.disable()
142+
if not M.enabled then
143+
return
144+
end
145+
146+
M.enabled = false
147+
pcall(api.nvim_clear_autocmds, { group = "codecompanion.watch" })
148+
pcall(api.nvim_del_augroup_by_name, "codecompanion.watch")
149+
for path in pairs(M.watches) do
150+
M.stop(path)
151+
end
152+
end
153+
154+
return M

tests/config.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ return {
7474
},
7575
},
7676
interactions = {
77+
opts = {
78+
watcher = {
79+
enabled = true,
80+
debounce = 500,
81+
},
82+
},
7783
background = {},
7884
chat = {
7985
adapter = "test_adapter",

0 commit comments

Comments
 (0)