@@ -34,7 +34,8 @@ function M.enable(server, visual_demotion_delay_ms)
3434end
3535
3636--- Disables selection tracking.
37- --- 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.
3839function M .disable ()
3940 if not M .state .tracking_enabled then
4041 return
@@ -48,14 +49,7 @@ function M.disable()
4849 M .server = nil
4950
5051 M ._cancel_debounce_timer ()
51-
52- if M .state .demotion_timer then
53- local demotion_timer = M .state .demotion_timer
54- M .state .demotion_timer = nil
55-
56- demotion_timer :stop ()
57- demotion_timer :close ()
58- end
52+ M ._cancel_demotion_timer ()
5953end
6054
6155--- Cancels and closes the current debounce timer, if any.
@@ -69,8 +63,20 @@ function M._cancel_debounce_timer()
6963 -- Clear state before stopping/closing so any already-scheduled callback is a no-op.
7064 M .state .debounce_timer = nil
7165
72- assert (timer .stop , " Expected debounce timer to have :stop()" )
73- assert (timer .close , " Expected debounce timer to have :close()" )
66+ timer :stop ()
67+ timer :close ()
68+ end
69+
70+ --- Cancels and closes the current demotion timer, if any.
71+ --- @local
72+ function M ._cancel_demotion_timer ()
73+ local timer = M .state .demotion_timer
74+ if not timer then
75+ return
76+ end
77+
78+ -- Clear state before stopping/closing so any already-scheduled callback is a no-op.
79+ M .state .demotion_timer = nil
7480
7581 timer :stop ()
7682 timer :close ()
@@ -153,7 +159,7 @@ function M.debounce_update()
153159 return
154160 end
155161
156- -- Clear state before stopping/closing so cancellation is idempotent .
162+ -- Clear state so _cancel_debounce_timer() is a no-op if called after firing .
157163 M .state .debounce_timer = nil
158164
159165 timer :stop ()
@@ -178,11 +184,7 @@ function M.update_selection()
178184 -- If the buffer name starts with "term://" and contains "claude", do not update selection
179185 if buf_name and buf_name :match (" ^term://" ) and buf_name :lower ():find (" claude" , 1 , true ) then
180186 -- Optionally, cancel demotion timer like for the terminal
181- if M .state .demotion_timer then
182- M .state .demotion_timer :stop ()
183- M .state .demotion_timer :close ()
184- M .state .demotion_timer = nil
185- end
187+ M ._cancel_demotion_timer ()
186188 return
187189 end
188190
@@ -191,11 +193,7 @@ function M.update_selection()
191193 local claude_term_bufnr = terminal .get_active_terminal_bufnr ()
192194 if claude_term_bufnr and current_buf == claude_term_bufnr then
193195 -- Cancel any pending demotion if we switch to the Claude terminal
194- if M .state .demotion_timer then
195- M .state .demotion_timer :stop ()
196- M .state .demotion_timer :close ()
197- M .state .demotion_timer = nil
198- end
196+ M ._cancel_demotion_timer ()
199197 return
200198 end
201199 end
@@ -206,11 +204,7 @@ function M.update_selection()
206204
207205 if current_mode == " v" or current_mode == " V" or current_mode == " \022 " then
208206 -- If a new visual selection is made, cancel any pending demotion
209- if M .state .demotion_timer then
210- M .state .demotion_timer :stop ()
211- M .state .demotion_timer :close ()
212- M .state .demotion_timer = nil
213- end
207+ M ._cancel_demotion_timer ()
214208
215209 current_selection = M .get_visual_selection ()
216210
@@ -246,21 +240,25 @@ function M.update_selection()
246240 -- The 'current_selection' for comparison should also be this visual one.
247241 current_selection = M .state .latest_selection
248242
249- if M .state .demotion_timer then -- Should not happen due to elseif, but as safeguard
250- M .state .demotion_timer :stop ()
251- M .state .demotion_timer :close ()
252- end
243+ local timer = uv .new_timer ()
244+ assert (timer , " Expected uv.new_timer() to return a timer handle" )
253245
254- M .state .demotion_timer = vim . loop . new_timer ()
255- M . state . demotion_timer :start (
246+ M .state .demotion_timer = timer
247+ timer :start (
256248 M .state .visual_demotion_delay_ms ,
257249 0 , -- 0 repeat = one-shot
258250 vim .schedule_wrap (function ()
259- if M .state .demotion_timer then -- Check if it wasn't cancelled right before firing
260- M .state .demotion_timer :stop ()
261- M .state .demotion_timer :close ()
262- M .state .demotion_timer = nil
251+ -- Ignore stale timers (e.g., cancelled and replaced before callback runs)
252+ if M .state .demotion_timer ~= timer then
253+ return
263254 end
255+
256+ -- Clear state so _cancel_demotion_timer() is a no-op if called after firing.
257+ M .state .demotion_timer = nil
258+
259+ timer :stop ()
260+ timer :close ()
261+
264262 M .handle_selection_demotion (current_buf ) -- Pass buffer at time of scheduling
265263 end )
266264 )
@@ -296,6 +294,10 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
296294 -- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
297295 -- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
298296
297+ if not M .state .tracking_enabled then
298+ return
299+ end
300+
299301 local current_buf = vim .api .nvim_get_current_buf ()
300302 local claude_term_bufnr = terminal .get_active_terminal_bufnr ()
301303
0 commit comments