@@ -5,6 +5,8 @@ local M = {}
55local logger = require (" claudecode.logger" )
66local terminal = require (" claudecode.terminal" )
77
8+ local uv = vim .uv or vim .loop
9+
810M .state = {
911 latest_selection = nil ,
1012 tracking_enabled = false ,
@@ -32,7 +34,8 @@ function M.enable(server, visual_demotion_delay_ms)
3234end
3335
3436--- Disables selection tracking.
35- --- Clears autocommands, resets internal state, and stops any active debounce timers.
37+ --- Clears autocommands, resets internal state, and stops any active debounce or
38+ --- demotion timers.
3639function M .disable ()
3740 if not M .state .tracking_enabled then
3841 return
@@ -43,12 +46,41 @@ function M.disable()
4346 M ._clear_autocommands ()
4447
4548 M .state .latest_selection = nil
49+ M .state .last_active_visual_selection = nil
4650 M .server = nil
4751
48- if M .state .debounce_timer then
49- vim .loop .timer_stop (M .state .debounce_timer )
50- M .state .debounce_timer = nil
52+ M ._cancel_debounce_timer ()
53+ M ._cancel_demotion_timer ()
54+ end
55+
56+ --- Cancels and closes the current debounce timer, if any.
57+ --- @local
58+ function M ._cancel_debounce_timer ()
59+ local timer = M .state .debounce_timer
60+ if not timer then
61+ return
62+ end
63+
64+ -- Clear state before stopping/closing so any already-scheduled callback is a no-op.
65+ M .state .debounce_timer = nil
66+
67+ timer :stop ()
68+ timer :close ()
69+ end
70+
71+ --- Cancels and closes the current demotion timer, if any.
72+ --- @local
73+ function M ._cancel_demotion_timer ()
74+ local timer = M .state .demotion_timer
75+ if not timer then
76+ return
5177 end
78+
79+ -- Clear state before stopping/closing so any already-scheduled callback is a no-op.
80+ M .state .demotion_timer = nil
81+
82+ timer :stop ()
83+ timer :close ()
5284end
5385
5486--- Creates autocommands for tracking selections.
@@ -107,14 +139,36 @@ end
107139--- Ensures that `update_selection` is not called too frequently by deferring
108140--- its execution.
109141function M .debounce_update ()
110- if M .state .debounce_timer then
111- vim .loop .timer_stop (M .state .debounce_timer )
112- end
142+ M ._cancel_debounce_timer ()
143+
144+ assert (type (M .state .debounce_ms ) == " number" , " Expected debounce_ms to be a number" )
145+
146+ local timer = uv .new_timer ()
147+ assert (timer , " Expected uv.new_timer() to return a timer handle" )
148+ assert (timer .start , " Expected debounce timer to have :start()" )
149+ assert (timer .stop , " Expected debounce timer to have :stop()" )
150+ assert (timer .close , " Expected debounce timer to have :close()" )
151+
152+ M .state .debounce_timer = timer
113153
114- M .state .debounce_timer = vim .defer_fn (function ()
115- M .update_selection ()
116- M .state .debounce_timer = nil
117- end , M .state .debounce_ms )
154+ timer :start (
155+ M .state .debounce_ms ,
156+ 0 , -- 0 repeat = one-shot
157+ vim .schedule_wrap (function ()
158+ -- Ignore stale timers (e.g., cancelled and replaced before callback runs)
159+ if M .state .debounce_timer ~= timer then
160+ return
161+ end
162+
163+ -- Clear state so _cancel_debounce_timer() is a no-op if called after firing.
164+ M .state .debounce_timer = nil
165+
166+ timer :stop ()
167+ timer :close ()
168+
169+ M .update_selection ()
170+ end )
171+ )
118172end
119173
120174--- Updates the current selection state.
@@ -131,11 +185,7 @@ function M.update_selection()
131185 -- If the buffer name starts with "term://" and contains "claude", do not update selection
132186 if buf_name and buf_name :match (" ^term://" ) and buf_name :lower ():find (" claude" , 1 , true ) then
133187 -- Optionally, cancel demotion timer like for the terminal
134- if M .state .demotion_timer then
135- M .state .demotion_timer :stop ()
136- M .state .demotion_timer :close ()
137- M .state .demotion_timer = nil
138- end
188+ M ._cancel_demotion_timer ()
139189 return
140190 end
141191
@@ -144,11 +194,7 @@ function M.update_selection()
144194 local claude_term_bufnr = terminal .get_active_terminal_bufnr ()
145195 if claude_term_bufnr and current_buf == claude_term_bufnr then
146196 -- Cancel any pending demotion if we switch to the Claude terminal
147- if M .state .demotion_timer then
148- M .state .demotion_timer :stop ()
149- M .state .demotion_timer :close ()
150- M .state .demotion_timer = nil
151- end
197+ M ._cancel_demotion_timer ()
152198 return
153199 end
154200 end
@@ -159,11 +205,7 @@ function M.update_selection()
159205
160206 if current_mode == " v" or current_mode == " V" or current_mode == " \022 " then
161207 -- If a new visual selection is made, cancel any pending demotion
162- if M .state .demotion_timer then
163- M .state .demotion_timer :stop ()
164- M .state .demotion_timer :close ()
165- M .state .demotion_timer = nil
166- end
208+ M ._cancel_demotion_timer ()
167209
168210 current_selection = M .get_visual_selection ()
169211
@@ -199,21 +241,25 @@ function M.update_selection()
199241 -- The 'current_selection' for comparison should also be this visual one.
200242 current_selection = M .state .latest_selection
201243
202- if M .state .demotion_timer then -- Should not happen due to elseif, but as safeguard
203- M .state .demotion_timer :stop ()
204- M .state .demotion_timer :close ()
205- end
244+ local timer = uv .new_timer ()
245+ assert (timer , " Expected uv.new_timer() to return a timer handle" )
206246
207- M .state .demotion_timer = vim . loop . new_timer ()
208- M . state . demotion_timer :start (
247+ M .state .demotion_timer = timer
248+ timer :start (
209249 M .state .visual_demotion_delay_ms ,
210250 0 , -- 0 repeat = one-shot
211251 vim .schedule_wrap (function ()
212- if M .state .demotion_timer then -- Check if it wasn't cancelled right before firing
213- M .state .demotion_timer :stop ()
214- M .state .demotion_timer :close ()
215- M .state .demotion_timer = nil
252+ -- Ignore stale timers (e.g., cancelled and replaced before callback runs)
253+ if M .state .demotion_timer ~= timer then
254+ return
216255 end
256+
257+ -- Clear state so _cancel_demotion_timer() is a no-op if called after firing.
258+ M .state .demotion_timer = nil
259+
260+ timer :stop ()
261+ timer :close ()
262+
217263 M .handle_selection_demotion (current_buf ) -- Pass buffer at time of scheduling
218264 end )
219265 )
@@ -249,6 +295,10 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
249295 -- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
250296 -- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
251297
298+ if not M .state .tracking_enabled then
299+ return
300+ end
301+
252302 local current_buf = vim .api .nvim_get_current_buf ()
253303 local claude_term_bufnr = terminal .get_active_terminal_bufnr ()
254304
0 commit comments