diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 00573853f..bbe2b7fa0 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 349b9bb73c6f64c5 +file lua/claudecode/selection.lua cf8e6464c4302b2f == lua/claudecode/server/client.lua file lua/claudecode/server/client.lua b8165df16f194471 block create_client 24fa2a266b76e6ad diff --git a/.agents/skills/verify-selection-tracking/SKILL.md b/.agents/skills/verify-selection-tracking/SKILL.md index b664c6ee0..fa66be67b 100644 --- a/.agents/skills/verify-selection-tracking/SKILL.md +++ b/.agents/skills/verify-selection-tracking/SKILL.md @@ -126,7 +126,7 @@ Then decide which kind of failure it is: ## Replays -Each probe in `replays/` starts the plugin through the real `require("claudecode").start()` with `terminal.provider = "none"`, then types keys through Neovim's main loop with real timers. It replaces only `server.broadcast`, in its own process, to record each `selection_changed`. It prints one line per step, the settled state, `OK` or `BUG`, and a count line, and Neovim exits 0 for `OK` and 1 for `BUG`. Each probe replays a path that once broke a rule (see "Bugs this model found"), or a limit that the model does not cover (the rows whose rule is `none`), so `OK` shows the promise kept or the limit as documented. Three probes replay known bugs and print `BUG` until their fix lands (see "Scope and bounds"). A probe does not prove that one guard is needed on its own: the listener also covers for `ReconcileOnTextChanged` and `TextChangeReports` at runtime (see "Read the result"). +Each probe in `replays/` starts the plugin through the real `require("claudecode").start()` with `terminal.provider = "none"`, then types keys through Neovim's main loop with real timers. It replaces only `server.broadcast`, in its own process, to record each `selection_changed`. It prints one line per step, the settled state, `OK` or `BUG`, and a count line, and Neovim exits 0 for `OK` and 1 for `BUG`. Each probe replays a path that once broke a rule (see "Bugs this model found"), or a limit that the model does not cover (the rows whose rule is `none`), so `OK` shows the promise kept or the limit as documented. A probe does not prove that one guard is needed on its own: the listener also covers for `ReconcileOnTextChanged` and `TextChangeReports` at runtime (see "Read the result"). ```bash NVIM=~/.local/share/mise/installs/neovim/0.12.3/bin/nvim \ @@ -135,24 +135,24 @@ NVIM=~/.local/share/mise/installs/neovim/0.12.3/bin/nvim \ `run.sh` puts this checkout first on `runtimepath` and gives each run a fresh `XDG_*_HOME`, `CLAUDE_CONFIG_DIR` and `TMPDIR` under `$XUM_SCRATCH_DIR` (or `/tmp`), removed afterwards. `DELAY_MS=50` reruns the two long-delay probes (`other_file_after_return.lua`, `cursor_after_return.lua`) with the default delay. They use 1000 ms. -| Probe | Keys | Rule | -| ---------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | -| `held_after_operator.lua` | `vb`, Esc, `gv`, `d` | `HeldEndsWhenMoved`, `NoDeletedText` | -| `held_after_quick_move.lua` | `ve`, Esc, `l`, then `h` 30 ms later | `HeldEndsWhenMoved` | -| `held_after_edit.lua` | `viw`, Esc, `x`, `x` | `NoDeletedText` | -| `held_after_plugin_edit.lua` | `Vj`, Esc, a plugin deletes text of line 1 | `NoDeletedText` | -| `held_after_reload_in_terminal.lua` | `ve`, Esc, the Claude terminal, Neovim reloads the rewritten file | `NoDeletedText` | -| `held_after_edit_in_terminal.lua` | `ve`, Esc, the Claude terminal, a plugin deletes the selected text | `NoDeletedText` | -| `other_file_after_return.lua` | `ve`, Esc, `:b b.txt`, `:b a.txt`, a long delay | `SameFile` | -| `cursor_after_return.lua` | `ve`, Esc, `l`, `h` 250 ms later, a long delay | `ClaudeSeesCursor` | -| `held_after_line_inserted_above.lua` | `ve`, Esc, a plugin inserts a line above the selection | none: a limit that the model skips | -| `release_after_plugin_edit_in_visual.lua` | `ve`, a plugin edits line 2, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | -| `release_after_plugin_append_in_visual.lua` | `ve`, a plugin appends two lines, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | -| `release_after_write_in_visual.lua` | a modified buffer, `ve`, `:write`, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | -| `release_in_same_typeahead_as_plugin_edit.lua` | `ve`, then one mapping edits line 2 and presses Esc | `ReleaseSent`: a known bug, `BUG` until its fix lands | -| `release_with_key_during_plugin_callback.lua` | `ve`, a timer callback edits line 2 and `nvim_input` sends Esc during it | `ReleaseSent`: a known bug, `BUG` until its fix lands | -| `release_after_autocmd_edit_in_visual.lua` | `ve` in one typeahead, a `ModeChanged` autocmd for `n:v` edits line 2 | `ReleaseSent`: a known bug, `BUG` until its fix lands | -| `v_escape_selects_one_character.lua` | `ve`, Esc, then `v` and Esc with no motion | none: shows a bare `v` Esc selects | +| Probe | Keys | Rule | +| ---------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------ | +| `held_after_operator.lua` | `vb`, Esc, `gv`, `d` | `HeldEndsWhenMoved`, `NoDeletedText` | +| `held_after_quick_move.lua` | `ve`, Esc, `l`, then `h` 30 ms later | `HeldEndsWhenMoved` | +| `held_after_edit.lua` | `viw`, Esc, `x`, `x` | `NoDeletedText` | +| `held_after_plugin_edit.lua` | `Vj`, Esc, a plugin deletes text of line 1 | `NoDeletedText` | +| `held_after_reload_in_terminal.lua` | `ve`, Esc, the Claude terminal, Neovim reloads the rewritten file | `NoDeletedText` | +| `held_after_edit_in_terminal.lua` | `ve`, Esc, the Claude terminal, a plugin deletes the selected text | `NoDeletedText` | +| `other_file_after_return.lua` | `ve`, Esc, `:b b.txt`, `:b a.txt`, a long delay | `SameFile` | +| `cursor_after_return.lua` | `ve`, Esc, `l`, `h` 250 ms later, a long delay | `ClaudeSeesCursor` | +| `held_after_line_inserted_above.lua` | `ve`, Esc, a plugin inserts a line above the selection | none: a limit that the model skips | +| `release_after_plugin_edit_in_visual.lua` | `ve`, a plugin edits line 2, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | +| `release_after_plugin_append_in_visual.lua` | `ve`, a plugin appends two lines, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | +| `release_after_write_in_visual.lua` | a modified buffer, `ve`, `:write`, Esc within 100 ms | `ReleaseSent`, `HeldUntilMoved` | +| `release_in_same_typeahead_as_plugin_edit.lua` | `ve`, then one mapping edits line 2 and presses Esc | `ReleaseSent`, `HeldUntilMoved` | +| `release_with_key_during_plugin_callback.lua` | `ve`, a timer callback edits line 2 and `nvim_input` sends Esc during it | `ReleaseSent`, `HeldUntilMoved` | +| `release_after_autocmd_edit_in_visual.lua` | `ve` in one typeahead, a `ModeChanged` autocmd for `n:v` edits line 2 | `ReleaseSent`, `HeldUntilMoved` | +| `v_escape_selects_one_character.lua` | `ve`, Esc, then `v` and Esc with no motion | none: shows a bare `v` Esc selects | ## Bugs this model found @@ -169,7 +169,7 @@ A review of #452 found one more path, and #471 fixed it: Claude holds selection A reviewer of this skill found one more path that no model step could express then, and #481 fixed it: Claude holds selection A, you go to the Claude terminal, and A's file changes while you are there (Claude rewrites it and Neovim reloads it, or a plugin edits it). `TextChanged` fires only for the current buffer, so Claude kept A's old text until you came back (`replays/held_after_reload_in_terminal.lua`, `replays/held_after_edit_in_terminal.lua`). The model has a step for it (`YouEditOtherBuffer`) and three guards: `ListensToSelectionBuffer`, `TerminalReportsHeldBuffer` and `UpdateChecksHeldText`. The model found the last one in the first version of that fix: a file that changes while you are in another file, just before you open a new Claude terminal, finds the terminal unregistered at `BufEnter`, so only the debounce can send the cursor. -An advisor of this skill found a last path that the model could not express, and #524 fixed it: you select, a plugin edits another line (or a `:write` runs) while you are still in visual mode, and you press Esc inside the 100 ms debounce. Entering visual mode had recorded the buffer's changedtick, and the flush took a moved changedtick for an operator that consumed the selection, so Claude got the cursor and not the selection, although its text was untouched (`replays/release_after_plugin_edit_in_visual.lua`, `release_after_plugin_append_in_visual.lua`, `release_after_write_in_visual.lua`). #524 replaced the comparison with a `visual_entry` that the buffer listener marks: an operator's edit counts and ends in a skipped flush, a plugin's edit is checked once visual mode is known to be on, and a `:write` fires no callback. The model has two steps that move only the changedtick (`YouEditByPluginInVisual`, `YouWriteInVisual`) and three guards for the new code (`FlushSkipsOperator`, `ListenerNotesVisualEdit`, `VisualCheckSparesPluginEdit`). `FlushIgnoresTick` keeps the old comparison out: put it back into `selection.lua` and `check.sh` exits 1 and prints the first broken rule, `ReleaseSent` (you select, a plugin edits another line, you press Esc). `--mutants` lists the others that break with it: `HeldUntilMoved` and `HeldInTerminal`. Paths of the same kind are still open: any edit whose check has not run when the next key arrives. Three are reproduced (`replays/release_in_same_typeahead_as_plugin_edit.lua`, `release_with_key_during_plugin_callback.lua` and `release_after_autocmd_edit_in_visual.lua` print `BUG`; see "Scope and bounds"). Fix PR #540 fixes all three. +An advisor of this skill found a last path that the model could not express, and #524 fixed it: you select, a plugin edits another line (or a `:write` runs) while you are still in visual mode, and you press Esc inside the 100 ms debounce. Entering visual mode had recorded the buffer's changedtick, and the flush took a moved changedtick for an operator that consumed the selection, so Claude got the cursor and not the selection, although its text was untouched (`replays/release_after_plugin_edit_in_visual.lua`, `release_after_plugin_append_in_visual.lua`, `release_after_write_in_visual.lua`). #524 replaced the comparison with a `visual_entry` that the buffer listener marks: an operator's edit counts and ends in a skipped flush, a plugin's edit is checked once visual mode is known to be on, and a `:write` fires no callback. The model has two steps that move only the changedtick (`YouEditByPluginInVisual`, `YouWriteInVisual`) and three guards for the new code (`FlushSkipsOperator`, `ListenerNotesVisualEdit`, `VisualCheckSparesPluginEdit`). `FlushIgnoresTick` keeps the old comparison out: put it back into `selection.lua` and `check.sh` exits 1 and prints the first broken rule, `ReleaseSent` (you select, a plugin edits another line, you press Esc). `--mutants` lists the others that break with it: `HeldUntilMoved` and `HeldInTerminal`. #540 fixed three more paths of the same kind, in which the check of an edit had not run when the next key arrived: a mapping that edits and presses Esc in one typeahead, a key that arrives while a callback runs (the plugin's own, or another one that was queued before the check), and an autocmd that edits when visual mode starts with the Esc already in the typeahead (`replays/release_in_same_typeahead_as_plugin_edit.lua`, `release_with_key_during_plugin_callback.lua` and `release_after_autocmd_edit_in_visual.lua`). A plugin's edit now has its check run from `vim.schedule` and from a `vim.on_key` listener, whichever comes first, and `VisualCheckSparesPluginEdit` reads both. The three replays print `BUG` on the code before #540. ## Update after an intended change @@ -183,7 +183,7 @@ An advisor of this skill found a last path that the model could not express, and ## Scope and bounds - The main run uses 2 files, the Claude terminal, 2 cursor positions, 2 selection ranges and 1 edit per file, any `visual_demotion_delay_ms` (129,986 distinct states). The `--mutants` runs use 1 file, except `SameFile`, `HeldEndsWhenMoved` and `NoDeletedText` with 2. -- One model step is one key or command together with the autocmds it fires (`ModeChanged`, `TextChanged`, `CursorMoved`, `BufEnter`). They run in the same main-loop iteration as the key, before Neovim polls libuv again, so no timer callback runs between them. A window command such as `w` in visual mode fires `ModeChanged` in the old buffer first (checked on Neovim 0.12.3), so the model splits it into Esc and a buffer switch. Each uv timer firing and each scheduled callback is its own step, the listener's queued check included (`ListenerCheckRuns`). The one exception is the check of an edit that you make while you select: the model runs it in the step of the edit, which is true only when it runs before the next key (see the bullet on edits in visual mode). The model can fire a timer between any two keys, which also covers typeahead. Typed one key at a time, each key fires its autocmds before the next. Keys that run with no main-loop turn between them (a mapping, a macro, `:normal!`, typeahead) fire `CursorMoved` and `TextChanged` later or not at all: #471 was such a path. `YouEditSelection` leaves both out, so it is the case in which they have not fired yet. +- One model step is one key or command together with the autocmds it fires (`ModeChanged`, `TextChanged`, `CursorMoved`, `BufEnter`). They run in the same main-loop iteration as the key, before Neovim polls libuv again, so no timer callback runs between them. A window command such as `w` in visual mode fires `ModeChanged` in the old buffer first (checked on Neovim 0.12.3), so the model splits it into Esc and a buffer switch. Each uv timer firing and each scheduled callback is its own step, the listener's queued check included (`ListenerCheckRuns`). The one exception is the check of a plugin's edit while you select: the model runs it in the step of the edit. The code runs it from `vim.schedule` and from a `vim.on_key` listener, whichever comes first, so it runs before the flush of the next Esc (see the bullet on edits in visual mode). The model can fire a timer between any two keys, which also covers typeahead. Typed one key at a time, each key fires its autocmds before the next. Keys that run with no main-loop turn between them (a mapping, a macro, `:normal!`, typeahead) fire `CursorMoved` and `TextChanged` later or not at all: #471 was such a path. `YouEditSelection` leaves both out, so it is the case in which they have not fired yet. - An operator in visual mode (`d`, `c`, `>`) sets the mode to normal before it changes the text, and `ModeChanged` (`v` to `n`) fires after the change: the listener's `on_lines` runs first, with the mode already normal, and the flush that `ModeChanged` triggers comes second. That order holds for 8 of 8 operators (`d`, `c`, `>`, `gU`, `~`, `J`, `r` and `x`) on Neovim 0.8.0 and 0.12.3, and the mode in `on_lines` is normal for these operators also on 0.9.5, 0.10.4 and 0.11.4. So the edit is counted before the flush. The cursor lands on any position. A plugin's edit in visual mode runs `on_lines` with the mode `n` on 0.8 and 0.9 (Neovim ends visual mode for the length of `nvim_buf_set_lines` and `nvim_buf_set_text`) and `v` from 0.10, so the code tests the mode in a `vim.schedule` callback, after the call. The model has no mode inside a call and runs that check before the next key. - At `BufEnter` the window cursor is not restored yet, so the model compares it only at `CursorMoved` and `TextChanged`. A new Claude terminal is not registered when `BufEnter` fires, so the model lets the plugin not know it yet (`termKnown`) and decide at the next event. - An edit changes the held text (`YouEditSelection`, `YouEditAtCursor`, `YouEditByPlugin`, `YouEditOtherBuffer`) or leaves it alone (`YouEditElsewhere`, and, while you select, `YouEditByPluginInVisual` and `YouWriteInVisual`, which move only the changedtick). The model keeps the changedtick as one bit on the visual entry ("moved since visual mode started"), not as a number per buffer: the code before #524 only compared it with the value recorded at entry, and a changedtick only grows. Each buffer has one text version, so the model cannot tell the text of the held selection from the text of another selection in the same buffer: the #471 path (an operator consumes a different selection) is checked by `tests/integration/selection_held_spec.lua`, and `FlushSkipReports` breaks no rule alone, because the held text is found at the next check anyway (the debounce in the Claude terminal, `UpdateChecksHeldText`, or the next `CursorMoved`). It is caught together with `EntryClearsHeld`. An edit that moves the held range's lines, such as a line added above, is not modeled. The code reads the range again with `nvim_buf_get_text`, and reports the cursor when the held text is not there. A user sees Claude lose the selection and get the cursor on the shifted line, although the selected text still exists (`replays/held_after_line_inserted_above.lua`). That is a limit, not a rule. @@ -200,7 +200,7 @@ An advisor of this skill found a last path that the model could not express, and - `:ClaudeCodeSend` at-mentions: they send a file and range mention, not the tracked selection. A bug there makes Claude get the wrong mention. - Insert mode as distinct from normal mode: `TextChangedI` and `TextChangedP` run the same handler as `TextChanged`. If they stopped doing so, Claude would keep a held selection that you typed over in insert mode. - A held selection whose buffer is unloaded while you are in the Claude terminal (`:bunload`): `demote_if_selection_text_changed` returns for a buffer that is not loaded, so the selection stays held. The model has no unloaded buffer. A user sees Claude keep the selection of a file that is not loaded until they come back, and then get the cursor (checked on Neovim 0.8.0 and 0.12.3 with `:bunload` in a Claude terminal tab and `:buffer` after it). The text on disk did not change, so Claude holds no text that is gone. Without the return, the check could not find the text in the unloaded buffer, would take it for gone, and would send the cursor of a file that is not loaded (read from the code). The listener is detached by the unload (`on_detach`) and attaches itself again only for a loaded buffer. -- Edits while you are in visual mode that the model leaves out: an edit of the selected text itself (the debounce or the listener's check reads the new text), and any edit whose check has not run when the next key arrives. The model runs the check of a plugin's edit in the step of the edit, which is true only when the check comes before the next key. The code runs it in `vim.schedule`, after the command or callback that made the edit returns. When a key comes first, the flush on Esc finds an edit that nobody checked and takes it for an operator's: a user sees Claude get the cursor and not the selection, although its text is untouched. Three ways to get there are reproduced on Neovim 0.8.0 and 0.12.3, and each is a known bug, not a limit by design: a mapping that edits and presses Esc in one typeahead (`replays/release_in_same_typeahead_as_plugin_edit.lua`), a key that arrives while the plugin's timer callback still runs (`replays/release_with_key_during_plugin_callback.lua`, with `nvim_input`, the way a UI sends a key), and an autocmd that edits when visual mode starts with the Esc already in the typeahead (`replays/release_after_autocmd_edit_in_visual.lua`, a `ModeChanged` handler for `n:v`). The three print `BUG` until fix PR #540 merges, and `OK` in a tree with #540. The model does not show them, so a PASS says nothing about them. A plugin edit whose callback ends before the next key is not like this (`replays/release_after_plugin_edit_in_visual.lua`). +- Edits while you are in visual mode that the model leaves out: an edit of the selected text itself (the debounce or the listener's check reads the new text). The model runs the check of a plugin's edit in the step of the edit, and the code guarantees that since #540: `note_visual_edit` runs `check_visual_edits` from `vim.schedule` (after the command or callback that made the edit returns) and from a `vim.on_key` listener (before the next key is processed, also a key of a mapping or one that a UI sends while a callback runs), whichever comes first. The flush on Esc comes after the first of them. When the key came first, the schedule runs later, after the flush, and finds nothing to do, and when the schedule came first the key listener finds nothing. Before #540 only the schedule ran it, so a key that came first reached the flush with an edit that nobody had checked, and the flush took it for an operator's: Claude got the cursor and not the selection, although its text was untouched. Three ways to get there were reproduced on Neovim 0.8.0 and 0.12.3: a mapping that edits and presses Esc in one typeahead, a key that arrives while a callback runs (`nvim_input`, the way a UI sends a key: during the plugin's own callback, or during another one that was queued before the check), and an autocmd that edits when visual mode starts with the Esc already in the typeahead (a `ModeChanged` handler for `n:v`). The three replays print `OK` now and `BUG` on the code before #540. The model has no step in which a key comes before the check, so removing the key listener or the schedule shows only as `VisualCheckSparesPluginEdit` reading `FALSE` (the fact reads both triggers, and the model then breaks `ReleaseSent`). - A reload or `:edit` while you select (`on_reload` and `on_detach` set `consumed`) counts as an operator, because it replaces the text under the selection. It is not modeled: a user sees Claude get the cursor and not the selection after the file was reloaded under it. - Coming back from the Claude terminal to the file of a held selection with the cursor where it was, the model allows Claude to hold either the selection or the cursor (`holdLoose`), and `HeldUntilMoved`, `HeldInTerminal` and `HeldEndsWhenMoved` make no claim there. A user sees Claude hold either the selection or only the cursor there, and the model allows both. - A Claude that connects after the last change gets no `selection_changed` until the selection changes: `on_connect` sends none, and `has_selection_changed` stops a resend of an unchanged selection. PROTOCOL.md promises a notification only when the selection changes, and the tools answer on request, so this is not a rule. diff --git a/.agents/skills/verify-selection-tracking/check.sh b/.agents/skills/verify-selection-tracking/check.sh index 6d9458d23..45312bf12 100755 --- a/.agents/skills/verify-selection-tracking/check.sh +++ b/.agents/skills/verify-selection-tracking/check.sh @@ -25,6 +25,7 @@ covered_block demote_text "$SEL" '^local function demote_if_selection_text_chang covered_block holds_selection "$SEL" '^local function holds_selection_of\(' covered_block entry_of "$SEL" '^local function visual_entry_of\(' covered_block note_edit "$SEL" '^local function note_visual_edit\(' +covered_block check_edits "$SEL" '^local function check_visual_edits\(' covered_block watch_buffer "$SEL" '^local function watch_selection_buffer\(' covered_block record_read "$SEL" '^local function record_selection_read\(' covered_block reconcile "$SEL" '^local function reconcile_held_selection\(' @@ -206,13 +207,24 @@ read_records_tick() { fact[ReadRecordsTick]=$(as_bool read_records_tick) # record_selection_read listens to the buffer it records, and the listener schedules the check when the buffer changes # (a reload or a plugin's edit: TextChanged fires only for the current buffer). -# on_lines and on_reload each call check_soon, at any indentation: the code before #524 called it straight from on_lines, and -# since then both calls sit inside `if holds_selection_of(buf) then`. +# on_lines and on_reload call check_soon while the buffer holds the selection. Since #524 on_lines does it in the branch of +# `if holds_selection_of(buf) then` (its `elseif not entry` branch detaches the listener, and a check_soon there would +# check nothing it holds), and before #524 it did it after the `if not holds_selection_of(buf) then ... return true end` that +# detaches. listens_to_selection_buffer() { + # Each listener is read inside its own body (up to the next ` end,`), so a check_soon that moved into on_detach does + # not count for on_reload. + local on_lines on_reload + on_lines=$(sed -n '/^ on_lines = function(_, buf)$/,/^ end,$/p' <<<"$watch_buffer") + on_reload=$(sed -n '/^ on_reload = function(_, buf)$/,/^ end,$/p' <<<"$watch_buffer") in_order '^ watch_selection_buffer\(bufnr\)$' <<<"$record_read" && in_order '^ local function check_soon\(\)$' 'vim\.schedule\(function\(\)' '^ demote_if_selection_text_changed\(\)$' \ - '^ vim\.api\.nvim_buf_attach\(bufnr, false, \{$' '^ on_lines = function\(_, buf\)$' '^ *check_soon\(\)$' \ - '^ on_reload = function\(_, buf\)$' '^ *check_soon\(\)$' <<<"$watch_buffer" + '^ vim\.api\.nvim_buf_attach\(bufnr, false, \{$' <<<"$watch_buffer" && + { + in_order '^ if holds_selection_of\(buf\) then$' '^ check_soon\(\)$' '^ elseif not entry then$' <<<"$on_lines" || + in_order '^ if not holds_selection_of\(buf\) then$' '^ check_soon\(\)$' <<<"$on_lines" + } && + in_order '^ *check_soon\(\)$' <<<"$on_reload" } fact[ListensToSelectionBuffer]=$(as_bool listens_to_selection_buffer) # The check runs in the Claude terminal for a selection of another buffer that is not a Claude terminal, clears the held @@ -242,11 +254,15 @@ listener_notes_visual_edit() { in_order '^ entry\.unchecked_edits = entry\.unchecked_edits \+ 1$' <<<"$note_edit" } fact[ListenerNotesVisualEdit]=$(as_bool listener_notes_visual_edit) -# note_visual_edit schedules a check that takes the edit back and marks the entry consumed only when visual mode is over: -# a plugin's edit leaves the selection on the screen and does not consume it. +# check_visual_edits takes the counted edits back and marks the entry consumed only when visual mode is over: a plugin's edit +# leaves the selection on the screen and does not consume it. note_visual_edit runs that check from vim.schedule and from a +# vim.on_key listener, whichever comes first, so it runs before the flush of the next Esc also when a mapping, a macro or +# a callback that is already running gets the key in first. Without either trigger an edit that nobody checked reaches the flush. visual_check_spares_plugin_edit() { - in_order '^ vim\.schedule\(function\(\)$' '^ entry\.unchecked_edits = entry\.unchecked_edits - 1$' \ - '^ if not is_visual_mode\(vim\.api\.nvim_get_mode\(\)\.mode\) then$' '^ entry\.consumed = true$' <<<"$note_edit" + in_order '^ entry\.unchecked_edits = 0$' '^ if not is_visual_mode\(vim\.api\.nvim_get_mode\(\)\.mode\) then$' \ + '^ entry\.consumed = true$' <<<"$check_edits" && + in_order '^ vim\.schedule\(function\(\)$' '^ check_visual_edits\(entry\)$' '^ vim\.on_key\(function\(\)$' \ + '^ check_visual_edits\(entry\)$' <<<"$note_edit" } fact[VisualCheckSparesPluginEdit]=$(as_bool visual_check_spares_plugin_edit) # Neither on_mode_changed nor the flush reads a changedtick (comments apart). The code before #524 compared the changedtick diff --git a/.agents/skills/verify-selection-tracking/covered.lock b/.agents/skills/verify-selection-tracking/covered.lock index 29c192c7b..21b319b8f 100644 --- a/.agents/skills/verify-selection-tracking/covered.lock +++ b/.agents/skills/verify-selection-tracking/covered.lock @@ -5,7 +5,7 @@ block stop d754def6d5865650 site lua/claudecode/init.lua: selection.disable() @ function M.stop() site lua/claudecode/init.lua: selection.enable(M.state.server, M.state.config.visual_demotion_delay_ms) @ function M.start(show_startup_notification) == lua/claudecode/selection.lua -file lua/claudecode/selection.lua 349b9bb73c6f64c5 +file lua/claudecode/selection.lua cf8e6464c4302b2f block unmoved f1a214d2c667190d block clear_held d64cb396d2cab01b block text_there 1b8ae5bb0f3cf2bc @@ -13,7 +13,8 @@ block cursor_other a3f70cc27b68d423 block demote_text 0668ccc9dd4a9b5f block holds_selection edc9a9c85c5d4ba0 block entry_of 0753ce446f57ebfd -block note_edit 4fdbc33b47229b1d +block note_edit 2b569fa719b53a17 +block check_edits 86b30fe1e23ab2d3 block watch_buffer b2d4b61899558e19 block record_read b0556eae55637f45 block reconcile bc1c8b3e222be191 @@ -121,7 +122,7 @@ site lua/claudecode/selection.lua: if M.state.debounce_timer ~= timer then @ fun site lua/claudecode/selection.lua: if M.state.demotion_timer then @ function M.update_selection() site lua/claudecode/selection.lua: if M.state.demotion_timer ~= timer then @ function M.update_selection() site lua/claudecode/selection.lua: if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == current_buf then @ function M.update_selection() -site lua/claudecode/selection.lua: if M.state.visual_entry ~= entry then @ local function note_visual_edit(entry) +site lua/claudecode/selection.lua: if M.state.visual_entry ~= entry or entry.unchecked_edits == 0 then @ local function check_visual_edits(entry) site lua/claudecode/selection.lua: if M.state.watched_buffers[bufnr] then @ local function watch_selection_buffer(bufnr) site lua/claudecode/selection.lua: last_active_visual_selection = nil, @ M.state = { site lua/claudecode/selection.lua: latest_selection = nil, @ M.state = { diff --git a/.agents/skills/verify-selection-tracking/model/SelectionTracking.tla b/.agents/skills/verify-selection-tracking/model/SelectionTracking.tla index 73fcbb2b0..8a8bceaaa 100644 --- a/.agents/skills/verify-selection-tracking/model/SelectionTracking.tla +++ b/.agents/skills/verify-selection-tracking/model/SelectionTracking.tla @@ -317,11 +317,11 @@ TickMoved(s, b) == IF s.entryBuf = b THEN [s EXCEPT !.entryMoved = TRUE] ELSE s \* An edit of buffer b reaches the listener (on_lines): note_visual_edit counts it on the visual_entry of b. EntryEdited(s, b) == IF s.entryBuf = b /\ ListenerNotesVisualEdit THEN [s EXCEPT !.entryDirty = TRUE] ELSE s -\* A plugin's edit in visual mode: the listener counts it, and the model runs the check that the code schedules in the same -\* step, which is true when the check comes before the next key. Visual mode is still on then, so the entry is not consumed, -\* unless the code takes every counted edit for an operator's. A key that comes first is not modeled (a known bug): a -\* mapping that edits and presses Esc in one typeahead, a key that arrives during the plugin's callback, an autocmd edit at -\* a mode change. See SKILL.md, "Scope and bounds". +\* A plugin's edit in visual mode: the listener counts it, and the model runs the check in the same step. The code runs it +\* from vim.schedule and from a vim.on_key listener, whichever comes first, so it comes before the flush of the next Esc +\* (before #540 only the schedule ran it, and a key that came first reached the flush with an unchecked edit: a mapping +\* that edits and presses Esc in one typeahead, a key during a callback, an autocmd edit at a mode change). Visual mode +\* is still on at the check, so the entry is not consumed, unless the code takes every counted edit for an operator's. PluginEditedInVisual(s, b) == TickMoved(IF VisualCheckSparesPluginEdit THEN s ELSE EntryEdited(s, b), b) @@ -439,7 +439,7 @@ YouEditByPlugin == (* A plugin edits another line while you select, and the selection stays on the screen with the *) (* text it had: the changedtick moves, and the listener counts the edit. No TextChanged and no *) (* CursorMoved fire in visual mode. An edit whose check has not run when the next key arrives *) -(* would reach the flush unchecked: not modeled (a known bug, see SKILL.md). *) +(* would reach the flush unchecked: the code checks it at the next key too (see SKILL.md). *) YouEditByPluginInVisual == /\ buf \in Files /\ mode = "v" /\ SetS(PluginEditedInVisual(S, buf)) diff --git a/.agents/skills/verify-selection-tracking/replays/driver.lua b/.agents/skills/verify-selection-tracking/replays/driver.lua index 5a6869c57..5ad68e5c5 100644 --- a/.agents/skills/verify-selection-tracking/replays/driver.lua +++ b/.agents/skills/verify-selection-tracking/replays/driver.lua @@ -61,10 +61,14 @@ function M.run(opts, steps, verdict) return true end local selection = require("claudecode.selection") + local version = vim.version() say( string.format( - "nvim %s, debounce_ms=%d, visual_demotion_delay_ms=%d", - tostring(vim.version()), + "nvim %d.%d.%d%s, debounce_ms=%d, visual_demotion_delay_ms=%d", + version.major, + version.minor, + version.patch, + version.prerelease and "-dev" or "", selection.state.debounce_ms, selection.state.visual_demotion_delay_ms ) diff --git a/.agents/skills/verify-selection-tracking/replays/release_after_autocmd_edit_in_visual.lua b/.agents/skills/verify-selection-tracking/replays/release_after_autocmd_edit_in_visual.lua index a43089dde..0dd341048 100644 --- a/.agents/skills/verify-selection-tracking/replays/release_after_autocmd_edit_in_visual.lua +++ b/.agents/skills/verify-selection-tracking/replays/release_after_autocmd_edit_in_visual.lua @@ -1,8 +1,9 @@ --- A known bug that the model does not cover (fix PR #540): an autocmd edits another line when visual mode starts (a --- `ModeChanged` handler for `n:v`), and the Esc is already in the typeahead (`ve`). The listener counts the edit and --- schedules the check that would show that visual mode is still on, but no main-loop turn runs before the flush on Esc, --- so the flush takes the edit for an operator's. Claude gets the cursor, not the selection, although its text is --- untouched. BUG means the plugin still does that; the probe prints OK once the fix lands. Default delays. +-- A bug that #540 fixed: an autocmd edits another line when visual mode starts (a `ModeChanged` handler for `n:v`), and +-- the Esc is already in the typeahead (`ve`). The listener counts the edit and schedules the check that shows that +-- visual mode is still on, but no main-loop turn runs before the flush on Esc, so the flush found an edit that nobody had +-- checked and took it for an operator's: Claude got the cursor, not the selection, although its text was untouched. Now +-- the check also runs from a `vim.on_key` listener, which sees the Esc before it is processed. BUG means the flush +-- takes the edit for an operator's again. Default delays. local driver = require("driver") driver.run({ prepare = function() diff --git a/.agents/skills/verify-selection-tracking/replays/release_in_same_typeahead_as_plugin_edit.lua b/.agents/skills/verify-selection-tracking/replays/release_in_same_typeahead_as_plugin_edit.lua index 0b20643c8..cfa790d56 100644 --- a/.agents/skills/verify-selection-tracking/replays/release_in_same_typeahead_as_plugin_edit.lua +++ b/.agents/skills/verify-selection-tracking/replays/release_in_same_typeahead_as_plugin_edit.lua @@ -1,11 +1,10 @@ --- A known bug that the model does not cover (fix PR #540): a mapping edits another line while you are in visual --- mode and presses Esc in the same typeahead (`lua ...`). The listener counts the edit and schedules the --- check that would show that visual mode is still on, but no main-loop turn runs between the edit and the Esc, so the --- flush on Esc finds an edit that nobody checked and takes it for an operator's. Claude gets the cursor, not the --- selection, although its text is untouched. A plugin edit whose check runs before the next key is not like this --- (`release_after_plugin_edit_in_visual.lua`). Two other ways to reach the same flush are --- `release_with_key_during_plugin_callback.lua` and `release_after_autocmd_edit_in_visual.lua`. BUG means the plugin --- still does that; the probe prints OK once the fix lands. Default delays. +-- A bug that #540 fixed: a mapping edits another line while you are in visual mode and presses Esc in the same typeahead +-- (`lua ...`). The listener counts the edit and schedules the check that shows that visual mode is still +-- on, but no main-loop turn runs between the edit and the Esc, so the flush on Esc found an edit that nobody had +-- checked and took it for an operator's: Claude got the cursor, not the selection, although its text was untouched. +-- Now the check also runs from a `vim.on_key` listener, which sees the Esc before it is processed. BUG means the flush +-- takes the edit for an operator's again. Two other ways to reach the same flush are +-- `release_with_key_during_plugin_callback.lua` and `release_after_autocmd_edit_in_visual.lua`. Default delays. local driver = require("driver") driver.run({ prepare = function() diff --git a/.agents/skills/verify-selection-tracking/replays/release_with_key_during_plugin_callback.lua b/.agents/skills/verify-selection-tracking/replays/release_with_key_during_plugin_callback.lua index 6afe839f6..fcd0f37d0 100644 --- a/.agents/skills/verify-selection-tracking/replays/release_with_key_during_plugin_callback.lua +++ b/.agents/skills/verify-selection-tracking/replays/release_with_key_during_plugin_callback.lua @@ -1,9 +1,10 @@ --- A known bug that the model does not cover (fix PR #540): a plugin edits another line from a timer callback while you --- are in visual mode, and your Esc arrives while that callback still runs (`nvim_input`, the way a UI sends a key). The --- schedule that would check the edit runs after the callback, and the key was read first, so the flush on Esc takes the --- edit for an operator's. Claude gets the cursor, not the selection, although its text is untouched. A plugin edit --- whose callback ends before the next key is not like this (`release_after_plugin_edit_in_visual.lua`). BUG means the --- plugin still does that; the probe prints OK once the fix lands. Default delays. +-- A bug that #540 fixed: a plugin edits another line from a timer callback while you are in visual mode, and your Esc +-- arrives (`nvim_input`, the way a UI sends a key) while that callback, or another callback that was queued before the +-- listener's check, still runs. The schedule that checks the edit runs after those callbacks, and the key was read +-- first, so the flush on Esc found an edit that nobody had checked and took it for an operator's: Claude got the +-- cursor, not the selection, although its text was untouched. Now the check also runs from a `vim.on_key` listener, +-- which sees the Esc before it is processed. This probe sends the key during the plugin's own callback. BUG means the +-- flush takes the edit for an operator's again. Default delays. local driver = require("driver") driver.run({ prepare = function() diff --git a/README.md b/README.md index acfb4ca5b..566e46d61 100644 --- a/README.md +++ b/README.md @@ -1173,7 +1173,9 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/ `ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`, `ClaudeCodeOpenFileNewFiles`, and `snacks_win_`, one per Snacks terminal whose split the plugin re-creates [lua/claudecode/terminal/snacks.lua:register_window_events]), `nvim_buf_attach` change listeners on the buffer of a held selection and on every buffer where a visual selection - starts, until the first edit after the selection ends [lua/claudecode/selection.lua:watch_selection_buffer], `User` + starts, until the first edit after the selection ends [lua/claudecode/selection.lua:watch_selection_buffer], a + `vim.on_key` listener (one namespace) that an edit of the buffer in which you select sets, at the latest until the next + key [lua/claudecode/selection.lua:note_visual_edit], `User` events (`ClaudeCodeSendComplete`, `ClaudeCodeDiffOpened`, `ClaudeCodeDiffClosed`), the four global `ClaudeCodeInlineDiff*` highlight groups for the `"unified"` diff layout [lua/claudecode/diff_inline.lua:setup_highlights], and a wrapped global `vim.paste` when diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index c78570306..e4087d4ec 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -146,7 +146,7 @@ Module map (direct files): - 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. It runs `update_selection` first, so a changed selection goes out twice [selection.lua:M.send_current_selection] - `has_selection_changed` compares only `filePath`, `text`, and the start and end positions [selection.lua:M.has_selection_changed] - 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 when an operator (`d`, `c`, `>`, `gU`...) changed the buffer's text in visual mode, because the marks then point at text you never selected. A plugin's edit in visual mode and a `:write` do not skip it, although both move the changedtick: Neovim ends visual mode before an operator edits, a plugin's edit runs in visual mode, and a `:write` fires no buffer callback. A reload counts as an operator. Neovim 0.8 and 0.9 end visual mode for the length of an `nvim_buf_set_lines` or `nvim_buf_set_text` call, so the `on_lines` callback only counts the edit and a `vim.schedule`d check reads the mode after the call. The flush skips a selection with an edit that is not checked yet, which is what an operator leaves, because its flush comes first [selection.lua:M.flush_visual_selection] [selection.lua:note_visual_edit] +- A visual selection is flushed on visual exit. The flush is skipped when an operator (`d`, `c`, `>`, `gU`...) changed the buffer's text in visual mode, because the marks then point at text you never selected. A plugin's edit in visual mode and a `:write` do not skip it, although both move the changedtick: Neovim ends visual mode before an operator edits, a plugin's edit runs in visual mode, and a `:write` fires no buffer callback. A reload counts as an operator. Neovim 0.8 and 0.9 end visual mode for the length of an `nvim_buf_set_lines` or `nvim_buf_set_text` call, so the `on_lines` callback only counts the edit and a check reads the mode after the call: in `vim.schedule`, or in a `vim.on_key` listener at the next key when a mapping runs that key first (`lua edit()`), whichever comes first. The flush skips a selection with an edit that is not checked yet, which is what an operator leaves, because its flush comes first [selection.lua:M.flush_visual_selection] [selection.lua:note_visual_edit] [selection.lua:check_visual_edits] - `selection_changed` positions are 0-based lines and 0-based UTF-8 byte offsets into the buffer line, not VS Code's UTF-16 code units. `end` is exclusive. A position inside a multibyte code point, such as a stale mark after an edit and `gv`, moves back to the code point's first byte [selection.lua:code_point_start]. A charwise or blockwise selection ends after the character that starts at its end position. That character is what `y` takes as one (`vim.fn.byteidx`): it includes its composing characters, and on Neovim 0.11+ the rest of its grapheme cluster. On Neovim 0.11+ a control, such as a TAB, U+0085 or U+200B, is a character of its own, without the composing characters after it. So is an invalid byte on every version, although before 0.11 the cursor can skip the composing characters after it. With `'selection'` set to `exclusive`, a selection of more than one position ends after the character before that position instead. Before 0.11, with `'selection'` set to `exclusive`, `y` can leave out composing characters that follow an invalid byte before that position, and the selection keeps them [selection.lua:selection_end_byte] - A charwise selection that includes a line break (`v$`) ends at the start of the next line, and its text ends with that line break, as Neovim yanks it. On the buffer's last line, for a blockwise selection, and with `'virtualedit'` containing `all` (where the end is a virtual space), the end stays at the end of the line. On an empty line, and for a block whose last row is empty, that end is `character` 1, because Claude Code counts an end at `character` 0 as the line before. With `all`, the text has none of the virtual spaces that Neovim's `y` adds. A blockwise selection sends the contiguous span between its first and last positions, not the rectangle [selection.lua:end_past_line] - A linewise selection ends on its last line at that line's byte length. When that line is empty and another line follows, it ends at the start of the next line, and its text ends with the empty line's line break, because Claude Code counts an end at `character` 0 as the line before. An empty last line of the buffer ends at `character` 1 [selection.lua:extract_linewise_text] diff --git a/lua/claudecode/selection.lua b/lua/claudecode/selection.lua index 8c18cc2ba..5b892e48b 100644 --- a/lua/claudecode/selection.lua +++ b/lua/claudecode/selection.lua @@ -175,6 +175,33 @@ local function visual_entry_of(bufnr) return entry and entry.bufnr == bufnr and entry or nil end +---The namespace of the key listener that note_visual_edit sets (see there), made when the first edit is noted. There +---is one listener: the one of the edit that was noted last replaces an earlier one. The next key clears it, and so does +---a check that runs before it for the selection that you are still making. After an operator the flush has removed the +---entry, so the check that is scheduled does nothing, and the listener lives until the next key. +local visual_edit_keys + +local function clear_visual_edit_keys() + if visual_edit_keys then + vim.on_key(nil, visual_edit_keys) + end +end + +---Checks the edits of the visual selection that nobody checked yet: visual mode is still on when a plugin edited a line +---and the selection stays on the screen, and it is off when an operator consumed the selection. Edits that came before +---this check are all judged at once, by the mode that they left. It is safe to run twice (the second finds nothing). +---@param entry table The visual selection, from visual_entry_of. +local function check_visual_edits(entry) + if M.state.visual_entry ~= entry or entry.unchecked_edits == 0 then + return -- The flush came first and skipped the selection, or this check came earlier: nothing to check. + end + entry.unchecked_edits = 0 + clear_visual_edit_keys() + if not is_visual_mode(vim.api.nvim_get_mode().mode) then + entry.consumed = true + end +end + ---Notes an edit of the text in the buffer in which you select. Three things move the buffer's changedtick: a ---consuming operator (d, c, >, gU, J, r, p...), a plugin's edit and a `:write`. The changedtick cannot tell them ---apart, and the flush must skip only the operator. @@ -183,20 +210,24 @@ end ---2. A plugin's edit runs in visual mode and leaves the selection on the screen. ---3. A `:write` fires no callback, so it is not an edit here. ---Neovim 0.8 and 0.9 end visual mode for the length of an `nvim_buf_set_lines` or `nvim_buf_set_text` call, and the ----callback runs in that state. `vim.schedule` runs after the call, so the mode that it sees is the one the edit left. ----The operator's flush comes before the check runs, so the flush skips a selection that has an unchecked edit. +---callback runs in that state. So the mode that tells the two apart is the one after the call, and the check runs in +---`vim.schedule` or before the next key, whichever comes first. A mapping or a macro runs its next key with no +---main-loop turn in between: `lua edit()` reaches the flush before the schedule, and `vim.on_key` +---sees that key while visual mode is still on. +---The operator's flush comes before either check runs, so the flush skips a selection that has an unchecked edit. ---@param entry table The visual selection, from visual_entry_of. local function note_visual_edit(entry) entry.unchecked_edits = entry.unchecked_edits + 1 vim.schedule(function() - if M.state.visual_entry ~= entry then - return -- The flush came first and skipped the selection: the edit was not checked. - end - entry.unchecked_edits = entry.unchecked_edits - 1 - if not is_visual_mode(vim.api.nvim_get_mode().mode) then - entry.consumed = true - end + check_visual_edits(entry) end) + -- Replaces the listener of an earlier edit of this selection. One of a flushed selection is a stale one: its check + -- finds another entry, does nothing, and clears the listener. + visual_edit_keys = visual_edit_keys or vim.api.nvim_create_namespace("claudecode_visual_edit") + vim.on_key(function() + clear_visual_edit_keys() + check_visual_edits(entry) + end, visual_edit_keys) end ---Listens for changes to the buffer of the selection that Claude holds, for as long as it holds one, and to the buffer diff --git a/tests/integration/selection_visual_edit_spec.lua b/tests/integration/selection_visual_edit_spec.lua index fc6fdaa3f..9ed24cbb0 100644 --- a/tests/integration/selection_visual_edit_spec.lua +++ b/tests/integration/selection_visual_edit_spec.lua @@ -76,6 +76,41 @@ describe("a visual selection released after the buffer changed, with the real pl return last and last.params and last.params.text end + -- Nothing that Claude was sent after the operator (the messages from number `from` + 1 on) may carry the text that + -- the marks point at after it. What was sent before is not the operator's doing: when something delays the keys + -- past the debounce (a slow :write, a busy host), the debounce sends the selection that you are still making, and + -- that is right. assert_is_the_selection checks that it is that selection, with its text and its range. + local function sent_no_selection(from) + for index = (from or 0) + 1, #sent do + local params = sent[index].params + if params and params.selection and not params.selection.isEmpty then + return false, params.text + end + end + return true + end + + -- What Claude was told last must be a cursor: whatever the debounce sent before the operator, the plugin must have sent + -- something after it that clears it, and not only forgotten it in its own state. + local function assert_last_sent_is_a_cursor() + local last = sent[#sent] + assert.is_truthy(last, "something was sent") + assert.is_true(last.params.selection.isEmpty, "the last selection_changed is a cursor") + end + + -- Every selection (not a cursor) among the first `count` messages is `alpha` of the first line, which the tests select + -- with `ve`: the text and the range, so a right text at a wrong range fails too. + local function assert_is_the_selection(count) + for index = 1, count do + local params = sent[index].params + if params and params.selection and not params.selection.isEmpty then + assert.are.equal("alpha", params.text) + assert.are.same({ line = 0, character = 0 }, params.selection.start) + assert.are.same({ line = 0, character = 5 }, params.selection["end"]) + end + end + end + -- A plugin edits the second line, without moving the selection on the first. local function plugin_edits_line_two() vim.api.nvim_buf_set_lines(0, 1, 2, false, { "second line changed" }) @@ -94,6 +129,7 @@ describe("a visual selection released after the buffer changed, with the real pl pcall(claudecode.stop) server.broadcast = real_broadcast vim.env.CLAUDE_CONFIG_DIR = real_config_dir + pcall(vim.api.nvim_del_augroup_by_name, "ClaudeCodeSelectionVisualEditSpec") vim.cmd("silent! only") vim.cmd("silent! %bwipeout!") end) @@ -159,41 +195,6 @@ describe("a visual selection released after the buffer changed, with the real pl -- What the guard exists for: an operator consumes the selection, and the marks point at text you never selected. describe("when an operator consumes the selection", function() - -- Nothing that Claude was sent after the operator (the messages from number `from` + 1 on) may carry the text that - -- the marks point at after it. What was sent before is not the operator's doing: when something delays the keys - -- past the debounce (a slow :write, a busy host), the debounce sends the selection that you are still making, and - -- that is right. assert_is_the_selection checks that it is that selection, with its text and its range. - local function sent_no_selection(from) - for index = (from or 0) + 1, #sent do - local params = sent[index].params - if params and params.selection and not params.selection.isEmpty then - return false, params.text - end - end - return true - end - - -- What Claude was told last must be a cursor: whatever the debounce sent before the operator, the plugin must have sent - -- something after it that clears it, and not only forgotten it in its own state. - local function assert_last_sent_is_a_cursor() - local last = sent[#sent] - assert.is_truthy(last, "something was sent") - assert.is_true(last.params.selection.isEmpty, "the last selection_changed is a cursor") - end - - -- Every selection (not a cursor) among the first `count` messages is `alpha` of the first line, which the tests select - -- with `ve`: the text and the range, so a right text at a wrong range fails too. - local function assert_is_the_selection(count) - for index = 1, count do - local params = sent[index].params - if params and params.selection and not params.selection.isEmpty then - assert.are.equal("alpha", params.text) - assert.are.same({ line = 0, character = 0 }, params.selection.start) - assert.are.same({ line = 0, character = 5 }, params.selection["end"]) - end - end - end - it("sends no selection for d (ved)", function() start() open_file({ "alpha beta gamma", "second line" }) @@ -277,6 +278,162 @@ describe("a visual selection released after the buffer changed, with the real pl end) end) + -- A mapping runs its keys with no main-loop turn between them, so the check that the listener schedules for an edit + -- has not run when the flush on Esc comes. The edit must be told apart from an operator's without waiting for it. + describe("when the edit and the next key come in one typeahead", function() + it("holds the selection after a mapping edits another line and presses Esc", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + wait(20) + press("lua vim.api.nvim_buf_set_lines(0, 1, 2, false, { 'second line changed' })") + wait(400) + + assert.are.equal("second line changed", vim.api.nvim_buf_get_lines(0, 1, 2, false)[1]) + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + assert.is_false(selection.get_latest_selection().selection.isEmpty) + end) + + it("holds the selection after a mapping appends lines and presses Esc", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + wait(20) + press("lua vim.api.nvim_buf_set_lines(0, 2, 2, false, { 'appended', 'lines' })") + wait(400) + + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + end) + + it("holds the selection after a mapping edits another line twice and presses Esc", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + wait(20) + press( + "lua vim.api.nvim_buf_set_lines(0, 1, 2, false, { 'one' })" + .. "lua vim.api.nvim_buf_set_lines(0, 1, 2, false, { 'two' })" + ) + wait(400) + + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + end) + + it("holds the selection after a mapping writes the file and presses Esc", function() + start() + open_file({ "alpha beta gamma", "second line" }) + vim.api.nvim_buf_set_lines(0, 1, 2, false, { "second line, modified" }) + wait(50) + + press("ve") + wait(20) + press("silent write") + wait(400) + + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + end) + + -- The edit of a mapping is not the operator's: d after it still consumes the selection. + it("sends no selection for d after a mapping edited another line", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + wait(20) + local before_operator = #sent + press("lua vim.api.nvim_buf_set_lines(0, 1, 2, false, { 'second line changed' })d") + wait_for_cursor() + + assert.are.equal(" beta gamma", vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]) + assert.are.same({ true }, { sent_no_selection(before_operator) }) + assert_is_the_selection(before_operator) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + assert_last_sent_is_a_cursor() + end) + + -- One typeahead for an operator and the next selection: the edit of the first must not count for the second. + it("does not carry an operator's edit into the next selection", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("vedwwve") + wait(400) + + assert.are.equal("gamma", held_text()) + assert.are.equal("gamma", last_sent_text()) + end) + end) + + -- The schedule that checks an edit runs after the command or callback that made it. A key that is read first, with no + -- main-loop turn before it, reaches the flush on Esc with an edit that nobody checked. (A key that a UI sends while a + -- callback runs is another way to get there, but vim.wait gives it a main-loop turn first, so replays/ has it.) + describe("when a key is read before the check of an edit", function() + it("holds the selection when an autocmd edits another line as visual mode starts and Esc follows", function() + start() + open_file({ "alpha beta gamma", "second line" }) + local group = vim.api.nvim_create_augroup("ClaudeCodeSelectionVisualEditSpec", { clear = true }) + vim.api.nvim_create_autocmd("ModeChanged", { + group = group, + pattern = "n:v", + once = true, + callback = function() + vim.api.nvim_buf_set_text(0, 1, 0, 1, 6, { "SECOND" }) + end, + }) + wait(50) + + press("ve") + wait(400) + vim.api.nvim_del_augroup_by_id(group) + + assert.are.equal("SECOND line", vim.api.nvim_buf_get_lines(0, 1, 2, false)[1]) + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + end) + end) + + -- The key listener checks the edit at the Esc, with visual mode still on. The schedule of that edit is still queued, + -- and another ModeChanged handler that runs before the plugin's, and turns the main loop (vim.wait), runs it after + -- Neovim ended visual mode and before the flush. That second check must find nothing to judge: judging the edit + -- again, with visual mode over, takes the plugin's edit for an operator's. + describe("when a queued check runs after visual mode ended and before the flush", function() + it("holds the selection after a mapping edits another line and presses Esc", function() + local group = vim.api.nvim_create_augroup("ClaudeCodeSelectionVisualEditSpec", { clear = true }) + vim.api.nvim_create_autocmd("ModeChanged", { + group = group, + pattern = "*:n", + callback = function() + vim.wait(0) + end, + }) + start() -- after the handler above, so that it runs first + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + wait(20) + press("lua vim.api.nvim_buf_set_lines(0, 1, 2, false, { 'second line changed' })") + wait(400) + + assert.are.equal("second line changed", vim.api.nvim_buf_get_lines(0, 1, 2, false)[1]) + assert.are.equal("alpha", held_text()) + assert.are.equal("alpha", last_sent_text()) + assert.is_false(selection.get_latest_selection().selection.isEmpty) + end) + end) + it("does not carry an edit from one visual selection into the next", function() start() open_file({ "alpha beta gamma", "second line" }) diff --git a/tests/selection_test.lua b/tests/selection_test.lua index 369293362..caea6e191 100644 --- a/tests/selection_test.lua +++ b/tests/selection_test.lua @@ -63,6 +63,14 @@ if not _G.vim then schedule = function(fn) fn() end, + -- vim.on_key: ns -> the callback that Neovim calls for the next key, before it is processed. A second callback for + -- the same namespace replaces the first, and nil removes it. + _on_key = {}, + on_key = function(fn, ns) + _G.vim._on_key[ns] = fn + end, + _namespaces = {}, + _next_namespace = 0, _buffers = {}, _windows = {}, _buf_listeners = {}, -- nvim_buf_attach: bufnr -> { callbacks } @@ -129,6 +137,18 @@ if not _G.vim then return _G.vim._buffers[bufnr] ~= nil end, + -- As in Neovim: one id for each name, and a new one for each call with the empty name. + nvim_create_namespace = function(name) + if name ~= "" and _G.vim._namespaces[name] then + return _G.vim._namespaces[name] + end + _G.vim._next_namespace = _G.vim._next_namespace + 1 + if name ~= "" then + _G.vim._namespaces[name] = _G.vim._next_namespace + end + return _G.vim._next_namespace + 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 @@ -1090,6 +1110,7 @@ describe("Range Selection Tests", function() mock_server.last_broadcast = nil _G.vim.v.event = {} _G.vim._buf_listeners = {} + _G.vim._on_key = {} end) after_each(function() @@ -1822,6 +1843,184 @@ describe("Range Selection Tests", function() assert(ok, err) end) + -- What Neovim does with the next key of a mapping: vim.on_key calls the listener before the key is processed, + -- with no main-loop turn between it and the edit, so vim.schedule has not run yet. + local function key_listeners() + local listeners = {} + for _, listener in pairs(_G.vim._on_key) do + table.insert(listeners, listener) + end + return listeners + end + + local function press_next_key() + local listeners = key_listeners() + assert.are.equal(1, #listeners, "the edit set one key listener") + listeners[1]("\27") + end + + -- vim.schedule runs its callbacks later, after a mapping's keys: collect them. + local function with_queued_schedule(body) + local queued = {} + local real_schedule = _G.vim.schedule + _G.vim.schedule = function(fn) + table.insert(queued, fn) + end + local ok, err = pcall(body, queued) + _G.vim.schedule = real_schedule + assert(ok, err) + end + + it("flushes the selection of a mapping that edits and presses Esc before vim.schedule runs", function() + with_queued_schedule(function(queued) + enter_visual_mode() + _G.vim._changedtick = 6 + edit_text(1) -- the mode is still visual, the check is queued + press_next_key() -- the Esc of the mapping: visual mode is on, so it was a plugin's edit + + leave_visual_mode() + + assert(mock_server.last_broadcast ~= nil) + assert.are.equal("alpha", mock_server.last_broadcast.data.text) + assert.are.equal(1, #queued) + queued[1]() -- the flush is over: the check does nothing + end) + end) + + -- Another ModeChanged handler that runs before the plugin's can turn the main loop (vim.wait), so a queued check + -- runs after Neovim ended visual mode and before the flush. The key listener already checked the edit at the + -- key, so that check must find nothing to judge: judging it again, with visual mode over, takes the plugin's + -- edit for an operator's. + it("does not judge an edit again in a check that runs after visual mode ended and before the flush", function() + with_queued_schedule(function(queued) + enter_visual_mode() + edit_text(1) -- a plugin's edit of another line, in a mapping + press_next_key() -- the Esc: visual mode is still on, so the edit is spared + _G.vim.test.set_mode("n") -- Neovim ended visual mode, and the ModeChanged handler of the plugin has not run + queued[1]() -- the vim.wait of another handler runs the queued check now + assert.is_false(selection.state.visual_entry.consumed) + + leave_visual_mode() + + assert(mock_server.last_broadcast ~= nil) + assert.are.equal("alpha", mock_server.last_broadcast.data.text) + end) + end) + + it("judges several edits that come before the next key together", function() + with_queued_schedule(function(queued) + enter_visual_mode() + edit_text(1) + edit_text(1) + assert.are.equal(2, selection.state.visual_entry.unchecked_edits) + press_next_key() + + assert.are.equal(0, selection.state.visual_entry.unchecked_edits) + assert.is_false(selection.state.visual_entry.consumed) + for _, check in ipairs(queued) do + check() -- the queued checks find nothing + end + assert.are.equal(0, selection.state.visual_entry.unchecked_edits) + assert.is_false(selection.state.visual_entry.consumed) + end) + end) + + it("does not take the edit of a mapping for the operator that comes next", function() + with_queued_schedule(function() + enter_visual_mode() + edit_text(1) -- a mapping's edit, visual mode is on + press_next_key() -- the key that is d: checked before it runs + _G.vim.test.set_mode("n") + edit_text(1) -- d: the text changes, visual mode is over + + leave_visual_mode() + + assert.is_nil(mock_server.last_broadcast) + assert.is_nil(selection.state.latest_selection) + end) + end) + + it("clears the key listener when the schedule runs before the next key", function() + with_queued_schedule(function(queued) + enter_visual_mode() + edit_text(1) -- a plugin's edit: the check is queued, the key listener is set + assert.are.equal(1, #key_listeners()) + + queued[1]() -- a main-loop turn came first + + assert.are.equal(0, #key_listeners()) + assert.are.equal(0, selection.state.visual_entry.unchecked_edits) + assert.is_false(selection.state.visual_entry.consumed) + end) + end) + + -- A typeahead can start the next selection and edit it before the schedule of the operator's edit runs. + it("does not let the stale check of an operator clear the listener of the next selection", function() + with_queued_schedule(function(queued) + enter_visual_mode() + _G.vim.test.set_mode("n") + edit_text(1) -- an operator's edit: the flush comes first, and its check is queued + leave_visual_mode() + + enter_visual_mode() -- the next selection, in the same typeahead + edit_text(1) -- a plugin's edit of it sets the listener + assert.are.equal(1, #key_listeners()) + + queued[1]() -- the stale check of the operator runs now + + assert.are.equal(1, #key_listeners()) + press_next_key() -- the Esc: visual mode is on, so the edit of the plugin does not consume + assert.is_false(selection.state.visual_entry.consumed) + assert.are.equal(0, selection.state.visual_entry.unchecked_edits) + end) + end) + + it("keeps one key listener for several edits and clears it at the next key", function() + with_queued_schedule(function() + enter_visual_mode() + edit_text(1) + edit_text(1) + edit_text(1) + assert.are.equal(1, #key_listeners()) -- each edit replaces the listener of the one before + + press_next_key() + + assert.are.equal(0, #key_listeners()) + end) + end) + + it("keeps the operator's edit when the next key comes after the flush", function() + with_queued_schedule(function() + enter_visual_mode() + _G.vim.test.set_mode("n") + edit_text(1) -- an operator's edit, then the flush for it + leave_visual_mode() + assert.is_nil(mock_server.last_broadcast) + + enter_visual_mode() -- the next selection starts before the stale listener has run + press_next_key() -- the stale listener finds another entry + assert.are.equal(0, selection.state.visual_entry.unchecked_edits) + assert.is_false(selection.state.visual_entry.consumed) + assert.are.equal(0, #key_listeners()) -- and clears itself + end) + end) + + it("skips the flush when an operator's key listener has not run and no other key came", function() + with_queued_schedule(function() + enter_visual_mode() + _G.vim.test.set_mode("n") + edit_text(1) + + leave_visual_mode() + + assert.is_nil(mock_server.last_broadcast) + assert.are.equal(1, #key_listeners()) -- waits for the next key + press_next_key() + assert.are.equal(0, #key_listeners()) -- the entry is gone: it does nothing and clears itself + assert.is_nil(selection.state.visual_entry) + end) + end) + it("skips the flush after a reload, which replaces the text", function() enter_visual_mode() call_listeners("on_reload") @@ -1831,6 +2030,33 @@ describe("Range Selection Tests", function() assert.is_nil(mock_server.last_broadcast) end) + -- `:edit!` unloads the text of the buffer and reads it again: the listener is detached, and the marks point at text + -- that you never selected. The detach counts as an operator, as a reload does. + it("skips the flush after the listener is detached, which replaces the text", function() + enter_visual_mode() + call_listeners("on_detach") + assert.is_true(selection.state.visual_entry.consumed) + + leave_visual_mode() + + assert.is_nil(mock_server.last_broadcast) + end) + + it("does not take the detach of another buffer for an operator of this selection", function() + enter_visual_mode() + for _, listener in ipairs(_G.vim._buf_listeners[1] or {}) do + if listener.on_detach then + listener.on_detach(nil, 2) -- a buffer that you do not select in + end + end + assert.is_false(selection.state.visual_entry.consumed) + + leave_visual_mode() + + assert(mock_server.last_broadcast ~= nil) + assert.are.equal("alpha", mock_server.last_broadcast.data.text) + end) + it("does not carry the operator of one selection into the next", function() enter_visual_mode() _G.vim.test.set_mode("n")