@@ -7,6 +7,28 @@ local terminal = require("claudecode.terminal")
77
88local uv = vim .uv or vim .loop
99
10+ --- Returns true if the given mode string denotes a visual mode (charwise, linewise, blockwise).
11+ --- Select mode (`s`/`S`/`<C-s>`) is deliberately NOT treated as visual: like the rest of the
12+ --- module it tracks visual selections only (Select mode is an atypical workflow here).
13+ --- @param mode string | nil
14+ --- @return boolean
15+ local function is_visual_mode (mode )
16+ return mode == " v" or mode == " V" or mode == " \22 "
17+ end
18+
19+ --- Returns true if the cursor is still at the position captured by the most recent flush
20+ --- for this buffer (i.e. the held visual selection has not been navigated away from yet).
21+ --- @param bufnr number
22+ --- @return boolean
23+ local function cursor_unmoved_since_flush (bufnr )
24+ local caf = M .state .cursor_at_flush
25+ if not caf or caf .bufnr ~= bufnr then
26+ return false
27+ end
28+ local pos = vim .api .nvim_win_get_cursor (0 )
29+ return pos [1 ] == caf .pos [1 ] and pos [2 ] == caf .pos [2 ]
30+ end
31+
1032M .state = {
1133 latest_selection = nil ,
1234 tracking_enabled = false ,
@@ -16,6 +38,16 @@ M.state = {
1638 last_active_visual_selection = nil ,
1739 demotion_timer = nil ,
1840 visual_demotion_delay_ms = 50 ,
41+
42+ -- Cursor position captured when a visual selection is flushed on visual-mode exit.
43+ -- Demotion only fires once the cursor actually moves away from this position, so a
44+ -- held selection persists (see issue #246 and M.flush_visual_selection).
45+ cursor_at_flush = nil ,
46+
47+ -- { bufnr, tick } captured when visual mode is entered. Used to detect that an operator
48+ -- consumed/mutated the selection (d/c/>/x...) so the flush does not broadcast stale,
49+ -- post-edit marks as a phantom selection (see M.flush_visual_selection).
50+ visual_entry = nil ,
1951}
2052
2153--- Enables selection tracking.
@@ -47,6 +79,8 @@ function M.disable()
4779
4880 M .state .latest_selection = nil
4981 M .state .last_active_visual_selection = nil
82+ M .state .cursor_at_flush = nil
83+ M .state .visual_entry = nil
5084 M .server = nil
5185
5286 M ._cancel_debounce_timer ()
@@ -124,8 +158,26 @@ function M.on_cursor_moved()
124158end
125159
126160--- Handles mode change events.
127- --- Triggers an immediate update of the selection.
161+ --- When leaving visual mode, the selection is flushed synchronously: at that instant
162+ --- `nvim_get_mode()` already reports the new (non-visual) mode, so the debounced
163+ --- `update_selection()` path can no longer capture the visual selection. Flushing here
164+ --- (from the still-valid `'<`/`'>` marks) ensures fast selections that are made and
165+ --- released in under `debounce_ms` are not lost (issue #246).
166+ --- Entering visual mode records the buffer's changedtick so the flush can tell an
167+ --- abandoned selection apart from one consumed by a mutating operator (d/c/>/x...).
128168function M .on_mode_changed ()
169+ local event = vim .v .event
170+ if event then
171+ local leaving_visual = is_visual_mode (event .old_mode ) and not is_visual_mode (event .new_mode )
172+ local entering_visual = is_visual_mode (event .new_mode ) and not is_visual_mode (event .old_mode )
173+ if entering_visual then
174+ local buf = vim .api .nvim_get_current_buf ()
175+ M .state .visual_entry = { bufnr = buf , tick = vim .api .nvim_buf_get_changedtick (buf ) }
176+ elseif leaving_visual then
177+ M .flush_visual_selection ()
178+ end
179+ end
180+
129181 M .debounce_update ()
130182end
131183
@@ -226,10 +278,16 @@ function M.update_selection()
226278 local last_visual = M .state .last_active_visual_selection
227279
228280 if M .state .demotion_timer then
229- -- A demotion is already pending. For this specific update_selection call (e.g. cursor moved),
230- -- current_selection reflects the immediate cursor position.
231- -- M.state.latest_selection (the one that might be sent) is still the visual one until timer resolves.
232- current_selection = M .get_cursor_position ()
281+ -- A demotion is already pending. While the cursor is still on the flushed position the
282+ -- held visual selection must be preserved (an idle re-entry here must not wipe it before
283+ -- the cursor actually moves -- matters when visual_demotion_delay_ms >= debounce_ms).
284+ -- Once the cursor has moved, reflect the immediate cursor position; M.state.latest_selection
285+ -- stays the visual one until the timer resolves.
286+ if cursor_unmoved_since_flush (current_buf ) then
287+ current_selection = M .state .latest_selection
288+ else
289+ current_selection = M .get_cursor_position ()
290+ end
233291 elseif
234292 last_visual
235293 and last_visual .bufnr == current_buf
@@ -264,11 +322,14 @@ function M.update_selection()
264322 end )
265323 )
266324 else
267- -- Genuinely in normal mode, no recent visual exit, no pending demotion.
325+ -- Genuinely in normal mode, no recent visual exit, no pending demotion. The
326+ -- selection_changed protocol reflects the active editor, so report this buffer's
327+ -- cursor. Any held visual selection (for this buffer, or one navigated away from
328+ -- without moving the cursor) is no longer current -- clear its tracked state so it
329+ -- does not leak across buffer switches.
268330 current_selection = M .get_cursor_position ()
269- if last_visual and last_visual .bufnr == current_buf then
270- M .state .last_active_visual_selection = nil -- Clear it as it's no longer relevant for demotion
271- end
331+ M .state .last_active_visual_selection = nil
332+ M .state .cursor_at_flush = nil
272333 end
273334 end
274335
@@ -334,6 +395,16 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
334395
335396 -- Condition 3: Still in Original Buffer & Not Visual & Not Claude Term -> Demote
336397 if current_buf == original_bufnr_when_scheduled then
398+ -- Only demote once the cursor has actually moved since the selection was flushed.
399+ -- This lets a held selection persist (matching the official VS Code extension, and
400+ -- fixing the external-Claude case where there is no in-Neovim Claude terminal to
401+ -- switch to), while still clearing a stale selection as soon as the user navigates
402+ -- away from it. last_active_visual_selection is intentionally left intact when the
403+ -- cursor is unmoved so a later cursor move re-arms demotion. See issue #246.
404+ if cursor_unmoved_since_flush (current_buf ) then
405+ return
406+ end
407+
337408 local new_sel_for_demotion = M .get_cursor_position ()
338409 -- Check if this new cursor position is actually different from the (visual) latest_selection
339410 if M .has_selection_changed (new_sel_for_demotion ) then
@@ -346,13 +417,16 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled)
346417 end
347418 -- User switched to different buffer
348419
349- -- Always clear last_active_visual_selection for the original buffer as its pending demotion is resolved .
420+ -- The pending demotion for the original buffer is resolved: clear its tracked state .
350421 if
351422 M .state .last_active_visual_selection
352423 and M .state .last_active_visual_selection .bufnr == original_bufnr_when_scheduled
353424 then
354425 M .state .last_active_visual_selection = nil
355426 end
427+ if M .state .cursor_at_flush and M .state .cursor_at_flush .bufnr == original_bufnr_when_scheduled then
428+ M .state .cursor_at_flush = nil
429+ end
356430end
357431
358432--- Validates if we're in a valid visual selection mode
@@ -372,17 +446,17 @@ local function validate_visual_mode()
372446 return true , nil
373447end
374448
375- --- Determines the effective visual mode character
449+ --- Determines the effective visual mode character.
450+ --- Prefers the LIVE mode; `vim.fn.visualmode()` (the LAST COMPLETED visual mode) is only
451+ --- used as a fallback when not currently in a visual mode. Trusting `visualmode()` while
452+ --- live in a different visual mode misclassifies the selection -- e.g. a fresh linewise
453+ --- `V` made right after a charwise selection would be extracted charwise, broadcasting a
454+ --- single character (or an empty selection on an empty line) instead of the whole line.
455+ --- See issue #246.
376456--- @return string | nil - the visual mode character or nil if invalid
377457local function get_effective_visual_mode ()
378458 local current_nvim_mode = vim .api .nvim_get_mode ().mode
379- local visual_fn_mode_char = vim .fn .visualmode ()
380459
381- if visual_fn_mode_char and visual_fn_mode_char ~= " " then
382- return visual_fn_mode_char
383- end
384-
385- -- Fallback to current mode
386460 if current_nvim_mode == " V" then
387461 return " V"
388462 elseif current_nvim_mode == " v" then
@@ -391,6 +465,12 @@ local function get_effective_visual_mode()
391465 return " \22 "
392466 end
393467
468+ -- Not currently in a visual mode: fall back to the last completed visual mode.
469+ local visual_fn_mode_char = vim .fn .visualmode ()
470+ if visual_fn_mode_char and visual_fn_mode_char ~= " " then
471+ return visual_fn_mode_char
472+ end
473+
394474 return nil
395475end
396476
@@ -511,6 +591,83 @@ function M.get_visual_selection()
511591 if visual_mode == " V" then
512592 final_text = extract_linewise_text (lines_content , start_coords )
513593 elseif visual_mode == " v" or visual_mode == " \22 " then
594+ -- Blockwise ("\22") is approximated as the contiguous charwise span: selection_changed
595+ -- carries a single start/end range and cannot represent a rectangular block. Proper
596+ -- per-column block extraction is a follow-up.
597+ final_text = extract_characterwise_text (lines_content , start_coords , end_coords )
598+ if not final_text then
599+ return nil
600+ end
601+ else
602+ return nil
603+ end
604+
605+ local lsp_positions = calculate_lsp_positions (start_coords , end_coords , visual_mode , lines_content )
606+
607+ return {
608+ text = final_text or " " ,
609+ filePath = file_path ,
610+ fileUrl = " file://" .. file_path ,
611+ selection = {
612+ start = lsp_positions .start ,
613+ [" end" ] = lsp_positions [" end" ],
614+ isEmpty = (not final_text or # final_text == 0 ),
615+ },
616+ }
617+ end
618+
619+ --- Gets the just-completed visual selection from the `'<` and `'>` marks.
620+ --- Unlike `get_visual_selection()`, this does NOT require the editor to currently be in
621+ --- visual mode, so it can be called from a `ModeChanged` (visual -> normal) handler where
622+ --- `nvim_get_mode()` already reports normal mode but the marks are still valid. The marks
623+ --- are always in chronological order (`'<` before `'>`).
624+ --- Only valid immediately on visual exit: it pairs the buffer-local `'<`/`'>` marks with the
625+ --- GLOBAL `vim.fn.visualmode()`, so both must describe the same just-completed selection
626+ --- (do not call it after an unrelated visual selection in another buffer).
627+ --- @return table | nil selection A selection table matching get_visual_selection ()' s shape, or nil.
628+ function M .get_visual_selection_from_marks ()
629+ local visual_mode = vim .fn .visualmode ()
630+ if not visual_mode or visual_mode == " " then
631+ return nil
632+ end
633+
634+ local start_pos = vim .fn .getpos (" '<" )
635+ local end_pos = vim .fn .getpos (" '>" )
636+ if start_pos [2 ] == 0 or end_pos [2 ] == 0 then
637+ return nil -- no recorded visual selection
638+ end
639+
640+ local current_buf = vim .api .nvim_get_current_buf ()
641+ local file_path = vim .api .nvim_buf_get_name (current_buf )
642+
643+ local start_coords = { lnum = start_pos [2 ], col = start_pos [3 ] }
644+ local end_coords = { lnum = end_pos [2 ], col = end_pos [3 ] }
645+
646+ local lines_content = vim .api .nvim_buf_get_lines (
647+ current_buf ,
648+ start_coords .lnum - 1 , -- Convert to 0-indexed
649+ end_coords .lnum , -- nvim_buf_get_lines end is exclusive
650+ false
651+ )
652+
653+ if # lines_content == 0 then
654+ return nil
655+ end
656+
657+ -- For linewise selections (and `$`), the `'>` column can be MAXCOL (2147483647). Clamp it
658+ -- to the last line's length + 1 so it never overflows string.sub / LSP character math.
659+ local last_line = lines_content [# lines_content ] or " "
660+ if end_coords .col > # last_line + 1 then
661+ end_coords .col = # last_line + 1
662+ end
663+
664+ local final_text
665+ if visual_mode == " V" then
666+ final_text = extract_linewise_text (lines_content , start_coords )
667+ elseif visual_mode == " v" or visual_mode == " \22 " then
668+ -- Blockwise ("\22") is approximated as the contiguous charwise span (matching
669+ -- get_visual_selection), since selection_changed carries a single start/end range and
670+ -- cannot represent a rectangular block. Proper per-column block extraction is a follow-up.
514671 final_text = extract_characterwise_text (lines_content , start_coords , end_coords )
515672 if not final_text then
516673 return nil
@@ -533,6 +690,70 @@ function M.get_visual_selection()
533690 }
534691end
535692
693+ --- Flushes the just-completed visual selection synchronously when leaving visual mode.
694+ --- Captures the selection from the `'<`/`'>` marks, records it as the active visual
695+ --- selection, cancels any pending demotion, and broadcasts it if it changed. This closes
696+ --- the debounce race where a selection made and released faster than `debounce_ms` was
697+ --- never broadcast at all (issue #246). The Claude terminal buffer is ignored, mirroring
698+ --- `update_selection()`. Deduplicates against the last sent selection so a selection
699+ --- already broadcast by the in-visual debounce is not sent twice on exit. If the buffer
700+ --- was mutated while in visual mode (a consuming operator like d/c/>/x), the marks no
701+ --- longer describe the user's selection, so the flush is skipped to avoid broadcasting
702+ --- stale, post-edit text as a phantom selection.
703+ function M .flush_visual_selection ()
704+ if not M .state .tracking_enabled then
705+ return
706+ end
707+
708+ local current_buf = vim .api .nvim_get_current_buf ()
709+ local buf_name = vim .api .nvim_buf_get_name (current_buf )
710+
711+ if buf_name and buf_name :match (" ^term://" ) and buf_name :lower ():find (" claude" , 1 , true ) then
712+ return
713+ end
714+ if terminal then
715+ local claude_term_bufnr = terminal .get_active_terminal_bufnr ()
716+ if claude_term_bufnr and current_buf == claude_term_bufnr then
717+ return
718+ end
719+ end
720+
721+ -- Skip when the buffer was edited while in visual mode: the changedtick advancing since
722+ -- visual entry means an operator (d/c/x...) consumed the selection, so the '<,'> marks
723+ -- point at post-edit text the user never selected. This is intentionally conservative --
724+ -- it also skips in-place transforms that leave a valid selection (gU/gu/~/J/=), because
725+ -- they cannot be told apart from consuming operators by the marks alone (both leave a
726+ -- non-empty, in-bounds region). Such fast transforms simply are not flushed; the prior
727+ -- behavior never broadcast them either (they lost the debounce race), so this is a
728+ -- residual non-broadcast, not a regression.
729+ local entry = M .state .visual_entry
730+ if entry and entry .bufnr == current_buf and vim .api .nvim_buf_get_changedtick (current_buf ) ~= entry .tick then
731+ return
732+ end
733+
734+ local selection = M .get_visual_selection_from_marks ()
735+ if not selection or selection .selection .isEmpty then
736+ return
737+ end
738+
739+ -- Record the cursor position at flush time so demotion only fires after a real move.
740+ M .state .cursor_at_flush = { bufnr = current_buf , pos = vim .api .nvim_win_get_cursor (0 ) }
741+ M .state .last_active_visual_selection = {
742+ bufnr = current_buf ,
743+ selection_data = vim .deepcopy (selection ),
744+ timestamp = vim .loop .now (),
745+ }
746+
747+ M ._cancel_demotion_timer ()
748+
749+ if M .has_selection_changed (selection ) then
750+ M .state .latest_selection = selection
751+ if M .server then
752+ M .send_selection_update (selection )
753+ end
754+ end
755+ end
756+
536757--- Gets the current cursor position when no visual selection is active.
537758--- @return table A table containing an empty text , file path , URL , and cursor
538759--- position as start/end, with isEmpty set to true.
0 commit comments