Skip to content

Commit 0b24505

Browse files
ThomasK33claude
andauthored
fix(selection): push quickly-made visual selections to Claude (#246) (#267)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent b9b99f9 commit 0b24505

4 files changed

Lines changed: 728 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228))
1313
- Rejecting a Claude diff with `:q` (or `:close` / `<C-w>c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238))
14+
- Push quickly-made visual selections to Claude reliably. Selections made and released faster than the selection-tracking debounce were never broadcast, and any selection was wiped shortly after leaving visual mode when Claude runs in an external terminal (the `/ide` flow) — so single-line selections in particular often never reached Claude. Selections are now flushed synchronously on visual-mode exit (from the `'<`/`'>` marks) and persist until the cursor actually moves; a single-line linewise `V` made right after a charwise selection is also no longer mis-extracted to a single character. ([#246](https://github.com/coder/claudecode.nvim/issues/246))
1415
- Diffs opened via `openDiff` no longer linger forever when they are resolved outside this Neovim or their Claude session goes away. Pending diffs are now automatically closed when the client that opened them disconnects or the integration is stopped, and `closeAllDiffTabs` now also resolves/cleans the diff module's tracked state instead of only closing windows. ([#248](https://github.com/coder/claudecode.nvim/issues/248))
1516
- Show diffs when the Claude Code terminal is the only window (no other splits). Previously `openDiff` failed with "No suitable editor window found"; now a split is created to host the diff, matching the behavior of the `openFile` tool. ([#231](https://github.com/coder/claudecode.nvim/issues/231))
1617
- Work around a Neovim core bug (< 0.12.2) that fragmented large bracketed pastes into the terminal across `vim.paste` phases, making Cmd+V appear to truncate content. Added a scoped, version-gated `vim.paste` shim controlled by `terminal.fix_streamed_paste` (`"auto"` by default; no-op on Neovim >= 0.12.2). ([#161](https://github.com/coder/claudecode.nvim/issues/161))

lua/claudecode/selection.lua

Lines changed: 238 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ local terminal = require("claudecode.terminal")
77

88
local 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+
1032
M.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()
124158
end
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...).
128168
function 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()
130182
end
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
356430
end
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
373447
end
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
377457
local 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
395475
end
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
}
534691
end
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

Comments
 (0)