From d3862c1b9a2d2ea22fd2379afa350cf1815671d1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 19:54:35 +0000 Subject: [PATCH 01/11] wip: listen to the buffer of a held selection --- lua/claudecode/selection.lua | 129 ++++++++++++-- tests/integration/selection_held_spec.lua | 95 ++++++++++ tests/selection_test.lua | 203 ++++++++++++++++++++++ 3 files changed, 410 insertions(+), 17 deletions(-) diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index db0347f06..49b524e00 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -28,6 +28,25 @@ local function cursor_unmoved_since_flush(bufnr) return pos[1] == caf.pos[1] and pos[2] == caf.pos[2] end +---The selection that says "the cursor is here", for a position in a buffer. +---@param bufnr integer +---@param row integer 1-based line +---@param col integer 0-based byte column +---@return table +local function cursor_selection(bufnr, row, col) + local file_path = vim.api.nvim_buf_get_name(bufnr) + return { + text = "", + filePath = file_path, + fileUrl = "file://" .. file_path, + selection = { + start = { line = row - 1, character = col }, + ["end"] = { line = row - 1, character = col }, + isEmpty = true, + }, + } +end + ---Forgets the held visual selection. Call it where `latest_selection` is replaced. local function clear_held_state() M.state.last_active_visual_selection = nil @@ -46,10 +65,30 @@ local function selection_text_is_there(held, bufnr) return ok and utils.valid_utf8(table.concat(lines, "\n")) == held.text end +---The cursor of a buffer that is not the current one: where a window shows it, else where the selection was +---released, kept inside the text that is there now. +---@param bufnr integer +---@return table +local function cursor_in_other_buffer(bufnr) + local winid = vim.fn.win_findbuf(bufnr)[1] + if winid then + local pos = vim.api.nvim_win_get_cursor(winid) + return cursor_selection(bufnr, pos[1], pos[2]) + end + local caf = M.state.cursor_at_flush + local pos = caf and caf.bufnr == bufnr and caf.pos or { 1, 0 } + local row = math.max(1, math.min(pos[1], vim.api.nvim_buf_line_count(bufnr))) + local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or "" + return cursor_selection(bufnr, row, math.min(pos[2], #line)) +end + ---Reports the cursor at once if the buffer's text changed since the visual selection in `latest_selection` was read, ---and the selection's text is no longer at its range. Claude must not keep text that is gone, not even until the ---debounce fires: the debounce does nothing in the Claude terminal. The changedtick says that the buffer changed, ---not where, so the range is read again. +---The selection's buffer is not the current one when you are in the Claude terminal, which keeps a held selection +---(issue #246). A reload (`:checktime`, autoread) or a plugin's edit changes that buffer then, and neither fires +---TextChanged. The terminal does not update the selection, so the cursor of the selection's buffer is sent here. ---@return boolean demoted local function demote_if_selection_text_changed() local read = M.state.selection_tick @@ -58,17 +97,81 @@ local function demote_if_selection_text_changed() return false end local current_buf = vim.api.nvim_get_current_buf() - if read.bufnr ~= current_buf or vim.api.nvim_buf_get_changedtick(current_buf) == read.tick then + local from_terminal = read.bufnr ~= current_buf and M.is_claude_terminal_buffer(current_buf) + if read.bufnr ~= current_buf and not from_terminal then + return false + end + if from_terminal and not vim.api.nvim_buf_is_loaded(read.bufnr) then return false end - if selection_text_is_there(held, current_buf) then + if vim.api.nvim_buf_get_changedtick(read.bufnr) == read.tick or selection_text_is_there(held, read.bufnr) then return false end + -- Where the selection was released is in the state that clear_held_state forgets. + local cursor = from_terminal and cursor_in_other_buffer(read.bufnr) clear_held_state() - M.update_selection() + if cursor then + if M.has_selection_changed(cursor) then + M.state.latest_selection = cursor + if M.server then + M.send_selection_update(cursor) + end + end + else + M.update_selection() + end return true end +---Whether a visual selection that was read in the buffer is what Claude holds. +---@param bufnr integer +---@return boolean +local function holds_selection_of(bufnr) + local read = M.state.selection_tick + local held = M.state.latest_selection + return M.state.tracking_enabled and read ~= nil and read.bufnr == bufnr and held ~= nil and not held.selection.isEmpty +end + +---Listens for changes to the buffer of the selection that Claude holds, for as long as it holds one. Neovim fires +---TextChanged only for the current buffer, so this is the only way to see a reload or a plugin's edit of a buffer +---while you are in the Claude terminal. A reload detaches the listener (on_detach), so it is attached again when the +---buffer still holds the selection. +---@param bufnr integer +local function watch_selection_buffer(bufnr) + if M.state.watched_buffers[bufnr] then + return + end + M.state.watched_buffers[bufnr] = true + local function on_changed(_, buf) + if not holds_selection_of(buf) then + M.state.watched_buffers[buf] = nil + return true + end + -- Do not touch the buffer here: it is being changed. + vim.schedule(demote_if_selection_text_changed) + end + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = on_changed, + on_reload = on_changed, + on_detach = function(_, buf) + M.state.watched_buffers[buf] = nil + vim.schedule(function() + if holds_selection_of(buf) and vim.api.nvim_buf_is_loaded(buf) then + watch_selection_buffer(buf) + demote_if_selection_text_changed() + end + end) + end, + }) +end + +---Records the buffer and changedtick of the visual selection that was just read, and listens for changes to it. +---@param bufnr integer +local function record_selection_read(bufnr) + M.state.selection_tick = { bufnr = bufnr, tick = vim.api.nvim_buf_get_changedtick(bufnr) } + watch_selection_buffer(bufnr) +end + ---Reacts, at the moment it happens, to something that ends the hold on a visual selection. A timer that compares ---the cursor when it fires does not see a move away and back inside one debounce, and does not see an edit that ---leaves the cursor where it was. @@ -134,6 +237,9 @@ M.state = { -- longer have (see M.on_text_changed). selection_tick = nil, + -- Buffers with a change listener (see watch_selection_buffer): bufnr -> true. + watched_buffers = {}, + -- { 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). @@ -358,7 +464,7 @@ function M.update_selection() selection_data = vim.deepcopy(current_selection), -- Store a copy timestamp = vim.loop.now(), } - M.state.selection_tick = { bufnr = current_buf, tick = vim.api.nvim_buf_get_changedtick(current_buf) } + record_selection_read(current_buf) else -- No valid visual selection (e.g., get_visual_selection returned nil) -- Clear last_active_visual if it was for this buffer @@ -918,7 +1024,7 @@ function M.flush_visual_selection() -- 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.selection_tick = { bufnr = current_buf, tick = vim.api.nvim_buf_get_changedtick(current_buf) } + record_selection_read(current_buf) M.state.last_active_visual_selection = { bufnr = current_buf, selection_data = vim.deepcopy(selection), @@ -942,18 +1048,7 @@ end function M.get_cursor_position(winid) local cursor_pos = vim.api.nvim_win_get_cursor(winid or 0) local current_buf = winid and vim.api.nvim_win_get_buf(winid) or vim.api.nvim_get_current_buf() - local file_path = vim.api.nvim_buf_get_name(current_buf) - - return { - text = "", - filePath = file_path, - fileUrl = "file://" .. file_path, - selection = { - start = { line = cursor_pos[1] - 1, character = cursor_pos[2] }, - ["end"] = { line = cursor_pos[1] - 1, character = cursor_pos[2] }, - isEmpty = true, - }, - } + return cursor_selection(current_buf, cursor_pos[1], cursor_pos[2]) end ---Checks if the selection has changed compared to the latest stored selection. diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 1369777e0..6b4fded11 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -137,6 +137,101 @@ describe("a held selection with the real plugin", function() assert.are.equal(14, held.selection.start.character) end) + -- The Claude terminal keeps a held selection, and Neovim fires TextChanged only for the current buffer. When the + -- file changes while you are in the terminal (Claude rewrites it and Neovim reloads it, or a plugin edits it), + -- Claude must not keep the text that is gone until you come back. + describe("when the file of the held selection changes while you are in the Claude terminal", function() + local function path_of(buf) + return vim.api.nvim_buf_get_name(buf) + end + + -- Neovim reloads a file whose modification time differs from the one it stored. + local function rewrite_on_disk_and_reload(buf, line) + local path = path_of(buf) + vim.fn.writefile({ line }, path) + local later = os.time() + 10 + (vim.uv or vim.loop).fs_utime(path, later, later) + vim.cmd("checktime " .. buf) + end + + it("reports the cursor when a plugin edits the text, and the file is not in a window", function() + start({ provider = "none" }, 50) + open_file("alpha beta gamma") + local file_buf = vim.api.nvim_get_current_buf() + vim.cmd("terminal sh -c 'sleep 60' claude") + local term_buf = vim.api.nvim_get_current_buf() + vim.cmd("stopinsert") + vim.cmd("buffer " .. file_buf) + wait(200) + select_and_release() + + vim.cmd("buffer " .. term_buf) + vim.cmd("stopinsert") + wait(300) + vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " + wait(300) + + assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) + local held = selection.get_latest_selection() + assert.is_true(held.selection.isEmpty) + assert.are.equal(path_of(file_buf), held.filePath) + assert.are.equal(4, held.selection.start.character) -- where you released the selection + end) + + it("reports the cursor of the file's window when Neovim reloads the file", function() + start({ provider = "none" }, 50) + open_file("alpha beta gamma") + local file_buf = vim.api.nvim_get_current_buf() + local file_win = vim.api.nvim_get_current_win() + vim.cmd("vsplit | terminal sh -c 'sleep 60' claude") -- the file stays in its own window + local term_win = vim.api.nvim_get_current_win() + vim.cmd("stopinsert") + vim.api.nvim_set_current_win(file_win) + wait(200) + select_and_release() + + vim.api.nvim_set_current_win(term_win) + vim.cmd("stopinsert") + wait(300) + rewrite_on_disk_and_reload(file_buf, "beta gamma") + wait(300) + + assert.are.same({ "beta gamma" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) + assert.are.equal(term_win, vim.api.nvim_get_current_win()) + local held = selection.get_latest_selection() + assert.is_true(held.selection.isEmpty) + assert.are.equal(path_of(file_buf), held.filePath) + assert.are.equal(4, held.selection.start.character) + end) + + it("keeps the selection when a reload leaves its text in place", function() + start({ provider = "none" }, 50) + open_file("alpha beta gamma") + local file_buf = vim.api.nvim_get_current_buf() + local file_win = vim.api.nvim_get_current_win() + vim.cmd("vsplit | terminal sh -c 'sleep 60' claude") + local term_win = vim.api.nvim_get_current_win() + vim.cmd("stopinsert") + vim.api.nvim_set_current_win(file_win) + wait(200) + select_and_release() + + vim.api.nvim_set_current_win(term_win) + vim.cmd("stopinsert") + wait(300) + rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, and more") + wait(300) + + assert.are.same({ "alpha beta gamma, and more" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) + assert.are.equal("alpha", selection.get_latest_selection().text) + + -- The reload detached the listener: the next change is seen too. + vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) + wait(300) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + end) + end) + -- A popup that wipes itself when you leave it: its buffer number is gone at the next event, and the plugin must -- neither raise nor keep the selection of a file you left. for _, kind in ipairs({ "bufhidden=wipe left with :buffer #", "nvim_buf_delete of the displayed buffer" }) do diff --git a/tests/selection_test.lua b/tests/selection_test.lua index a4bba33fe..cc242b333 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -65,6 +65,8 @@ if not _G.vim then end, _buffers = {}, _windows = {}, + _buf_listeners = {}, -- nvim_buf_attach: bufnr -> { callbacks } + _buffer_windows = {}, -- vim.fn.win_findbuf: bufnr -> { winid } _commands = {}, _autocmds = {}, _vars = {}, @@ -123,6 +125,16 @@ if not _G.vim then return _G.vim._buffers[bufnr] ~= nil end, + nvim_buf_is_loaded = function(bufnr) + return _G.vim._buffers[bufnr] ~= nil + end, + + nvim_buf_attach = function(bufnr, _send_buffer, callbacks) + _G.vim._buf_listeners[bufnr] = _G.vim._buf_listeners[bufnr] or {} + table.insert(_G.vim._buf_listeners[bufnr], callbacks) + return true + end, + nvim_get_current_win = function() return 1 end, @@ -221,6 +233,9 @@ if not _G.vim then end return -1 end, + win_findbuf = function(bufnr) + return _G.vim._buffer_windows[bufnr] or {} + end, -- Last completed visual mode; tests set _G.vim._visualmode. visualmode = function() return _G.vim._visualmode or "" @@ -2028,6 +2043,20 @@ describe("Range Selection Tests", function() fire_autocmd("CursorMoved") end + -- What Neovim does for nvim_buf_attach: runs the callback of each listener on the buffer, and drops one that + -- returns true. A reload (on_detach) drops them all. + local function fire_buffer_listeners(bufnr, name) + local listeners = _G.vim._buf_listeners[bufnr] or {} + _G.vim._buf_listeners[bufnr] = {} + for _, listener in ipairs(listeners) do + -- A callback can attach a new listener, so the list is a fresh one and each callback runs once. + local detach = listener[name](nil, bufnr) + if name ~= "on_detach" and detach ~= true then + table.insert(_G.vim._buf_listeners[bufnr], listener) + end + end + end + local function assert_claude_holds_cursor(path, col) local held = selection.get_latest_selection() assert(held ~= nil) @@ -2050,6 +2079,8 @@ describe("Range Selection Tests", function() _G.vim.test.add_buffer(1, "/path/to/a.txt", { "alpha beta gamma" }) _G.vim.test.add_buffer(2, "/path/to/b.txt", { "one two" }) _G.vim.test.set_cursor(0, 1, 0) + _G.vim._buf_listeners = {} + _G.vim._buffer_windows = {} end) after_each(function() @@ -2481,6 +2512,178 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) + -- TextChanged fires only for the current buffer. A reload (:checktime, autoread) or a plugin's edit changes + -- the buffer of the held selection while you are in the Claude terminal, which keeps the selection and does + -- not update it. A listener on that buffer sees the change. + describe("when the buffer of the held selection changes while you are in the Claude terminal", function() + local ticks + + -- Holds "alpha" in buffer 1, then goes to the Claude terminal, buffer 2. + local function hold_alpha_and_enter_the_terminal() + ticks = { [1] = 5, [2] = 5 } + _G.vim.api.nvim_buf_get_changedtick = function(bufnr) + return ticks[bufnr] + end + selection.enable(mock_server) + start_selecting(5) + release_selection(1, 5, 4) + settle() + terminal_module.get_active_terminal_bufnr = function() + return 2 + end + go_to_buffer(2, 0) + settle() + assert.are.equal("alpha", selection.get_latest_selection().text) + mock_server.last_broadcast = nil + end + + local function change_buffer_1(lines) + ticks[1] = ticks[1] + 1 + _G.vim.test.add_buffer(1, "/path/to/a.txt", lines) + fire_buffer_listeners(1, "on_lines") + end + + it("listens to the buffer of a held selection once", function() + selection.enable(mock_server) + start_selecting(5) + release_selection(1, 5, 4) + start_selecting(5) + release_selection(1, 5, 4) + + assert.are.equal(1, #_G.vim._buf_listeners[1]) + end) + + it("sends the cursor where the buffer's window has it, not the deleted text", function() + hold_alpha_and_enter_the_terminal() + _G.vim._buffer_windows[1] = { 7 } + _G.vim.test.set_cursor(7, 1, 3) + + change_buffer_1({ "beta gamma" }) + + assert_claude_holds_cursor("/path/to/a.txt", 3) + assert.is_nil(selection.state.cursor_at_flush) + assert.is_nil(selection.state.last_active_visual_selection) + end) + + it("sends the position where it was released, kept inside the text, when no window shows the buffer", function() + hold_alpha_and_enter_the_terminal() + + change_buffer_1({ "be" }) + + assert_claude_holds_cursor("/path/to/a.txt", 2) + end) + + it("sends the first line when the text has fewer lines than the release position", function() + hold_alpha_and_enter_the_terminal() + _G.vim.test.add_buffer(1, "/path/to/a.txt", { "alpha beta gamma", "second", "third" }) + selection.state.cursor_at_flush.pos = { 3, 2 } + + change_buffer_1({ "x" }) + + assert_claude_holds_cursor("/path/to/a.txt", 1) + assert.are.equal(0, selection.get_latest_selection().selection.start.line) + end) + + it("keeps the selection when the change leaves its text in place", function() + hold_alpha_and_enter_the_terminal() + + change_buffer_1({ "alpha beta gamma", "a second line" }) + + assert.are.equal("alpha", selection.get_latest_selection().text) + assert.is_nil(mock_server.last_broadcast) + end) + + it("listens again after a reload detaches the listener, and sees the change of that reload", function() + hold_alpha_and_enter_the_terminal() + + ticks[1] = 6 + _G.vim.test.add_buffer(1, "/path/to/a.txt", { "beta gamma" }) + fire_buffer_listeners(1, "on_detach") -- the reload + + assert_claude_holds_cursor("/path/to/a.txt", 4) + end) + + it("listens again after a reload that left the text in place", function() + hold_alpha_and_enter_the_terminal() + + ticks[1] = 6 + fire_buffer_listeners(1, "on_detach") + + assert.are.equal("alpha", selection.get_latest_selection().text) + assert.are.equal(1, #_G.vim._buf_listeners[1]) + change_buffer_1({ "beta gamma" }) + assert_claude_holds_cursor("/path/to/a.txt", 4) + end) + + it("does nothing for the buffer you are in: TextChanged handles that one", function() + selection.enable(mock_server) + ticks = { [1] = 5 } + _G.vim.api.nvim_buf_get_changedtick = function(bufnr) + return ticks[bufnr] + end + start_selecting(5) + release_selection(1, 5, 4) + settle() + mock_server.last_broadcast = nil + + -- A listener runs before TextChanged: the change is reported once, by whichever comes first. + ticks[1] = 6 + _G.vim.test.add_buffer(1, "/path/to/a.txt", { "beta gamma" }) + fire_buffer_listeners(1, "on_lines") + + assert_claude_holds_cursor("/path/to/a.txt", 4) + end) + + it("does not send a cursor while you are in another file: the debounce does", function() + selection.enable(mock_server) + ticks = { [1] = 5, [2] = 5 } + _G.vim.api.nvim_buf_get_changedtick = function(bufnr) + return ticks[bufnr] + end + start_selecting(5) + release_selection(1, 5, 4) + settle() + enter_buffer(2) -- b.txt, not the Claude terminal, and the debounce has not fired yet + mock_server.last_broadcast = nil + + change_buffer_1({ "beta gamma" }) + + assert.are.equal("alpha", selection.get_latest_selection().text) + assert.is_nil(mock_server.last_broadcast) + end) + + it("stops listening when Claude no longer holds a selection", function() + hold_alpha_and_enter_the_terminal() + change_buffer_1({ "beta gamma" }) -- Claude holds the cursor now + + change_buffer_1({ "beta gamma!" }) + + assert.are.equal(0, #_G.vim._buf_listeners[1]) + assert.is_nil(selection.state.watched_buffers[1]) + end) + + it("stops listening when tracking is disabled", function() + hold_alpha_and_enter_the_terminal() + selection.disable() + + change_buffer_1({ "beta gamma" }) + + assert.are.equal(0, #_G.vim._buf_listeners[1]) + end) + + it("does not read a buffer that was unloaded", function() + hold_alpha_and_enter_the_terminal() + _G.vim._buffers[1] = nil -- :bunload + + assert.has_no.errors(function() + fire_buffer_listeners(1, "on_detach") + end) + + assert.are.equal("alpha", selection.get_latest_selection().text) + assert.is_nil(mock_server.last_broadcast) + end) + end) + -- Finding 3, SameFile: a long visual_demotion_delay_ms, so the demotion is still pending (it never fires -- in this test until the end). it("never holds another file's cursor after you come back (:b b.txt, :b a.txt)", function() From a1bca366f4b3477c5d1a2146194a428afee6091e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:08:50 +0000 Subject: [PATCH 02/11] wip: dedupe the scheduled check, docs, lock --- .../features/selection.md | 2 +- .../skills/verify-diff-lifecycle/covered.lock | 2 +- ARCHITECTURE.md | 2 +- lua/claudecode/README.md | 2 +- lua/claudecode/selection.lua | 11 ++++++++-- tests/selection_test.lua | 22 ++++++++++++++++++- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.agents/skills/verify-claudecode-nvim/features/selection.md b/.agents/skills/verify-claudecode-nvim/features/selection.md index d9b487051..81134843a 100644 --- a/.agents/skills/verify-claudecode-nvim/features/selection.md +++ b/.agents/skills/verify-claudecode-nvim/features/selection.md @@ -36,7 +36,7 @@ Steps: ## Gotchas -- Leaving visual mode keeps the last visual selection until the user moves the cursor, starts another selection, lets an operator consume it, edits its text, or goes to another file. A visit to the Claude terminal keeps it. `getCurrentSelection` still returns it with `isEmpty: false`. Move the cursor before you test the empty state. +- Leaving visual mode keeps the last visual selection until the user moves the cursor, starts another selection, lets an operator consume it, edits its text, or goes to another file. A visit to the Claude terminal keeps it, unless the file's text changes meanwhile (Neovim reloads it, or a plugin edits it): the cursor of that file goes out at once. `getCurrentSelection` still returns it with `isEmpty: false`. Move the cursor before you test the empty state. - While tracking is on, `getCurrentSelection` and `getLatestSelection` both return the selection Neovim last pushed. `getCurrentSelection` only adds `"success":true`. Neither reads the editor again, so during the debounce both return the older selection. Three events do not wait for the debounce, and each pushes at once: leaving visual mode, editing the held text or consuming it with an operator, and moving the cursor away from a held selection. Entering another file (`:b`, a window switch) waits for the debounce: measured about 100 ms. - Most notifications are debounced; the three events above are not. Wait for the mode or cursor state with `$V until`, then allow about one second before you read `ws-in.jsonl`. - Lines and characters are 0-based in every payload. `:3` in Neovim is `line: 2`. diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 393628771..0a6cc4279 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -133,7 +133,7 @@ file lua/claudecode/lockfile.lua 2746b4c6e0b2b630 == lua/claudecode/logger.lua file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua -file lua/claudecode/selection.lua 0fa651cc95e0af0d +file lua/claudecode/selection.lua f333af40ed03eb2f == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 3454ff62e057b632 block create_client 647b7523dea26af6 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ebaaf0769..5a6b0d90e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -116,7 +116,7 @@ vim.keymap.set("n", "dq", exit_diff_mode) Pushes a `selection_changed` notification when the selection Claude should see differs from the last one sent. Most updates are debounced, and three events are not: - **Debounced, 100 ms.** `CursorMoved`, `CursorMovedI`, `BufEnter`, `ModeChanged`, `TextChanged`, `TextChangedI` and `TextChangedP` each restart a one-shot timer. When it fires, `update_selection` reads the mode and sends the selection, or the cursor in normal mode. -- **At once.** Leaving visual mode sends the selection from the `'<` and `'>` marks (the flush). Editing the held text, or consuming it with an operator, sends the cursor. Moving the cursor away from a held selection sends the cursor. +- **At once.** Leaving visual mode sends the selection from the `'<` and `'>` marks (the flush). Editing the held text, or consuming it with an operator, sends the cursor. Moving the cursor away from a held selection sends the cursor. When the held text changes while you are in the Claude terminal (Neovim reloads the file, or a plugin edits it), the cursor of its file goes out too: `TextChanged` fires only for the current buffer, so a listener from `nvim_buf_attach` on the file's buffer sees the change. - **Entering another file** waits for the debounce. - **Claude terminal buffers** are skipped. diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index ac00ee4ef..7d84523f4 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -113,7 +113,7 @@ Module map (direct files): - A mention sent while disconnected goes to the queue with the same absolute path as a connected send, and the terminal opens. A connected send goes to the queue too, unless broadcast debouncing is off and the queue is empty [init.lua:M._broadcast_at_mention] - The queue sends each mention once, oldest first: 50 ms after a send, `connection_wait_delay` ms after a client completed its handshake, and 25 ms apart. A mention leaves the queue only when a connected client took it. When the write of that frame then fails on every client, the error "Claude's connection failed while the @ mention was sent" appears, and the mention is not sent again. When Claude is not connected, the mentions wait up to `connection_timeout` ms for a handshake, then the queue is cleared with the error "Connection timeout - clearing N queued @ mentions". A handshake stops that timeout, and a flush that finds no connected client starts it again. `queue_timeout` is accepted but unused [init.lua:schedule_mention_flush] - `M.stop` clears the queue and stops its timers. A queue timer's callback does nothing once its timer was stopped or replaced, so nothing queued before a stop reaches Claude [init.lua:start_timer] -- Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] +- Selection tracking is a no-op unless it is enabled. Updates are debounced by 100 ms, except that leaving visual mode, editing or consuming the held text and moving the cursor away from a held selection report at once [selection.lua:M.flush_visual_selection] [selection.lua:reconcile_held_selection]. The held text can also change while you are in the Claude terminal (a reload, a plugin's edit), where `TextChanged` does not fire for its buffer: a listener from `nvim_buf_attach` on that buffer sends the file's cursor then [selection.lua:watch_selection_buffer]. Entering another file waits for the debounce. Claude terminal buffers are skipped [selection.lua:M.update_selection] - The automatic paths send `selection_changed` only when the selection differs from the last one sent [selection.lua:M.update_selection] [selection.lua:M.flush_visual_selection]. `send_current_selection` sends with no change check [selection.lua:M.send_current_selection] - Select mode (`gh`, or `v`, `V` and CTRL-V when `'selectmode'` contains `cmd`) is tracked like Visual mode [selection.lua:is_visual_mode], and a command such as a `ClaudeCodeSend` mapping takes the visual path there [visual_commands.lua:M.create_visual_command_wrapper]. The visual path ends Select mode. For a mapping made with `mode = "v"` (`:vmap`), Neovim queues a key that restores Select mode after the mapping, and the visual path cancels it with `gV` [visual_commands.lua:M.exit_visual_and_schedule] - A visual selection is flushed on visual exit. The flush is skipped if the buffer changed during visual mode [selection.lua:M.flush_visual_selection] diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 49b524e00..305c0d0a4 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -142,13 +142,20 @@ local function watch_selection_buffer(bufnr) return end M.state.watched_buffers[bufnr] = true + local check_scheduled = false local function on_changed(_, buf) if not holds_selection_of(buf) then M.state.watched_buffers[buf] = nil return true end - -- Do not touch the buffer here: it is being changed. - vim.schedule(demote_if_selection_text_changed) + -- Do not touch the buffer here: it is being changed. One check after a burst of changes (a :%s) is enough. + if not check_scheduled then + check_scheduled = true + vim.schedule(function() + check_scheduled = false + demote_if_selection_text_changed() + end) + end end vim.api.nvim_buf_attach(bufnr, false, { on_lines = on_changed, diff --git a/tests/selection_test.lua b/tests/selection_test.lua index cc242b333..5348ba4be 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2573,7 +2573,7 @@ describe("Range Selection Tests", function() assert_claude_holds_cursor("/path/to/a.txt", 2) end) - it("sends the first line when the text has fewer lines than the release position", function() + it("keeps the release position inside the lines that are there", function() hold_alpha_and_enter_the_terminal() _G.vim.test.add_buffer(1, "/path/to/a.txt", { "alpha beta gamma", "second", "third" }) selection.state.cursor_at_flush.pos = { 3, 2 } @@ -2652,6 +2652,26 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) + it("checks once after a burst of changes, such as a :%s", function() + hold_alpha_and_enter_the_terminal() + local original_schedule = _G.vim.schedule + local queue = {} + _G.vim.schedule = function(fn) + table.insert(queue, fn) + end + + ticks[1] = 6 + _G.vim.test.add_buffer(1, "/path/to/a.txt", { "beta gamma" }) + for _ = 1, 5 do + fire_buffer_listeners(1, "on_lines") + end + _G.vim.schedule = original_schedule + + assert.are.equal(1, #queue) + queue[1]() + assert_claude_holds_cursor("/path/to/a.txt", 4) + end) + it("stops listening when Claude no longer holds a selection", function() hold_alpha_and_enter_the_terminal() change_buffer_1({ "beta gamma" }) -- Claude holds the cursor now From 37c7d85b626994860c6403ba41815334de0e87bb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:10:08 +0000 Subject: [PATCH 03/11] fix(selection): cover an unloaded buffer, drop a redundant check --- lua/claudecode/selection.lua | 4 ++-- tests/selection_test.lua | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 305c0d0a4..3bf450c94 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -123,13 +123,13 @@ local function demote_if_selection_text_changed() return true end ----Whether a visual selection that was read in the buffer is what Claude holds. +---Whether a visual selection that was read in the buffer is what Claude holds. Disabling tracking forgets both. ---@param bufnr integer ---@return boolean local function holds_selection_of(bufnr) local read = M.state.selection_tick local held = M.state.latest_selection - return M.state.tracking_enabled and read ~= nil and read.bufnr == bufnr and held ~= nil and not held.selection.isEmpty + return read ~= nil and read.bufnr == bufnr and held ~= nil and not held.selection.isEmpty end ---Listens for changes to the buffer of the selection that Claude holds, for as long as it holds one. Neovim fires diff --git a/tests/selection_test.lua b/tests/selection_test.lua index 5348ba4be..da8262d55 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2652,6 +2652,19 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) + it("does not read a buffer that was unloaded while you stay in the terminal", function() + hold_alpha_and_enter_the_terminal() + ticks[1] = 6 + _G.vim._buffers[1] = nil -- :bunload + + assert.has_no.errors(function() + fire_autocmd("CursorMoved") -- in the terminal + end) + + assert.are.equal("alpha", selection.get_latest_selection().text) + assert.is_nil(mock_server.last_broadcast) + end) + it("checks once after a burst of changes, such as a :%s", function() hold_alpha_and_enter_the_terminal() local original_schedule = _G.vim.schedule From 703300211e91f332f76231cc30207588b990ead1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:12:12 +0000 Subject: [PATCH 04/11] chore(skills): re-record the verify-diff-lifecycle lock --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index f4041d469..257dd7655 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -136,7 +136,7 @@ file lua/claudecode/lockfile.lua 2746b4c6e0b2b630 == lua/claudecode/logger.lua file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua -file lua/claudecode/selection.lua f333af40ed03eb2f +file lua/claudecode/selection.lua 01c5ab1f6e1890d5 == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 3454ff62e057b632 block create_client 647b7523dea26af6 From 7616fac33573f47efc65f1a340de1afc3b853eb9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:27:54 +0000 Subject: [PATCH 05/11] fix(selection): check the held text in the debounce of the Claude terminal too --- lua/claudecode/selection.lua | 4 +++- tests/selection_test.lua | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 3bf450c94..d81feabcf 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -449,9 +449,11 @@ function M.update_selection() local current_buf = vim.api.nvim_get_current_buf() - -- Do not update the selection in the Claude terminal; cancel any pending demotion. + -- Do not update the selection in the Claude terminal; cancel any pending demotion. A new terminal is not registered + -- when BufEnter fires, so the held text of the file you came from is checked here too, once the debounce knows it. if M.is_claude_terminal_buffer(current_buf) then M._cancel_demotion_timer() + demote_if_selection_text_changed() return end diff --git a/tests/selection_test.lua b/tests/selection_test.lua index da8262d55..fb8620132 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2665,6 +2665,32 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) + -- A new Claude terminal is not registered when BufEnter fires. The file changed before that, while you were in + -- another file, where nothing needs to be sent yet. The debounce is the first event that knows the terminal. + it("checks the held text in the debounce when the terminal was not registered at BufEnter", function() + ticks = { [1] = 5, [2] = 5, [3] = 5 } + _G.vim.api.nvim_buf_get_changedtick = function(bufnr) + return ticks[bufnr] + end + _G.vim.test.add_buffer(3, "/path/to/c.txt", { "three" }) + selection.enable(mock_server) + start_selecting(5) + release_selection(1, 5, 4) + settle() + go_to_buffer(3, 0) -- c.txt, a file + change_buffer_1({ "beta gamma" }) + assert.are.equal("alpha", selection.get_latest_selection().text) -- nothing to send in c.txt yet + + terminal_module.get_active_terminal_bufnr = function() + return 3 -- the terminal in buffer 3 is registered now + end + _G.vim.test.add_buffer(3, "term://~/project//4242:claude", { "claude> " }) + fire_debounce() + + assert_claude_holds_cursor("/path/to/a.txt", 4) + _G.vim._buffers[3] = nil + end) + it("checks once after a burst of changes, such as a :%s", function() hold_alpha_and_enter_the_terminal() local original_schedule = _G.vim.schedule From 2cd61d37f1664e7586827e07b84d9b78e1877c7f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 20:29:51 +0000 Subject: [PATCH 06/11] chore(skills): re-record the verify-diff-lifecycle lock --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 257dd7655..733982870 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -136,7 +136,7 @@ file lua/claudecode/lockfile.lua 2746b4c6e0b2b630 == lua/claudecode/logger.lua file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua -file lua/claudecode/selection.lua 01c5ab1f6e1890d5 +file lua/claudecode/selection.lua eb1a7cec47115a17 == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 3454ff62e057b632 block create_client 647b7523dea26af6 From beb43ee7a3cbdf1a16809480bc00812e63d7064a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 21:09:22 +0000 Subject: [PATCH 07/11] fix(selection): do not let on_reload forget a listener that Neovim keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neovim ignores what on_reload returns, so a listener that cleared its watched_buffers entry there stayed attached and the next read attached a second one. Only on_lines detaches now. An independent review found it. Also: say which reloads detach the listener (:edit, :edit!) and which keep it (:checktime, autoread), fix the test mock to match, and narrow the feature doc: the cursor goes out when the selected text changes or moves. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ --- .../features/selection.md | 2 +- .../skills/verify-diff-lifecycle/covered.lock | 2 +- lua/claudecode/selection.lua | 27 ++++++++----- tests/integration/selection_held_spec.lua | 2 +- tests/selection_test.lua | 40 +++++++++++++++---- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/.agents/skills/verify-claudecode-nvim/features/selection.md b/.agents/skills/verify-claudecode-nvim/features/selection.md index 81134843a..cd954c225 100644 --- a/.agents/skills/verify-claudecode-nvim/features/selection.md +++ b/.agents/skills/verify-claudecode-nvim/features/selection.md @@ -36,7 +36,7 @@ Steps: ## Gotchas -- Leaving visual mode keeps the last visual selection until the user moves the cursor, starts another selection, lets an operator consume it, edits its text, or goes to another file. A visit to the Claude terminal keeps it, unless the file's text changes meanwhile (Neovim reloads it, or a plugin edits it): the cursor of that file goes out at once. `getCurrentSelection` still returns it with `isEmpty: false`. Move the cursor before you test the empty state. +- Leaving visual mode keeps the last visual selection until the user moves the cursor, starts another selection, lets an operator consume it, edits its text, or goes to another file. A visit to the Claude terminal keeps it, and `getCurrentSelection` still returns it with `isEmpty: false`, unless the selected text changes or moves meanwhile (Neovim reloads the file, or a plugin edits it): then the cursor of that file goes out at once. Move the cursor before you test the empty state. - While tracking is on, `getCurrentSelection` and `getLatestSelection` both return the selection Neovim last pushed. `getCurrentSelection` only adds `"success":true`. Neither reads the editor again, so during the debounce both return the older selection. Three events do not wait for the debounce, and each pushes at once: leaving visual mode, editing the held text or consuming it with an operator, and moving the cursor away from a held selection. Entering another file (`:b`, a window switch) waits for the debounce: measured about 100 ms. - Most notifications are debounced; the three events above are not. Wait for the mode or cursor state with `$V until`, then allow about one second before you read `ws-in.jsonl`. - Lines and characters are 0-based in every payload. `:3` in Neovim is `line: 2`. diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 49a9dc6b2..275071a7d 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -136,7 +136,7 @@ file lua/claudecode/lockfile.lua 2746b4c6e0b2b630 == lua/claudecode/logger.lua file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua -file lua/claudecode/selection.lua eb1a7cec47115a17 +file lua/claudecode/selection.lua 3eafdce5ac55e7a1 == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 3454ff62e057b632 block create_client 647b7523dea26af6 diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index d81feabcf..dd1572460 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -134,8 +134,8 @@ end ---Listens for changes to the buffer of the selection that Claude holds, for as long as it holds one. Neovim fires ---TextChanged only for the current buffer, so this is the only way to see a reload or a plugin's edit of a buffer ----while you are in the Claude terminal. A reload detaches the listener (on_detach), so it is attached again when the ----buffer still holds the selection. +---while you are in the Claude terminal. `:checktime` and autoread reload a buffer and keep the listener (on_reload). +---`:edit` and `:edit!` detach it (on_detach), so it is attached again when the buffer still holds the selection. ---@param bufnr integer local function watch_selection_buffer(bufnr) if M.state.watched_buffers[bufnr] then @@ -143,12 +143,8 @@ local function watch_selection_buffer(bufnr) end M.state.watched_buffers[bufnr] = true local check_scheduled = false - local function on_changed(_, buf) - if not holds_selection_of(buf) then - M.state.watched_buffers[buf] = nil - return true - end - -- Do not touch the buffer here: it is being changed. One check after a burst of changes (a :%s) is enough. + -- Do not touch the buffer in a callback: it is being changed. One check after a burst of changes (a :%s) is enough. + local function check_soon() if not check_scheduled then check_scheduled = true vim.schedule(function() @@ -158,8 +154,19 @@ local function watch_selection_buffer(bufnr) end end vim.api.nvim_buf_attach(bufnr, false, { - on_lines = on_changed, - on_reload = on_changed, + on_lines = function(_, buf) + if not holds_selection_of(buf) then + M.state.watched_buffers[buf] = nil + return true -- detach + end + check_soon() + end, + -- Neovim ignores what on_reload returns and keeps the listener, so only on_lines can detach it. + on_reload = function(_, buf) + if holds_selection_of(buf) then + check_soon() + end + end, on_detach = function(_, buf) M.state.watched_buffers[buf] = nil vim.schedule(function() diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 6b4fded11..0f931347a 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -225,7 +225,7 @@ describe("a held selection with the real plugin", function() assert.are.same({ "alpha beta gamma, and more" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) assert.are.equal("alpha", selection.get_latest_selection().text) - -- The reload detached the listener: the next change is seen too. + -- The listener survives the reload: the next change is seen too. vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) wait(300) assert.is_true(selection.get_latest_selection().selection.isEmpty) diff --git a/tests/selection_test.lua b/tests/selection_test.lua index fb8620132..ed77001ed 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2043,15 +2043,16 @@ describe("Range Selection Tests", function() fire_autocmd("CursorMoved") end - -- What Neovim does for nvim_buf_attach: runs the callback of each listener on the buffer, and drops one that - -- returns true. A reload (on_detach) drops them all. + -- What Neovim does for nvim_buf_attach: runs the callback of each listener on the buffer. on_lines drops a + -- listener whose callback returns true. on_reload (:checktime, autoread) ignores what the callback returns and + -- keeps the listener. on_detach (:edit, :edit!, :bunload) means every listener is gone. local function fire_buffer_listeners(bufnr, name) local listeners = _G.vim._buf_listeners[bufnr] or {} _G.vim._buf_listeners[bufnr] = {} for _, listener in ipairs(listeners) do -- A callback can attach a new listener, so the list is a fresh one and each callback runs once. local detach = listener[name](nil, bufnr) - if name ~= "on_detach" and detach ~= true then + if name == "on_reload" or (name == "on_lines" and detach ~= true) then table.insert(_G.vim._buf_listeners[bufnr], listener) end end @@ -2593,17 +2594,17 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) - it("listens again after a reload detaches the listener, and sees the change of that reload", function() + it("listens again after :edit! detaches the listener, and sees the change of that reload", function() hold_alpha_and_enter_the_terminal() ticks[1] = 6 _G.vim.test.add_buffer(1, "/path/to/a.txt", { "beta gamma" }) - fire_buffer_listeners(1, "on_detach") -- the reload + fire_buffer_listeners(1, "on_detach") -- :edit! assert_claude_holds_cursor("/path/to/a.txt", 4) end) - it("listens again after a reload that left the text in place", function() + it("listens again after :edit! that left the text in place", function() hold_alpha_and_enter_the_terminal() ticks[1] = 6 @@ -2615,7 +2616,7 @@ describe("Range Selection Tests", function() assert_claude_holds_cursor("/path/to/a.txt", 4) end) - it("does nothing for the buffer you are in: TextChanged handles that one", function() + it("reports a change of the buffer you are in at once, before TextChanged", function() selection.enable(mock_server) ticks = { [1] = 5 } _G.vim.api.nvim_buf_get_changedtick = function(bufnr) @@ -2711,6 +2712,31 @@ describe("Range Selection Tests", function() assert_claude_holds_cursor("/path/to/a.txt", 4) end) + it("sees the change of a reload (:checktime), which keeps the listener", function() + hold_alpha_and_enter_the_terminal() + + ticks[1] = 6 + _G.vim.test.add_buffer(1, "/path/to/a.txt", { "beta gamma" }) + fire_buffer_listeners(1, "on_reload") + + assert_claude_holds_cursor("/path/to/a.txt", 4) + assert.are.equal(1, #_G.vim._buf_listeners[1]) + end) + + -- on_reload cannot detach a listener: if it forgot the buffer, the next read would attach a second one. + it("keeps one listener when a reload comes while Claude holds only a cursor", function() + hold_alpha_and_enter_the_terminal() + change_buffer_1({ "beta gamma" }) -- Claude holds the cursor now + fire_buffer_listeners(1, "on_reload") + assert.are.equal(1, #_G.vim._buf_listeners[1]) + + go_to_buffer(1, 0) + start_selecting(6) + release_selection(1, 4, 3) -- Claude holds a selection of buffer 1 again + + assert.are.equal(1, #_G.vim._buf_listeners[1]) + end) + it("stops listening when Claude no longer holds a selection", function() hold_alpha_and_enter_the_terminal() change_buffer_1({ "beta gamma" }) -- Claude holds the cursor now From db3b731d26dd1776de6f1a63ed6f7696c6344617 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 21:18:26 +0000 Subject: [PATCH 08/11] fix(selection): put the fallback cursor on the first byte of a multibyte character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 3 (P2): when no window shows the selection's buffer, the release position is kept inside the new text, and its byte column can fall inside a multibyte character. Snap it with code_point_start, which moves up next to the code that uses it first. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ --- .../skills/verify-diff-lifecycle/covered.lock | 2 +- lua/claudecode/selection.lua | 48 ++++++++++--------- tests/selection_test.lua | 9 ++++ 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 275071a7d..144b3cccb 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -136,7 +136,7 @@ file lua/claudecode/lockfile.lua 2746b4c6e0b2b630 == lua/claudecode/logger.lua file lua/claudecode/logger.lua 17b404dda41c3591 == lua/claudecode/selection.lua -file lua/claudecode/selection.lua 3eafdce5ac55e7a1 +file lua/claudecode/selection.lua 05663913bc425259 == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 3454ff62e057b632 block create_client 647b7523dea26af6 diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index dd1572460..3a9b6a14c 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -65,6 +65,28 @@ local function selection_text_is_there(held, bufnr) return ok and utils.valid_utf8(table.concat(lines, "\n")) == held.text end +---Returns the byte index of the first byte of the code point that contains byte col of line. A mark keeps its +---byte column when text before it on the line changes, so `gv` can put the anchor inside a multibyte character. +---A continuation byte outside a well-formed sequence is a character of its own in Neovim and keeps its column. +---@param line string +---@param col integer +---@return integer +local function code_point_start(line, col) + local byte = line:byte(col) + if not byte or byte < 0x80 or byte > 0xBF then + return col + end + -- A code point has at most three continuation bytes (0x80-0xBF) after its first byte. + for lead = col - 1, math.max(col - 3, 1), -1 do + byte = line:byte(lead) + if byte < 0x80 or byte > 0xBF then + local length = utils.utf8_sequence_length(line, lead) + return (length and lead + length > col) and lead or col + end + end + return col +end + ---The cursor of a buffer that is not the current one: where a window shows it, else where the selection was ---released, kept inside the text that is there now. ---@param bufnr integer @@ -79,7 +101,9 @@ local function cursor_in_other_buffer(bufnr) local pos = caf and caf.bufnr == bufnr and caf.pos or { 1, 0 } local row = math.max(1, math.min(pos[1], vim.api.nvim_buf_line_count(bufnr))) local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or "" - return cursor_selection(bufnr, row, math.min(pos[2], #line)) + -- The text changed, so the byte column can fall inside a multibyte character: a cursor is on its first byte. + local col = code_point_start(line, math.min(pos[2], #line) + 1) - 1 + return cursor_selection(bufnr, row, col) end ---Reports the cursor at once if the buffer's text changed since the visual selection in `latest_selection` was read, @@ -700,28 +724,6 @@ local function get_selection_coordinates() end end ----Returns the byte index of the first byte of the code point that contains byte col of line. A mark keeps its ----byte column when text before it on the line changes, so `gv` can put the anchor inside a multibyte character. ----A continuation byte outside a well-formed sequence is a character of its own in Neovim and keeps its column. ----@param line string ----@param col integer ----@return integer -local function code_point_start(line, col) - local byte = line:byte(col) - if not byte or byte < 0x80 or byte > 0xBF then - return col - end - -- A code point has at most three continuation bytes (0x80-0xBF) after its first byte. - for lead = col - 1, math.max(col - 3, 1), -1 do - byte = line:byte(lead) - if byte < 0x80 or byte > 0xBF then - local length = utils.utf8_sequence_length(line, lead) - return (length and lead + length > col) and lead or col - end - end - return col -end - ---Returns the byte index of the last selected byte on the last line of a charwise or blockwise selection. ---Cutting inside a multibyte character would put invalid UTF-8 into selection_changed, so the selection takes ---the whole end character. With 'selection' set to exclusive, a selection of more than one position ends after diff --git a/tests/selection_test.lua b/tests/selection_test.lua index ed77001ed..30540ba13 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2574,6 +2574,15 @@ describe("Range Selection Tests", function() assert_claude_holds_cursor("/path/to/a.txt", 2) end) + it("puts the release position on the first byte of a multibyte character", function() + hold_alpha_and_enter_the_terminal() + selection.state.cursor_at_flush.pos = { 1, 1 } + + change_buffer_1({ "é" }) -- two bytes: column 1 is its continuation byte + + assert_claude_holds_cursor("/path/to/a.txt", 0) + end) + it("keeps the release position inside the lines that are there", function() hold_alpha_and_enter_the_terminal() _G.vim.test.add_buffer(1, "/path/to/a.txt", { "alpha beta gamma", "second", "third" }) From 6eba6a5b866f7560f8a7cf6de4cd101f7233372e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 01:38:02 +0000 Subject: [PATCH 09/11] wip: F1 tests and docs --- CLAUDE.md | 2 +- README.md | 4 ++- lua/claudecode/README.md | 1 + tests/integration/selection_held_spec.lua | 33 +++++++++++++++++++++++ tests/selection_test.lua | 20 ++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5caf3670..810bef08b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -350,7 +350,7 @@ Log levels for authentication events: ### Performance Optimizations -- Selection tracking is debounced (100 ms) to reduce overhead. Leaving visual mode, editing or consuming the held text, and moving the cursor away from a held selection report at once +- Selection tracking is debounced (100 ms) to reduce overhead. Leaving visual mode, editing or consuming the held text, and moving the cursor away from a held selection report at once. So does a change to the held text while you are in the Claude terminal (a reload, a plugin's edit): a listener from `nvim_buf_attach` on its buffer sees it, because `TextChanged` fires only for the current buffer - WebSocket frame processing optimized for JSON-RPC payload sizes - Connection pooling and cleanup to prevent resource leaks diff --git a/README.md b/README.md index 20159e95a..d1cd8591e 100644 --- a/README.md +++ b/README.md @@ -1144,7 +1144,9 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ an error that names both directories [lua/claudecode/diff.lua:create_temp_file]. The `openDiff` tool writes no temp files [lua/claudecode/diff.lua:M.open_diff_blocking]. - Editor state: autocmd groups (`ClaudeCodeShutdown`, `ClaudeCodeSelection`, `ClaudeCodeMCPDiff`, - `ClaudeCodeDiffCleanup`), `User` events (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, `ClaudeCodeDiffClosed`), + `ClaudeCodeDiffCleanup`), `nvim_buf_attach` change listeners on the buffer of a held selection + [lua/claudecode/selection.lua:watch_selection_buffer], `User` events (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, + `ClaudeCodeDiffClosed`), and a wrapped global `vim.paste` when `terminal.fix_streamed_paste` is `true`, or `"auto"` on Neovim < 0.12.2 [lua/claudecode/terminal/paste_fix.lua:M.apply] [lua/claudecode/terminal/paste_fix.lua:M.install]. - Release tooling: `scripts/release-please-runner.js` without `--dry-run` creates GitHub tags, releases, and PRs diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index f4237bf12..6bd126216 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -157,6 +157,7 @@ Module map (direct files): - The terminal env gets `ENABLE_IDE_INTEGRATION=true`, `FORCE_CODE_TERMINAL=true`, `CLAUDE_CODE_SSE_PORT=` only while the server runs, the user `env` (merged last, so it can override these), and the merged `no_proxy`/`NO_PROXY` [terminal.lua:get_claude_command_and_env] - `terminal.setup` can replace the global `vim.paste` through `paste_fix.apply` [terminal.lua:M.setup] - The plugin broadcasts `at_mentioned` (`filePath`, `lineStart`, `lineEnd`) [init.lua:M._broadcast_at_mention] and `selection_changed` [selection.lua:M.send_selection_update] to every connected client +- While Claude holds a visual selection, `selection.lua` attaches an `nvim_buf_attach` listener (`on_lines`, `on_reload`, `on_detach`) to its buffer, once per buffer. It detaches itself at the next `on_lines` after Claude holds no selection of that buffer, and a reload with `:edit` or `:edit!` detaches it, so it attaches again [selection.lua:watch_selection_buffer] - Augroups: `ClaudeCodeShutdown`, which holds the `VimLeavePre` autocmd that stops on exit [init.lua:M.setup], `ClaudeCodeSelection` [selection.lua:M._create_autocommands], `ClaudeCodeMCPDiff` [diff.lua:get_autocmd_group], `ClaudeCodeDiffCleanup` [diff.lua:M._open_native_diff], and a module-level `VimLeavePre` autocmd in `diff.lua` that calls `M._cleanup_all_active_diffs("shutdown")` [diff.lua:M._cleanup_all_active_diffs] - Diffs create scratch buffers, splits, and tabs. They resize the terminal window and fire `User` events. Cleanup closes these and reloads unchanged file buffers 100 ms after a save [diff.lua:M.close_diff_by_tab_name] - The native diff writes each temp file into its own session folder under `stdpath("cache")/claudecode_diffs`, or `claudecode_diffs_fallback` when it cannot create a directory in the first. The folder name includes the Neovim PID, so two running Neovim processes never share one. If both fail, `open_diff` returns `success = false` with an error that names both directories [diff.lua:create_temp_file]. A `BufDelete`/`BufWipeout` autocmd on the proposed buffer cleans up once, even when a wipe fires both events. It deletes the file and then its empty folder with `vim.fn.delete`, and warns once for each delete that fails. When the file delete fails, the folder is not empty, so its delete fails too and both warnings show [diff.lua:cleanup_temp_file]. `_cleanup_diff_state` does not [diff.lua:M._open_native_diff] diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 0f931347a..43b38e6e1 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -204,6 +204,39 @@ describe("a held selection with the real plugin", function() assert.are.equal(4, held.selection.start.character) end) + -- :bunload! detaches the listener, and nvim_buf_attach cannot attach to an unloaded buffer. When the file is loaded + -- again and selected from, a new listener must be attached. + it("listens again when the file was unloaded and a new selection is read after it loads again", function() + start({ provider = "none" }, 50) + open_file("alpha beta gamma") + local file_buf = vim.api.nvim_get_current_buf() + vim.cmd("terminal sh -c 'sleep 60' claude") + local term_buf = vim.api.nvim_get_current_buf() + vim.cmd("stopinsert") + vim.cmd("buffer " .. file_buf) + wait(200) + select_and_release() + + vim.cmd("buffer " .. term_buf) + vim.cmd("stopinsert") + wait(300) + vim.cmd("bunload! " .. file_buf) + wait(200) + vim.cmd("buffer " .. file_buf) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- Neovim put the cursor back where it was + wait(200) + select_and_release() + vim.cmd("buffer " .. term_buf) + vim.cmd("stopinsert") + wait(300) + + vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " + wait(300) + + assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + end) + it("keeps the selection when a reload leaves its text in place", function() start({ provider = "none" }, 50) open_file("alpha beta gamma") diff --git a/tests/selection_test.lua b/tests/selection_test.lua index 30540ba13..dd2ae8318 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -130,6 +130,9 @@ if not _G.vim then end, nvim_buf_attach = function(bufnr, _send_buffer, callbacks) + if not _G.vim._buffers[bufnr] then + return false -- Neovim cannot attach to an unloaded buffer, and does not raise + end _G.vim._buf_listeners[bufnr] = _G.vim._buf_listeners[bufnr] or {} table.insert(_G.vim._buf_listeners[bufnr], callbacks) return true @@ -2765,6 +2768,23 @@ describe("Range Selection Tests", function() assert.are.equal(0, #_G.vim._buf_listeners[1]) end) + -- nvim_buf_attach returns false for an unloaded buffer. A watched flag set before that would stay set, and the + -- next read of the buffer, after it is loaded again, would attach no listener. + it("listens again after the buffer was unloaded and loaded again", function() + hold_alpha_and_enter_the_terminal() + local lines = _G.vim._buffers[1].lines + _G.vim._buffers[1] = nil -- :bunload! + + fire_buffer_listeners(1, "on_detach") + + assert.is_nil(selection.state.watched_buffers[1]) + _G.vim.test.add_buffer(1, "/path/to/a.txt", lines) -- :buffer 1 loads it again + go_to_buffer(1, 0) + start_selecting(6) + release_selection(1, 5, 4) + assert.are.equal(1, #_G.vim._buf_listeners[1]) + end) + it("does not read a buffer that was unloaded", function() hold_alpha_and_enter_the_terminal() _G.vim._buffers[1] = nil -- :bunload From 7f48a9da23b75cc180e042426f87f50f0b76eed1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 01:42:11 +0000 Subject: [PATCH 10/11] wip: F3 integration test --- tests/integration/selection_held_spec.lua | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 43b38e6e1..4b1fbe1cf 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -237,6 +237,38 @@ describe("a held selection with the real plugin", function() assert.is_true(selection.get_latest_selection().selection.isEmpty) end) + -- Neovim ignores what on_reload returns and keeps the listener. A listener that forgot its buffer there would let + -- the next selection attach a second one, one more after every reload. + it("keeps one listener on the file through reloads while Claude holds only a cursor", function() + start({ provider = "none" }, 50) + open_file("alpha beta gamma") + local file_buf = vim.api.nvim_get_current_buf() + local attached = 0 + local real_attach = vim.api.nvim_buf_attach + vim.api.nvim_buf_attach = function(buf, ...) + attached = attached + (buf == file_buf and 1 or 0) + return real_attach(buf, ...) + end + + local ok, err = pcall(function() + for cycle = 1, 3 do + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + select_and_release() + vim.cmd("enew") -- another file: the debounce replaces the selection with that buffer's cursor + wait(300) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, cycle " .. cycle) -- on_reload, nothing held + wait(200) + vim.cmd("buffer " .. file_buf) + wait(200) + end + end) + vim.api.nvim_buf_attach = real_attach + assert(ok, err) + + assert.are.equal(1, attached) + end) + it("keeps the selection when a reload leaves its text in place", function() start({ provider = "none" }, 50) open_file("alpha beta gamma") From 117afc45b876c97c0fd407854a066fb2963edc49 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 01:55:17 +0000 Subject: [PATCH 11/11] test: make the nvim_buf_attach on_detach mock match Neovim --- tests/selection_test.lua | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/selection_test.lua b/tests/selection_test.lua index dd2ae8318..c5a5095bc 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -2048,9 +2048,34 @@ describe("Range Selection Tests", function() -- What Neovim does for nvim_buf_attach: runs the callback of each listener on the buffer. on_lines drops a -- listener whose callback returns true. on_reload (:checktime, autoread) ignores what the callback returns and - -- keeps the listener. on_detach (:edit, :edit!, :bunload) means every listener is gone. + -- keeps the listener. on_detach (:edit, :edit!, :bunload) detaches the listeners one by one and calls each + -- on_detach while the others are still attached, so a listener that a callback attaches from inside on_detach + -- is detached in turn, and one that attaches again each time loops for ever. vim.schedule runs later, as in + -- Neovim. local function fire_buffer_listeners(bufnr, name) local listeners = _G.vim._buf_listeners[bufnr] or {} + if name == "on_detach" then + local real_schedule = _G.vim.schedule + local queued = {} + _G.vim.schedule = function(fn) + table.insert(queued, fn) + end + local calls = 0 + local ok, err = pcall(function() + while #listeners > 0 do + calls = calls + 1 + assert(calls <= 20, "on_detach loops: a listener was attached from inside on_detach") + local listener = table.remove(listeners, 1) + listener.on_detach(nil, bufnr) + end + end) + _G.vim.schedule = real_schedule + assert(ok, err) + for _, fn in ipairs(queued) do + fn() + end + return + end _G.vim._buf_listeners[bufnr] = {} for _, listener in ipairs(listeners) do -- A callback can attach a new listener, so the list is a fresh one and each callback runs once.