From edb125f38aa074c87c1048e12f17985012b018f0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 14:41:53 +0200 Subject: [PATCH] fix(selection): push quickly-made visual selections to Claude (#246) Passive selection tracking dropped selections that were made and released faster than the 100ms debounce, and wiped any selection 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. A single-line linewise `V` made right after a charwise selection was also mis-extracted to a single character. None of this was a Claude-CLI limitation. - Flush the selection synchronously on visual-mode exit from the `'<`/`'>` marks (the live `get_visual_selection()` returns nil at that instant), closing the debounce race; dedup against the in-visual debounce so a lingered selection is not sent twice. - Skip the flush when an operator consumed/mutated the selection (changedtick advanced since visual entry) so `d`/`c`/`>`/`x` do not broadcast stale, post-edit text as a phantom selection. - Demote a held selection to an empty cursor only once the cursor actually moves, so it persists (matching the official VS Code extension) instead of timing out -- this is what made the external-Claude case lose the selection. - Prefer the live mode in `get_effective_visual_mode()` over the global `vim.fn.visualmode()` (the last completed visual mode). Adds regression tests for the flush, dedup, operator-no-phantom, cursor-guarded demotion, and stale-mode extraction, plus `scripts/repro_issue_246.lua` which fails on the unfixed code and passes on the fix. Change-Id: I8769510fd0aa95ca9dec9016f38fd30247134761 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + lua/claudecode/selection.lua | 255 ++++++++++++++++++++++++++-- scripts/repro_issue_246.lua | 174 +++++++++++++++++++ tests/selection_test.lua | 315 +++++++++++++++++++++++++++++++++++ 4 files changed, 728 insertions(+), 17 deletions(-) create mode 100644 scripts/repro_issue_246.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfd9393..81430007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - `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)) - Rejecting a Claude diff with `:q` (or `:close` / `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)) +- 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)) - 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)) - 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)) - 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)) diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index c06b32ce..441fc8dc 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -7,6 +7,28 @@ local terminal = require("claudecode.terminal") local uv = vim.uv or vim.loop +---Returns true if the given mode string denotes a visual mode (charwise, linewise, blockwise). +---Select mode (`s`/`S`/``) is deliberately NOT treated as visual: like the rest of the +---module it tracks visual selections only (Select mode is an atypical workflow here). +---@param mode string|nil +---@return boolean +local function is_visual_mode(mode) + return mode == "v" or mode == "V" or mode == "\22" +end + +---Returns true if the cursor is still at the position captured by the most recent flush +---for this buffer (i.e. the held visual selection has not been navigated away from yet). +---@param bufnr number +---@return boolean +local function cursor_unmoved_since_flush(bufnr) + local caf = M.state.cursor_at_flush + if not caf or caf.bufnr ~= bufnr then + return false + end + local pos = vim.api.nvim_win_get_cursor(0) + return pos[1] == caf.pos[1] and pos[2] == caf.pos[2] +end + M.state = { latest_selection = nil, tracking_enabled = false, @@ -16,6 +38,16 @@ M.state = { last_active_visual_selection = nil, demotion_timer = nil, visual_demotion_delay_ms = 50, + + -- Cursor position captured when a visual selection is flushed on visual-mode exit. + -- Demotion only fires once the cursor actually moves away from this position, so a + -- held selection persists (see issue #246 and M.flush_visual_selection). + cursor_at_flush = nil, + + -- { bufnr, tick } captured when visual mode is entered. Used to detect that an operator + -- consumed/mutated the selection (d/c/>/x...) so the flush does not broadcast stale, + -- post-edit marks as a phantom selection (see M.flush_visual_selection). + visual_entry = nil, } ---Enables selection tracking. @@ -47,6 +79,8 @@ function M.disable() M.state.latest_selection = nil M.state.last_active_visual_selection = nil + M.state.cursor_at_flush = nil + M.state.visual_entry = nil M.server = nil M._cancel_debounce_timer() @@ -124,8 +158,26 @@ function M.on_cursor_moved() end ---Handles mode change events. ----Triggers an immediate update of the selection. +---When leaving visual mode, the selection is flushed synchronously: at that instant +---`nvim_get_mode()` already reports the new (non-visual) mode, so the debounced +---`update_selection()` path can no longer capture the visual selection. Flushing here +---(from the still-valid `'<`/`'>` marks) ensures fast selections that are made and +---released in under `debounce_ms` are not lost (issue #246). +---Entering visual mode records the buffer's changedtick so the flush can tell an +---abandoned selection apart from one consumed by a mutating operator (d/c/>/x...). function M.on_mode_changed() + local event = vim.v.event + if event then + local leaving_visual = is_visual_mode(event.old_mode) and not is_visual_mode(event.new_mode) + local entering_visual = is_visual_mode(event.new_mode) and not is_visual_mode(event.old_mode) + if entering_visual then + local buf = vim.api.nvim_get_current_buf() + M.state.visual_entry = { bufnr = buf, tick = vim.api.nvim_buf_get_changedtick(buf) } + elseif leaving_visual then + M.flush_visual_selection() + end + end + M.debounce_update() end @@ -226,10 +278,16 @@ function M.update_selection() local last_visual = M.state.last_active_visual_selection if M.state.demotion_timer then - -- A demotion is already pending. For this specific update_selection call (e.g. cursor moved), - -- current_selection reflects the immediate cursor position. - -- M.state.latest_selection (the one that might be sent) is still the visual one until timer resolves. - current_selection = M.get_cursor_position() + -- A demotion is already pending. While the cursor is still on the flushed position the + -- held visual selection must be preserved (an idle re-entry here must not wipe it before + -- the cursor actually moves -- matters when visual_demotion_delay_ms >= debounce_ms). + -- Once the cursor has moved, reflect the immediate cursor position; M.state.latest_selection + -- stays the visual one until the timer resolves. + if cursor_unmoved_since_flush(current_buf) then + current_selection = M.state.latest_selection + else + current_selection = M.get_cursor_position() + end elseif last_visual and last_visual.bufnr == current_buf @@ -264,11 +322,14 @@ function M.update_selection() end) ) else - -- Genuinely in normal mode, no recent visual exit, no pending demotion. + -- Genuinely in normal mode, no recent visual exit, no pending demotion. The + -- selection_changed protocol reflects the active editor, so report this buffer's + -- cursor. Any held visual selection (for this buffer, or one navigated away from + -- without moving the cursor) is no longer current -- clear its tracked state so it + -- does not leak across buffer switches. current_selection = M.get_cursor_position() - if last_visual and last_visual.bufnr == current_buf then - M.state.last_active_visual_selection = nil -- Clear it as it's no longer relevant for demotion - end + M.state.last_active_visual_selection = nil + M.state.cursor_at_flush = nil end end @@ -334,6 +395,16 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled) -- Condition 3: Still in Original Buffer & Not Visual & Not Claude Term -> Demote if current_buf == original_bufnr_when_scheduled then + -- Only demote once the cursor has actually moved since the selection was flushed. + -- This lets a held selection persist (matching the official VS Code extension, and + -- fixing the external-Claude case where there is no in-Neovim Claude terminal to + -- switch to), while still clearing a stale selection as soon as the user navigates + -- away from it. last_active_visual_selection is intentionally left intact when the + -- cursor is unmoved so a later cursor move re-arms demotion. See issue #246. + if cursor_unmoved_since_flush(current_buf) then + return + end + local new_sel_for_demotion = M.get_cursor_position() -- Check if this new cursor position is actually different from the (visual) latest_selection if M.has_selection_changed(new_sel_for_demotion) then @@ -346,13 +417,16 @@ function M.handle_selection_demotion(original_bufnr_when_scheduled) end -- User switched to different buffer - -- Always clear last_active_visual_selection for the original buffer as its pending demotion is resolved. + -- The pending demotion for the original buffer is resolved: clear its tracked state. if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled then M.state.last_active_visual_selection = nil end + if M.state.cursor_at_flush and M.state.cursor_at_flush.bufnr == original_bufnr_when_scheduled then + M.state.cursor_at_flush = nil + end end ---Validates if we're in a valid visual selection mode @@ -372,17 +446,17 @@ local function validate_visual_mode() return true, nil end ----Determines the effective visual mode character +---Determines the effective visual mode character. +---Prefers the LIVE mode; `vim.fn.visualmode()` (the LAST COMPLETED visual mode) is only +---used as a fallback when not currently in a visual mode. Trusting `visualmode()` while +---live in a different visual mode misclassifies the selection -- e.g. a fresh linewise +---`V` made right after a charwise selection would be extracted charwise, broadcasting a +---single character (or an empty selection on an empty line) instead of the whole line. +---See issue #246. ---@return string|nil - the visual mode character or nil if invalid local function get_effective_visual_mode() local current_nvim_mode = vim.api.nvim_get_mode().mode - local visual_fn_mode_char = vim.fn.visualmode() - if visual_fn_mode_char and visual_fn_mode_char ~= "" then - return visual_fn_mode_char - end - - -- Fallback to current mode if current_nvim_mode == "V" then return "V" elseif current_nvim_mode == "v" then @@ -391,6 +465,12 @@ local function get_effective_visual_mode() return "\22" end + -- Not currently in a visual mode: fall back to the last completed visual mode. + local visual_fn_mode_char = vim.fn.visualmode() + if visual_fn_mode_char and visual_fn_mode_char ~= "" then + return visual_fn_mode_char + end + return nil end @@ -511,6 +591,83 @@ function M.get_visual_selection() if visual_mode == "V" then final_text = extract_linewise_text(lines_content, start_coords) elseif visual_mode == "v" or visual_mode == "\22" then + -- Blockwise ("\22") is approximated as the contiguous charwise span: selection_changed + -- carries a single start/end range and cannot represent a rectangular block. Proper + -- per-column block extraction is a follow-up. + final_text = extract_characterwise_text(lines_content, start_coords, end_coords) + if not final_text then + return nil + end + else + return nil + end + + local lsp_positions = calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content) + + return { + text = final_text or "", + filePath = file_path, + fileUrl = "file://" .. file_path, + selection = { + start = lsp_positions.start, + ["end"] = lsp_positions["end"], + isEmpty = (not final_text or #final_text == 0), + }, + } +end + +---Gets the just-completed visual selection from the `'<` and `'>` marks. +---Unlike `get_visual_selection()`, this does NOT require the editor to currently be in +---visual mode, so it can be called from a `ModeChanged` (visual -> normal) handler where +---`nvim_get_mode()` already reports normal mode but the marks are still valid. The marks +---are always in chronological order (`'<` before `'>`). +---Only valid immediately on visual exit: it pairs the buffer-local `'<`/`'>` marks with the +---GLOBAL `vim.fn.visualmode()`, so both must describe the same just-completed selection +---(do not call it after an unrelated visual selection in another buffer). +---@return table|nil selection A selection table matching get_visual_selection()'s shape, or nil. +function M.get_visual_selection_from_marks() + local visual_mode = vim.fn.visualmode() + if not visual_mode or visual_mode == "" then + return nil + end + + local start_pos = vim.fn.getpos("'<") + local end_pos = vim.fn.getpos("'>") + if start_pos[2] == 0 or end_pos[2] == 0 then + return nil -- no recorded visual selection + end + + local current_buf = vim.api.nvim_get_current_buf() + local file_path = vim.api.nvim_buf_get_name(current_buf) + + local start_coords = { lnum = start_pos[2], col = start_pos[3] } + local end_coords = { lnum = end_pos[2], col = end_pos[3] } + + local lines_content = vim.api.nvim_buf_get_lines( + current_buf, + start_coords.lnum - 1, -- Convert to 0-indexed + end_coords.lnum, -- nvim_buf_get_lines end is exclusive + false + ) + + if #lines_content == 0 then + return nil + end + + -- For linewise selections (and `$`), the `'>` column can be MAXCOL (2147483647). Clamp it + -- to the last line's length + 1 so it never overflows string.sub / LSP character math. + local last_line = lines_content[#lines_content] or "" + if end_coords.col > #last_line + 1 then + end_coords.col = #last_line + 1 + end + + local final_text + if visual_mode == "V" then + final_text = extract_linewise_text(lines_content, start_coords) + elseif visual_mode == "v" or visual_mode == "\22" then + -- Blockwise ("\22") is approximated as the contiguous charwise span (matching + -- get_visual_selection), since selection_changed carries a single start/end range and + -- cannot represent a rectangular block. Proper per-column block extraction is a follow-up. final_text = extract_characterwise_text(lines_content, start_coords, end_coords) if not final_text then return nil @@ -533,6 +690,70 @@ function M.get_visual_selection() } end +---Flushes the just-completed visual selection synchronously when leaving visual mode. +---Captures the selection from the `'<`/`'>` marks, records it as the active visual +---selection, cancels any pending demotion, and broadcasts it if it changed. This closes +---the debounce race where a selection made and released faster than `debounce_ms` was +---never broadcast at all (issue #246). The Claude terminal buffer is ignored, mirroring +---`update_selection()`. Deduplicates against the last sent selection so a selection +---already broadcast by the in-visual debounce is not sent twice on exit. If the buffer +---was mutated while in visual mode (a consuming operator like d/c/>/x), the marks no +---longer describe the user's selection, so the flush is skipped to avoid broadcasting +---stale, post-edit text as a phantom selection. +function M.flush_visual_selection() + if not M.state.tracking_enabled then + return + end + + local current_buf = vim.api.nvim_get_current_buf() + local buf_name = vim.api.nvim_buf_get_name(current_buf) + + if buf_name and buf_name:match("^term://") and buf_name:lower():find("claude", 1, true) then + return + end + if terminal then + local claude_term_bufnr = terminal.get_active_terminal_bufnr() + if claude_term_bufnr and current_buf == claude_term_bufnr then + return + end + end + + -- Skip when the buffer was edited while in visual mode: the changedtick advancing since + -- visual entry means an operator (d/c/x...) consumed the selection, so the '<,'> marks + -- point at post-edit text the user never selected. This is intentionally conservative -- + -- it also skips in-place transforms that leave a valid selection (gU/gu/~/J/=), because + -- they cannot be told apart from consuming operators by the marks alone (both leave a + -- non-empty, in-bounds region). Such fast transforms simply are not flushed; the prior + -- behavior never broadcast them either (they lost the debounce race), so this is a + -- residual non-broadcast, not a regression. + local entry = M.state.visual_entry + if entry and entry.bufnr == current_buf and vim.api.nvim_buf_get_changedtick(current_buf) ~= entry.tick then + return + end + + local selection = M.get_visual_selection_from_marks() + if not selection or selection.selection.isEmpty then + return + end + + -- Record the cursor position at flush time so demotion only fires after a real move. + M.state.cursor_at_flush = { bufnr = current_buf, pos = vim.api.nvim_win_get_cursor(0) } + M.state.last_active_visual_selection = { + bufnr = current_buf, + selection_data = vim.deepcopy(selection), + timestamp = vim.loop.now(), + } + + M._cancel_demotion_timer() + + if M.has_selection_changed(selection) then + M.state.latest_selection = selection + if M.server then + M.send_selection_update(selection) + end + end +end + ---Gets the current cursor position when no visual selection is active. ---@return table A table containing an empty text, file path, URL, and cursor ---position as start/end, with isEmpty set to true. diff --git a/scripts/repro_issue_246.lua b/scripts/repro_issue_246.lua new file mode 100644 index 00000000..703b4a7c --- /dev/null +++ b/scripts/repro_issue_246.lua @@ -0,0 +1,174 @@ +-- Reproduction / verification for issue #246: +-- "Single-line visual selections are not pushed to Claude." +-- https://github.com/coder/claudecode.nvim/issues/246 +-- +-- Root cause (selection.lua), all timing/extraction, none Claude-CLI side: +-- 1. 100ms debounce race: a visual selection made and released faster than +-- M.state.debounce_ms was never captured (update_selection only runs in the +-- visual branch if its debounced callback fires while still in visual mode). +-- 2. Demotion-to-empty: after leaving visual mode (with an EXTERNAL Claude, where +-- there is no in-Neovim terminal to "switch to"), the selection was wiped to an +-- empty cursor ~debounce+demotion ms later. +-- 3. get_effective_visual_mode() trusted vim.fn.visualmode() (the LAST COMPLETED +-- visual mode) over the live mode, so a single-line linewise `V` right after a +-- charwise selection was extracted charwise -> 1 char (or empty on an empty line). +-- +-- The fix: flush the selection synchronously on visual-mode exit (from the '<,'> +-- marks), demote only after the cursor actually moves, and prefer the live mode in +-- get_effective_visual_mode(). +-- +-- This script drives the REAL selection.lua through the actual autocmd path +-- (ModeChanged -> flush) and real uv timers, with a mock server capturing broadcasts. +-- No WebSocket/Claude CLI needed. +-- +-- Run from the repo root: +-- nvim --headless -u NONE -l scripts/repro_issue_246.lua +-- +-- Exit code: 0 if every check passes (fixed), 1 if any check fails (#246 reproduced). + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h") +vim.opt.rtp:prepend(repo_root) + +local function out(msg) + io.stdout:write(msg .. "\n") +end + +local selection = require("claudecode.selection") + +local broadcasts = {} +local mock_server = { + broadcast = function(_, params) + table.insert(broadcasts, vim.deepcopy(params)) + return true + end, +} + +local buf = vim.api.nvim_create_buf(true, false) +vim.api.nvim_buf_set_name(buf, repo_root .. "/__issue_246_sample__.txt") +vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + "alpha beta gamma", + "delta epsilon zeta", + "eta theta iota", +}) +vim.api.nvim_set_current_buf(buf) +selection.enable(mock_server, 50) + +local function t(k) + return vim.api.nvim_replace_termcodes(k, true, false, true) +end + +local function reset() + vim.api.nvim_feedkeys(t(""), "x", false) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + selection.state.latest_selection = nil + selection.state.last_active_visual_selection = nil + selection.state.cursor_at_flush = nil + selection.state.visual_entry = nil + selection._cancel_debounce_timer() + selection._cancel_demotion_timer() + broadcasts = {} +end + +local function nonempty_broadcasts() + local n = 0 + for _, b in ipairs(broadcasts) do + if not b.selection.isEmpty then + n = n + 1 + end + end + return n +end + +local function latest_text() + local s = selection.state.latest_selection + return s and s.text or nil +end + +local function latest_is_empty() + local s = selection.state.latest_selection + return s == nil or s.selection.isEmpty +end + +local all_ok = true +local function check(name, ok) + if not ok then + all_ok = false + end + out((" [%s] %s"):format(ok and "PASS" or "FAIL", name)) +end + +out("== issue #246 reproduction / verification ==") + +-- 1. THE BUG: a fast single-line viw + Esc must reach Claude (one non-empty frame) and persist. +reset() +vim.api.nvim_feedkeys(t("viw"), "x", false) +vim.api.nvim_feedkeys(t(""), "x", false) +vim.wait(300) +out("\n[1] fast viw + Esc, cursor left still") +check("a non-empty selection was broadcast", nonempty_broadcasts() >= 1) +check("latest selection is 'alpha'", latest_text() == "alpha") +check("selection persists (not demoted)", not latest_is_empty()) + +-- 2. After a real cursor move, the held selection is demoted to an empty cursor. +reset() +vim.api.nvim_feedkeys(t("viw"), "x", false) +vim.api.nvim_feedkeys(t(""), "x", false) +vim.wait(20) +vim.api.nvim_feedkeys(t("l"), "x", false) +vim.wait(300) +out("\n[2] fast viw + Esc, then move the cursor") +check("selection demoted to empty after move", latest_is_empty()) + +-- 3. A lingered selection is sent exactly once (flush dedups against the in-visual debounce). +reset() +vim.api.nvim_feedkeys(t("viw"), "x", false) +vim.wait(150) +vim.api.nvim_feedkeys(t(""), "x", false) +vim.wait(250) +out("\n[3] lingered viw then Esc (no duplicate frame)") +check("exactly one non-empty broadcast", nonempty_broadcasts() == 1) +check("latest still 'alpha'", latest_text() == "alpha") + +-- 4. Stale-visualmode fix: a single-line linewise V after a charwise selection sends the whole line. +reset() +vim.api.nvim_feedkeys(t("viw"), "x", false) -- prime visualmode() = 'v' +vim.api.nvim_feedkeys(t(""), "x", false) +vim.wait(30) +broadcasts = {} +selection.state.latest_selection = nil +vim.api.nvim_win_set_cursor(0, { 2, 0 }) +vim.api.nvim_feedkeys(t("V"), "x", false) +vim.wait(150) +out("\n[4] charwise viw THEN single-line linewise V") +check("V sends the whole line, not 1 char", latest_text() == "delta epsilon zeta") + +-- 5. Fast multi-line Vjj + Esc reaches Claude too. +reset() +vim.api.nvim_feedkeys(t("Vjj"), "x", false) +vim.api.nvim_feedkeys(t(""), "x", false) +vim.wait(300) +out("\n[5] fast Vjj + Esc (multi-line)") +check("a non-empty selection was broadcast", nonempty_broadcasts() >= 1) +check( + "selection spans three lines", + (selection.state.latest_selection or {}).selection ~= nil + and selection.state.latest_selection.selection["end"].line == 2 +) + +-- 6. An operator that consumes the selection (viwd) must NOT broadcast phantom post-edit text. +reset() +vim.api.nvim_feedkeys(t("viwd"), "x", false) -- delete inner word: mutates buffer, exits visual +vim.wait(300) +out("\n[6] viwd (operator consumes/mutates the selection)") +check("no phantom non-empty broadcast after a mutating operator", nonempty_broadcasts() == 0) + +out("\n== verdict ==") +if all_ok then + out("FIXED: single- and multi-line selections reach Claude on visual exit and persist until the cursor moves.") +else + out("BUG REPRODUCED: at least one selection was not pushed (issue #246).") +end + +io.stdout:flush() +vim.cmd("cquit " .. (all_ok and 0 or 1)) diff --git a/tests/selection_test.lua b/tests/selection_test.lua index 1ba9d6e6..8f14ae57 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -76,6 +76,10 @@ if not _G.vim then return { mode = _G.vim._current_mode } end, + nvim_buf_get_changedtick = function(_) + return _G.vim._changedtick or 0 + end, + nvim_buf_get_lines = function(bufnr, start, end_line, _strict) -- Prefix unused param with underscore if not _G.vim._buffers[bufnr] then return {} @@ -116,6 +120,10 @@ if not _G.vim then return -1 end, getpos = function(mark) + -- Tests can override specific marks via _G.vim._marks (e.g. { ["'<"] = {0,1,1,0} }). + if _G.vim._marks and _G.vim._marks[mark] then + return _G.vim._marks[mark] + end if mark == "'<" then return { 0, 1, 1, 0 } elseif mark == "'>" then @@ -123,6 +131,10 @@ if not _G.vim then end return { 0, 0, 0, 0 } end, + -- Last completed visual mode; tests set _G.vim._visualmode. + visualmode = function() + return _G.vim._visualmode or "" + end, -- Add other vim.fn mocks as needed by selection tests mode = function() return _G.vim._current_mode or "n" @@ -302,6 +314,7 @@ if not _G.vim then return {} end, -- Mock for vim.empty_dict() g = {}, -- Mock for vim.g + v = { event = {} }, -- Mock for vim.v (ModeChanged supplies v:event.old_mode/new_mode) deepcopy = function(orig) local orig_type = type(orig) local copy @@ -885,4 +898,306 @@ describe("Range Selection Tests", function() assert(result == false) end) end) + + describe("issue #246 - flush on visual exit + cursor-guarded demotion", function() + local terminal_module = require("claudecode.terminal") + local original_get_term + -- Own mock_server (declared local-first so the broadcast closure captures THIS table). + -- `selection` reuses the enclosing describe's upvalue, refreshed per-test in before_each. + local mock_server = {} + mock_server.broadcast = function(event, data) + mock_server.last_broadcast = { event = event, data = data } + end + + before_each(function() + package.loaded["claudecode.selection"] = nil + selection = require("claudecode.selection") + original_get_term = terminal_module.get_active_terminal_bufnr + terminal_module.get_active_terminal_bufnr = function() + return nil -- external Claude: no in-Neovim terminal buffer + end + mock_server.last_broadcast = nil + _G.vim.v.event = {} + end) + + after_each(function() + if selection.state.tracking_enabled then + selection.disable() + end + terminal_module.get_active_terminal_bufnr = original_get_term + _G.vim._marks = nil + _G.vim._visualmode = nil + _G.vim._changedtick = nil + _G.vim.v.event = {} + -- Restore the default shared buffer so later specs are unaffected. + _G.vim.test.add_buffer(1, "/path/to/test.lua", "local test = {}\nreturn test") + _G.vim.test.set_cursor(1, 1, 0) + end) + + describe("get_visual_selection_from_marks()", function() + it("extracts a single-line charwise selection from the '<,'> marks", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + + local sel = selection.get_visual_selection_from_marks() + + assert(sel ~= nil) + assert.are.equal("alpha", sel.text) + assert.are.equal(false, sel.selection.isEmpty) + assert.are.equal(0, sel.selection.start.line) + assert.are.equal(0, sel.selection.start.character) + assert.are.equal(0, sel.selection["end"].line) + assert.are.equal(5, sel.selection["end"].character) + end) + + it("extracts a single-line linewise V selection (whole line), clamping MAXCOL '>", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma", "delta epsilon zeta" }) + _G.vim._visualmode = "V" + _G.vim._marks = { ["'<"] = { 0, 2, 1, 0 }, ["'>"] = { 0, 2, 2147483647, 0 } } + + local sel = selection.get_visual_selection_from_marks() + + assert(sel ~= nil) + assert.are.equal("delta epsilon zeta", sel.text) + assert.are.equal(false, sel.selection.isEmpty) + assert.are.equal(1, sel.selection.start.line) + assert.are.equal(0, sel.selection.start.character) + assert.are.equal(1, sel.selection["end"].line) + assert.are.equal(#"delta epsilon zeta", sel.selection["end"].character) + end) + + it("returns nil when no visual selection was ever recorded", function() + _G.vim._visualmode = "" + assert(selection.get_visual_selection_from_marks() == nil) + end) + + it("reports an empty-line linewise selection as isEmpty", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "" }) + _G.vim._visualmode = "V" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 2147483647, 0 } } + + local sel = selection.get_visual_selection_from_marks() + + assert(sel ~= nil) + assert.are.equal("", sel.text) + assert.are.equal(true, sel.selection.isEmpty) + end) + + it("approximates a blockwise selection as the contiguous charwise span (documented limitation)", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta", "gamma delta" }) + _G.vim._visualmode = "\22" -- CTRL-V blockwise + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 2, 2, 0 } } -- cols 1-2 across 2 lines + + local sel = selection.get_visual_selection_from_marks() + + assert(sel ~= nil) + -- Charwise approximation, NOT the rectangular block "al"/"ga": selection_changed + -- carries a single range, so the block is sent as its contiguous span. + assert.are.equal("alpha beta\nga", sel.text) + assert.are.equal(false, sel.selection.isEmpty) + end) + end) + + describe("on_mode_changed() flush", function() + it("broadcasts a fast single-line selection on visual->normal exit", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + selection.enable(mock_server) + _G.vim.test.set_mode("n") -- nvim_get_mode already reports normal at this point + _G.vim.test.set_cursor(0, 1, 4) + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + _G.vim.v.event = { old_mode = "v", new_mode = "n" } + mock_server.last_broadcast = nil + + selection.on_mode_changed() + + assert(mock_server.last_broadcast ~= nil) + assert.are.equal("selection_changed", mock_server.last_broadcast.event) + assert.are.equal("alpha", mock_server.last_broadcast.data.text) + assert.are.equal(false, mock_server.last_broadcast.data.selection.isEmpty) + assert(selection.state.latest_selection ~= nil) + assert.are.equal("alpha", selection.state.latest_selection.text) + assert(selection.state.cursor_at_flush ~= nil) + end) + + it("does NOT flush on a visual->visual transition (v to V)", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + selection.enable(mock_server) + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + _G.vim.v.event = { old_mode = "v", new_mode = "V" } + mock_server.last_broadcast = nil + + selection.on_mode_changed() + + assert(mock_server.last_broadcast == nil) + end) + + it("does NOT flush a phantom selection when an operator mutated the buffer (viwd)", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + selection.enable(mock_server) + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + + -- Enter visual mode: records the entry changedtick. + _G.vim._changedtick = 7 + _G.vim.v.event = { old_mode = "n", new_mode = "v" } + selection.on_mode_changed() + + -- An operator (d) mutates the buffer (tick advances) and exits visual mode. + _G.vim._changedtick = 8 + _G.vim.test.set_mode("n") + _G.vim.v.event = { old_mode = "v", new_mode = "n" } + mock_server.last_broadcast = nil + + selection.on_mode_changed() + + assert(mock_server.last_broadcast == nil) -- no phantom post-edit broadcast + assert(selection.state.latest_selection == nil) + end) + + it("does not double-send a selection already broadcast by the in-visual debounce", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + selection.enable(mock_server) + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + -- The in-visual debounce already sent this exact selection. + selection.state.latest_selection = selection.get_visual_selection_from_marks() + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 1, 4) + _G.vim.v.event = { old_mode = "v", new_mode = "n" } + mock_server.last_broadcast = nil + + selection.on_mode_changed() + + assert(mock_server.last_broadcast == nil) -- dedup: no resend on exit + end) + + it("does not flush when the active buffer is the Claude terminal", function() + _G.vim.test.add_buffer(1, "/path/to/test.lua", { "alpha beta gamma" }) + selection.enable(mock_server) + terminal_module.get_active_terminal_bufnr = function() + return 1 -- the current buffer IS the Claude terminal + end + _G.vim._visualmode = "v" + _G.vim._marks = { ["'<"] = { 0, 1, 1, 0 }, ["'>"] = { 0, 1, 5, 0 } } + _G.vim.test.set_mode("n") + _G.vim.v.event = { old_mode = "v", new_mode = "n" } + mock_server.last_broadcast = nil + + selection.on_mode_changed() + + assert(mock_server.last_broadcast == nil) + end) + end) + + describe("cursor-guarded demotion", function() + local function seed_held_selection() + local visual_selection = { + text = "alpha", + filePath = "/path/to/test.lua", + fileUrl = "file:///path/to/test.lua", + selection = { + start = { line = 0, character = 0 }, + ["end"] = { line = 0, character = 5 }, + isEmpty = false, + }, + } + selection.state.last_active_visual_selection = { bufnr = 1, selection_data = visual_selection, timestamp = 0 } + selection.state.latest_selection = visual_selection + selection.state.cursor_at_flush = { bufnr = 1, pos = { 1, 4 } } + end + + it("keeps a held selection when the cursor has not moved since the flush", function() + selection.enable(mock_server) + seed_held_selection() + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 1, 4) -- identical to cursor_at_flush -> unmoved + mock_server.last_broadcast = nil + + selection.update_selection() + local timer = selection.state.demotion_timer + assert(timer ~= nil) + timer:fire() + + assert.are.equal("alpha", selection.state.latest_selection.text) + assert.are.equal(false, selection.state.latest_selection.selection.isEmpty) + assert(mock_server.last_broadcast == nil) + -- kept intact so a later real cursor move can still re-arm demotion + assert(selection.state.last_active_visual_selection ~= nil) + end) + + it("demotes to an empty cursor once the cursor has moved since the flush", function() + selection.enable(mock_server) + seed_held_selection() + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 1, 6) -- moved from {1,4} + mock_server.last_broadcast = nil + + selection.update_selection() + local timer = selection.state.demotion_timer + assert(timer ~= nil) + timer:fire() + + assert.are.equal("", selection.state.latest_selection.text) + assert.are.equal(true, selection.state.latest_selection.selection.isEmpty) + assert(mock_server.last_broadcast ~= nil) + assert.are.equal("selection_changed", mock_server.last_broadcast.event) + end) + + it("still demotes when no flush occurred (cursor_at_flush nil = legacy path)", function() + selection.enable(mock_server) + seed_held_selection() + selection.state.cursor_at_flush = nil -- e.g. selection captured by the in-visual debounce only + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 2, 3) + mock_server.last_broadcast = nil + + selection.update_selection() + local timer = selection.state.demotion_timer + assert(timer ~= nil) + timer:fire() + + assert.are.equal(true, selection.state.latest_selection.selection.isEmpty) + assert(mock_server.last_broadcast ~= nil) + end) + + it("does not wipe a held selection on a pending-demotion re-entry while unmoved", function() + selection.enable(mock_server) + seed_held_selection() -- cursor_at_flush = { bufnr = 1, pos = { 1, 4 } } + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 1, 4) -- unmoved + + selection.update_selection() -- arms the demotion timer + assert(selection.state.demotion_timer ~= nil) + mock_server.last_broadcast = nil + + -- A second update runs while demotion is still pending and the cursor has not moved + -- (reachable when visual_demotion_delay_ms >= debounce_ms). It must NOT wipe the selection. + selection.update_selection() + + assert.are.equal("alpha", selection.state.latest_selection.text) + assert.are.equal(false, selection.state.latest_selection.selection.isEmpty) + assert(mock_server.last_broadcast == nil) + end) + + it("clears a held selection's tracked state when normal mode is entered in another buffer", function() + selection.enable(mock_server) + -- A held selection that belongs to a DIFFERENT buffer (2); current buffer is 1. + seed_held_selection() + selection.state.last_active_visual_selection.bufnr = 2 + selection.state.cursor_at_flush = { bufnr = 2, pos = { 1, 4 } } + _G.vim.test.set_mode("n") + _G.vim.test.set_cursor(0, 1, 0) + + selection.update_selection() -- else branch: different buffer, no demotion pending + + -- Stale cross-buffer state must not leak (no demotion timer armed for the other buffer). + assert(selection.state.last_active_visual_selection == nil) + assert(selection.state.cursor_at_flush == nil) + assert(selection.state.demotion_timer == nil) + end) + end) + end) end)