Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0231b99
fix(selection): check a plugin's edit in visual mode at the next key,…
ThomasK33 Nov 12, 2026
4bb67f9
docs(selection): say that the next key also checks a plugin's edit in…
ThomasK33 Nov 12, 2026
621374a
chore(skills): re-record the verify-diff-lifecycle lock after the vis…
ThomasK33 Nov 12, 2026
0341a9e
Merge remote-tracking branch 'origin/main' into fix/selection-same-ty…
ThomasK33 Nov 12, 2026
d0e0c1b
test(selection): pin that the schedule clears the key listener and th…
ThomasK33 Nov 12, 2026
6286f57
test(selection): pin that a stale check does not clear the listener o…
ThomasK33 Nov 12, 2026
43a1283
test(selection): count only what is sent after the operator in the tw…
ThomasK33 Nov 12, 2026
a007509
test(selection): wait for the cursor after an operator, and check the…
ThomasK33 Nov 12, 2026
b398e1e
Merge remote-tracking branch 'origin/main' into test/selection-visual…
ThomasK33 Nov 12, 2026
5a32dc1
test(selection): require that the last selection_changed after an ope…
ThomasK33 Nov 12, 2026
b9c0833
test(selection): pin that a detached listener counts as an operator o…
ThomasK33 Nov 12, 2026
574fa16
Merge remote-tracking branch 'origin/main' into fix/selection-same-ty…
ThomasK33 Nov 12, 2026
00bc7b4
wip: facts
ThomasK33 Nov 12, 2026
0cabb93
docs(skills): flip the same-typeahead bug to fixed, read the key list…
ThomasK33 Nov 12, 2026
de9f3fd
chore(skills): re-record both locks after the visual-edit key check
ThomasK33 Nov 12, 2026
d3e913d
Merge remote-tracking branch 'origin/main' into fix/selection-same-ty…
ThomasK33 Nov 12, 2026
24cce3d
Merge remote-tracking branch 'origin/test/selection-visual-edit-flake…
ThomasK33 Nov 12, 2026
803b24a
Merge remote-tracking branch 'origin/main' into fix/selection-same-ty…
ThomasK33 Nov 12, 2026
68a2ce5
test(selection): require the zero guard of check_visual_edits
ThomasK33 Nov 12, 2026
d7d94d7
chore(skills): read the on_reload check_soon inside on_reload and say…
ThomasK33 Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/verify-diff-lifecycle/covered.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 22 additions & 22 deletions .agents/skills/verify-selection-tracking/SKILL.md

Large diffs are not rendered by default.

32 changes: 24 additions & 8 deletions .agents/skills/verify-selection-tracking/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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\('
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/verify-selection-tracking/covered.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ 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
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
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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))
Expand Down
8 changes: 6 additions & 2 deletions .agents/skills/verify-selection-tracking/replays/driver.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Esc>`). 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<Esc>`). 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (`<Cmd>lua ...<CR><Esc>`). 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
-- (`<Cmd>lua ...<CR><Esc>`). 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1173,7 +1173,9 @@ The plugin runs the CLI through a [terminal provider](./lua/claudecode/terminal/
`ClaudeCodeDiffCleanup`, `ClaudeCodeNativeAdopted`, `ClaudeCodeOpenFileNewFiles`, and `snacks_win_<id>`, 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
Expand Down
Loading
Loading