diff --git a/.agents/skills/verify-claudecode-nvim/features/selection.md b/.agents/skills/verify-claudecode-nvim/features/selection.md index d9b487051..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. `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 a556281cd..f2f0776e3 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -137,7 +137,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 05663913bc425259 == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua 5789c6ff18045f9f 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/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 37e0c1c17..6bd126216 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] @@ -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/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index db0347f06..3a9b6a14c 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,54 @@ 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 +---@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 "" + -- 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, ---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 +121,95 @@ 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 selection_text_is_there(held, current_buf) then + if from_terminal and not vim.api.nvim_buf_is_loaded(read.bufnr) then return false end + 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. 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 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. `: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 + return + end + M.state.watched_buffers[bufnr] = true + local check_scheduled = false + -- 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() + check_scheduled = false + demote_if_selection_text_changed() + end) + end + end + vim.api.nvim_buf_attach(bufnr, false, { + 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() + 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 +275,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). @@ -336,9 +480,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 @@ -358,7 +504,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 @@ -578,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 @@ -918,7 +1042,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 +1066,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..4b1fbe1cf 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -137,6 +137,166 @@ 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) + + -- :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) + + -- 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") + 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 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) + 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..c5a5095bc 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,19 @@ 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) + 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 + end, + nvim_get_current_win = function() return 1 end, @@ -221,6 +236,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 +2046,46 @@ 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. 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) 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. + local detach = listener[name](nil, bufnr) + if name == "on_reload" or (name == "on_lines" 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 +2108,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 +2541,288 @@ 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("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" }) + 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 :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") -- :edit! + + assert_claude_holds_cursor("/path/to/a.txt", 4) + end) + + it("listens again after :edit! 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("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) + 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("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) + + -- 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 + 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("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 + + 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) + + -- 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 + + 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()