Skip to content

test(selection): count only what is sent after the operator in two visual-edit tests - #553

Merged
ThomasK33 merged 4 commits into
mainfrom
test/selection-visual-edit-flake
Nov 12, 2026
Merged

ThomasK33 merged 4 commits into
mainfrom
test/selection-visual-edit-flake

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

What failed

CI run 43167645889 (job integration-tests (stable), PR #523, head 3ceb6ecc) failed tests/integration/selection_visual_edit_spec.lua:230, "sends no selection for d after a :write in visual mode", with {false, 'alpha'}.

Cause: a timing bug in the test, not in the plugin

The test presses ve, waits 20 ms, runs :write, waits 20 ms and presses d. It then asserts that Claude was never sent a non-empty selection.

That holds only while d comes within the 100 ms debounce of ve. When anything delays the keys past it (a slow :write on a busy host, a stalled loop), the debounce sends the selection that you are still making, with the mode still visual. That send is right: you did select alpha. The plugin then sends the cursor (an empty selection) the moment d runs. sent_no_selection counted every message, so it also counted the right one.

I traced it with time stamps, on Neovim 0.12.3 and 0.8.0. In every failing run the sequence is the same:

0 ms    press ve
20 ms   write start
167 ms  write end
167 ms  SENT text="alpha" empty=false mode=v      <- the debounce, before d
247 ms  press d
297 ms  SENT text="" empty=true mode=n            <- the cursor, right after d

A phantom after d would carry beta (the text at the old marks) and a mode other than v. None of the 30 traces has one. A deterministic repro makes :write block for 150 ms with a BufWritePost autocmd: the same sequence, the same failure, and the plugin ends with the cursor held and beta gamma on line 1.

The sibling test "sends no selection for d after a plugin edited another line in visual mode" has the same shape and failed too (wait(20) stalled). The ved, vec and Vj> tests press all their keys in one press, so no timer can fire between them.

The fix

Round 1 (43a12838): sent_no_selection(from) looks only at the messages after number from. The two tests record #sent before they press d, and assert that a selection sent before the operator is the one the user made. With the flush made to never skip (if false and entry ...), the five operator tests fail on both versions, these two included.

Round 2 (the Opus verifier's two findings):

  • F1, a second flake in the same tests (also on main). After an operator the plugin sends the cursor when its flush or the debounce has run, and get_latest_selection() is nil until then. A host that stalls the loop past the fixed wait(400) made the test fail with attempt to index a nil value. The five operator tests (ved, vec, Vj>, and the plugin-edit and :write tests) now call wait_for_cursor(): vim.wait with a condition (the plugin holds an empty selection) and a 5 s limit, then wait(200) so that a selection sent late by the debounce would still show. The release tests keep wait(400): the flush on Esc sends the selection before press returns, so get_latest_selection() is never nil there.
  • F2, the early-selection check compared only the text. assert_is_the_selection checks the text and the range (0:0 to 0:5) of every selection sent before the operator.

Round 3 (Codex P2): when the debounce sends alpha before d, the tests passed even if the plugin only forgot the selection locally and never sent the cursor. Each of the five operator tests now also asserts that the last selection_changed sent is a cursor. A mutant whose flush sets latest_selection to the cursor without sending it passes the round-2 spec 12 of 12, and fails this spec's plugin-edit and :write tests under the 150 ms stall on both versions (a verifier reran it: 2 of 12 fail, those two). Without a stall, tests/selection_test.lua already catches that mutant.

Proof

Deterministic stalls, on Neovim 0.12.3 and 0.8.0 (generated copies of the spec, not part of the PR):

Stall Old spec (main) Round-1 spec This spec
150 ms between the selection and d (:write and plugin-edit tests) 2 of 12 fail ({false,'alpha'}) passes 12 of 12 pass
loop blocked 450 ms right after the operator (the five operator tests) 5 of 12 fail (attempt to index a nil value) 5 of 12 fail 12 of 12 pass
no stall 12 of 12 12 of 12 12 of 12

Wrong range, right text (the in-visual selection sent with its end line moved by 5), with the 150 ms stall: the round-1 spec passes 12 of 12 on both versions, and this spec fails the plugin-edit and :write tests (both on 0.8.0 and in 3 of 4 runs on 0.12.3, one run failed only the :write test).

Same-window load (24 runners in parallel and 8 dd conv=fsync loops, or 40 runners with 12 disk loops and 16 CPU burners):

Window Old or round-1 spec: runs that failed This spec
round 1, 0.12.3, old spec 25 of 72 ({false,'alpha'}) 0 of 72 (round-1 spec)
round 1, 0.8.0, old spec 4 of 72 0 of 72 (round-1 spec)
round 2, 0.12.3, 40 runners, round-1 spec 24 of 80 (all 24 attempt to index a nil value: Vj> 15, vec 7, :write 1, ved 1) 0 of 80 (round-2 spec)
this head (verifier rerun, load up to about 114) not reproduced: the round-1 spec also passed 0 of 80 0 of 80 on 0.12.3, 0 of 40 on 0.8.0

A window with lower load had no failure for either spec (0 of 72 each), so the deterministic stalls above are the proof and the load windows the check.

Other gates: the spec passes 12 of 12 on both versions on an idle host. The CI-style integration runner (private HOME, normal and odd tmp 50% TMPDIR) passes with 417 tests per run on 0.12.3 and 309 per run on v0.8.0, and leaves the real cache untouched. luacheck shows 0 warnings in 181 files and treefmt --ci 0 changes.

Notes for the reviewer

#540 moves sent_no_selection to the outer scope of this spec and adds cases that use it. It resolves the conflict when it merges main.


Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

…o timing-exposed visual-edit tests

Both tests press ve, wait 20 ms, edit another line or :write, wait 20 ms, and press d. When anything delays that past the 100 ms debounce (a slow :write on a busy host, a stalled loop), the debounce sends the selection that you are still making, with the mode still visual. That send is right, and the plugin sends the cursor as soon as d runs. sent_no_selection counted every message, so the test failed with {false, 'alpha'} on CI. It now looks at the messages after the operator, and checks that a selection sent before it is the selection you made.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T08:38:12.723288Z 5a32dc1 Manual request
🔒 Security Review ✅ Completed 2026-11-12T08:37:35.095553Z 5a32dc1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 43a128382b

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 43a128382b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… range of an early selection

After an operator the plugin sends the cursor once its flush or the debounce has run, and get_latest_selection() is nil until then. A host that stalls the loop past the fixed 400 ms wait made the five operator tests fail with 'attempt to index a nil value' (the stall repro fails them all on main). They wait until the plugin holds a cursor now, then give the debounce and the demotion time to run. The selection that the debounce may send before the operator is checked for its range too, not only its text.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43a128382b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 43a128382b

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: b398e1e24c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…rator is a cursor

When a slow write or plugin edit lets the debounce send the selection before the operator, the tests passed even if the plugin only forgot the selection in its own state and never told Claude. The five operator tests now also assert that the last message sent is a cursor.
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: b398e1e24c

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 5a32dc13ac

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 5a32dc13ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ThomasK33 added a commit that referenced this pull request Nov 12, 2026
…' into fix/selection-same-typeahead-edit

#553 fixes two timing bugs of the operator tests of this spec. Its helpers move to the outer scope of the spec, where the cases of this branch use them, and the new mapping case waits for the cursor and checks the selections sent before the operator, as #553 does. The spec has one more case: an autocmd that edits as visual mode starts.
@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit e0bdff6 Nov 12, 2026
8 checks passed
@ThomasK33
ThomasK33 deleted the test/selection-visual-edit-flake branch November 12, 2026 11:32
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…oder#540)

## What a user sees

You select text in visual mode, a plugin or a mapping edits another
line, and your Esc is read before the plugin has checked that edit. The
selection is untouched and its text is still there, but Claude gets the
cursor and not the selection. coder#524 fixed the case in which the check ran
before the key. Three ways to read the key first also failed on `main`,
and this PR fixes all of them:

1. A mapping that edits and presses Esc in one key sequence, for example
`<Cmd>lua edit_other_line()<CR><Esc>`.
2. A key that arrives while a callback runs, as a UI sends it
(`nvim_input`): during the plugin's own timer callback, or during
another one that was queued before the check.
3. An autocmd that edits when visual mode starts (a `ModeChanged`
handler for `n:v`) with the Esc already in the typeahead (`ve<Esc>`).

The replays of `verify-selection-tracking` print `BUG` on `main` for
each of them, 3 of 3 runs on Neovim 0.8.0 and 0.12.3, and `OK` with this
change.

## Cause

The flush on visual exit skips a selection that has an edit nobody
checked yet (`entry.consumed or entry.unchecked_edits > 0`). An operator
(`d`, `c`, `>`) edits the text and then ends visual mode, and its flush
runs before any check, so that skip is what stops a phantom selection. A
plugin's edit leaves visual mode on. The check that tells the two apart
(is visual mode still on?) ran only in `vim.schedule`, and a schedule
runs after the command or callback that made the edit returns, and after
any callback that was queued before it. When a key is read first, the
flush takes the plugin's edit for an operator's.

## The fix

`lua/claudecode/selection.lua`:

- `check_visual_edits(entry)` is the old scheduled check as its own
function. It judges every edit that is waiting at once, by the mode that
they left (visual mode on: a plugin's edit, benign. Off: an operator
consumed the selection). It does nothing for a stale entry or when
nothing waits, so running it twice is safe.
- `note_visual_edit` still counts the edit and schedules the check, and
it now also sets a `vim.on_key` listener (one namespace, made on first
use). Neovim calls the listener for the next key before it processes
that key, also for a key that comes from a mapping or that a UI sends,
so the Esc finds visual mode still on. Whichever of the schedule and the
key comes first runs the check, and the check clears the listener.
- An operator's flush still comes before both, so `d`, `c`, `>` and the
other operators skip as before. A listener of a flushed selection does
nothing at the next key and clears itself, so it can live until that key
(README says "at the latest until the next key").

I checked the key listener on Neovim 0.8.0, 0.9.5, 0.10.4, 0.11.4 and
0.12.3 (a probe script, not the plugin): it fires for the `<Esc>` of a
mapping before it is processed with the mode still `v`, a second
`vim.on_key(fn, ns)` replaces the first, and `vim.on_key(nil, ns)` is
safe inside the callback and for an unregistered namespace.

I did not try the range of the edit (`on_lines` receives the changed
lines) as the test for whether it touched the selected lines. By my
reasoning, which I did not test, a plugin edit inside the selection
would then count as an operator's although the selection is still on the
screen, and the marks and the edit range can be in different coordinates
once lines move.

## Proof

- `tests/integration/selection_visual_edit_spec.lua` has eight new
real-Neovim cases: a mapping edits another line and presses Esc, appends
lines and presses Esc, edits twice and presses Esc, writes the file and
presses Esc, edits and then `d` (nothing is sent after it), an operator
followed by the next selection in one typeahead, an autocmd edit at a
mode change with `ve<Esc>`, and a queued check that runs after visual
mode ended and before the flush (a user `ModeChanged` handler for `*:n`
that calls `vim.wait(0)`, defined before the plugin starts, and a
mapping that edits and presses Esc). On `main`'s `selection.lua` five of
them fail on Neovim 0.8.0 and 0.12.3 (the first three, the autocmd case
and the new one), and the rest are guards. The spec passes 20 of 20 on
0.8.0, 0.9.5, 0.10.4, 0.11.4 and 0.12.3. The case for a key that arrives
during a callback is a replay only: `vim.wait` gives the callback's key
a main-loop turn first, so the check cannot lose the race in that spec.
- `tests/selection_test.lua` has eleven new cases (the mock gets
`vim.on_key` and one `nvim_create_namespace` id for each name, a new one
for the empty name, as Neovim does): the key comes before the schedule,
a check that runs after visual mode ended and before the flush judges
nothing again, several edits are judged together, the edit of a mapping
is not the operator that comes next, a stale check does not clear the
listener of the next selection, the schedule clears the listener,
several edits keep one listener, a stale listener clears itself, an
operator's listener waits for a key, and (the last two) a detached
listener counts as an operator on the visual entry and the detach of
another buffer does not.
I changed `selection.lua` in 14 ways, one at a time, and counted the
failures in `tests/selection_test.lua` (158 cases): no key listener (9),
a listener that only clears (4), one decrement for each check (1), never
consumed (6), always consumed (6), no schedule (6), no
`clear_visual_edit_keys()` in the key listener (2), no
`clear_visual_edit_keys()` in `check_visual_edits` (1), a
`clear_visual_edit_keys` that does nothing, which removes both (4), a
namespace for each edit (3), no stale-entry guard (1), no
`unchecked_edits == 0` guard (1), and no `entry.consumed = true` in
`on_detach` (1). 13 are killed. One survives and I think it is
equivalent, but I did not prove it: the key listener checks
`M.state.visual_entry` instead of the entry it was set for. The listener
always belongs to the last noted entry, and a different current entry
has no unchecked edit, so the zero guard returns. The zero guard itself
is not equivalent. A `ModeChanged` handler that runs before the plugin's
and calls `vim.wait(0)` runs the queued check after visual mode ended
and before the flush. Without the guard that check judges the edit a
second time, takes it for an operator's, and Claude gets the cursor. The
new unit case and the new integration case kill it (the integration case
on Neovim 0.8.0 and 0.12.3).
- `verify-selection-tracking` (merged in coder#477): `check.sh` passes
(129,986 distinct states) and so does `verify-diff-lifecycle` (89,205),
with both locks re-recorded: `selection.lua` changed, the selection
skill has a new covered block (`check_visual_edits`), and the diff model
reads no block or site of that file. All 16 replays print `OK` on Neovim
0.8.0 and 0.12.3 at the default delay and with `DELAY_MS=50`. The three
that replay these bugs print `BUG` on `main`.
- The skill is updated for this fix: `VisualCheckSparesPluginEdit` now
reads `check_visual_edits` and both triggers in `note_visual_edit`, so
removing the schedule, the key listener, the mode test or the `consumed`
mark reads `FALSE` and nothing else (checked with five mutants of
`selection.lua`; the model then breaks `ReleaseSent`).
`ListensToSelectionBuffer` reads `check_soon()` in the body of
`on_lines` (in the branch that holds the selection) and in the body of
`on_reload`, each body on its own. So moving the call into the `elseif
not entry` branch, removing it from `on_lines` or `on_reload`, or moving
it from `on_reload` into `on_detach` reads `FALSE` and nothing else
(five mutants). All 27 facts read `TRUE` on this tree. On the code
before coder#524 exactly four facts read `FALSE` (checked at the previous
push), and the old form of `on_lines` still reads `TRUE` for
`ListensToSelectionBuffer` after this change. The replay header prints
the Neovim version as numbers (it printed a table address on 0.8).
- Gates on the final head: the full unit suite (`busted --shuffle`, seed
1794484841) 1748 successes, 0 failures, 28 pending. The integration
runner (CI style, a private `HOME`, normal and `odd tmp 50%` `TMPDIR`):
535 tests in both runs on 0.12.3 and 398 in both runs on v0.8.0, 0
failures, and the real cache stays untouched.
`scripts/tla/test-check-lib.sh` 44 of 44. The spec passes 20 of 20 on
all five Neovim versions, `selection_test.lua` 158 of 158, `check.sh` of
both skills passes, `luacheck` 0 warnings in 185 files, `treefmt --ci` 0
changes in 427 files, and `--hash-only` matches for all four `verify-*`
skills (no re-record was needed after the merge of `main`).
- `selection_held_spec.lua` is flaky under load on `main` (here 7 of 72
runs, on `origin/main` 14 of 72). This PR does not touch it. coder#559 fixes
it with condition waits and shows the cause with traces.

## Notes for the reviewer

- coder#553 is merged. This branch merges `main` and keeps the helpers of
that PR at the outer scope of the spec, because the mapping cases use
them. coder#559 also edits `selection_visual_edit_spec.lua`
(`wait_for_cursor` waits 400 ms, and one new case at the end of the
operator block), so whichever of the two lands second needs a small
merge in that file.
- Neovim 0.8 and 0.9 still need the scheduled check for a plugin edit
whose callback ends before any key (their `on_lines` runs with visual
mode off during the API call). The key listener adds the keys that come
first.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
… other path characters (coder#561)

## Why

`cwd.git_root` must find the repository of a folder whatever its name
is. A repository in a folder with a space or a comma in its path was not
found when git could not run: the fallback gave nil, or the repository
of Neovim's current folder. A folder named `[x] {y}` or `$HOME-like` was
lost or changed even when git answered. A verifier of F12 found the
first case (finding F12-P1, a space or comma in the path makes the
`.git` fallback return nil).

## What changed

Four problems in `lua/claudecode/cwd.lua`. The first two took a path as
a pattern. The third stepped one folder down at a trailing `..`. The
fourth is a flaw in my first version of the second step.

1. **The `.git` fallback** called `vim.fn.finddir(".git", start_dir ..
";")`. `finddir` reads its path as a list in `'path'` option syntax. A
space or a comma split the path, and a `;` ended it. The fallback now
walks up from the full path of `start_dir` and tests each folder for a
`.git` folder with `vim.fn.isdirectory`. A relative `start_dir` now
reaches the folders above the current one too. Before, a relative start
gave a wrong answer or nil in some cases (for example
`git_root("sub/deeper")` in the repository root).
2. **`normalize_dir`** passed every path through `vim.fn.expand`.
`expand` reads `[`, `{`, `*`, `?`, `$` and `\` in a name as patterns or
variables. A folder named `[x] {y}` or `it's` came back with a trailing
slash. A folder named `$HOME-like` or `back\slash` came back nil. This
also happened when `git rev-parse` answered. `normalize_dir` now returns
a directory that exists as written, and expands any other string (`~`,
`$VAR`). See step 4 for text that starts with `~` or `$`.

3. **`..` before Neovim 0.11.** Before 0.11, `fnamemodify(path, ":p")`
keeps a last `..`, and `:h` of `a/b/..` is `a/b`. So a walk from a start
folder that ends in `..` stepped one folder down and returned the wrong
root. The walk now calls `simplify()` on the full path first. A verifier
found this.

4. **Text that starts with `~` or `$`.** The first version of step 2
took a path that names an existing folder as written, on the raw text.
`vim.fn.isdirectory` does not expand, so with `cwd = "~"` or `"$HOME"`
and a stray folder of that literal name in Neovim's current folder (for
example one that an unexpanded `mkdir "~/x"` made), `git_root` and
`resolve` returned the stray folder where main returned the home folder.
`normalize_dir` now sends text that starts with `~` or `$` through
`expand` first, as a shell and `:cd` do. A verifier of the terminal
change found this.

`lua/claudecode/README.md` gets one new bullet that states this, with
the rule for text that starts with `~` or `$`. It sits after the
"Terminal cwd precedence" bullet. It names `git_root` and `resolve`
only, and it says that the terminal still passes a static `cwd` and a
`cwd_provider` result through `vim.fn.expand` itself.

## Scope

- Only `cwd.lua` changes in code. Steps 1 and 2 are one change of idea:
a path is a literal, not a pattern. Step 3 is a quirk of `fnamemodify`
before Neovim 0.11. Step 4 closes a hole that step 2 opened.
- `normalize_dir` serves `cwd.git_root` and `cwd.resolve`. Nothing in
`lua/` calls `cwd.resolve`. The terminal calls `cwd.git_root` when
`git_repo_cwd` is set, so this PR fixes folder names with `$` and `\` on
that path: when git does not answer, the terminal now gets `$HOME-like`,
`back\slash`, `[x] {y}`, a space and a comma right, where main gave nil
or the repository of Neovim's current folder. A static `cwd` and a
`cwd_provider` result do not go through `normalize_dir`:
`terminal.lua:build_config` passes them through `vim.fn.expand` itself,
so this PR does not change what the terminal does with those. An
absolute start path that ends in `..` is fixed here, through the
`simplify()` call above.
- Two gaps stay for separate follow-ups. Main already has both, and this
PR does not reach them. First, the fallback finds a `.git` folder only,
not a `.git` file in a worktree or submodule. Second, the walk is
lexical, so a link into another repository answers with the repository
of the link's own folder, where git answers with the link target's
repository. The terminal's `expand` of a static `cwd` and of a provider
result is a third follow-up, and it has its own change.
- `~` and `$VAR` still expand in `cwd.resolve` (the spec checks both).
The spec's cases for `cwd.resolve` are named as `cwd.resolve` cases,
because they do not test the terminal.
- `git rev-parse` still runs first. Its result for a plain path is the
same string as before.

## Blast Radius

- User-visible: a repository in a folder with a space, a comma, a quote
or another path character is now found when git cannot run. With git,
`git_root` returns a folder with `[`, `{`, `*`, `?`, `$` or `\` in its
name exactly. It does not reach the terminal through a `cwd_provider`:
`build_config` still expands the provider's result, so the root README's
example still loses `$` and `\` there (follow-up). A plain path gives
the same result as before.
- The fallback result is an absolute path with no `..` part. Before it
could be relative. The one exception is a relative start whose name
begins with `~`: Vim reads `~` as the home folder and `~name` as a
user's home folder, so `:p` leaves `~name` relative and the result can
be the relative `~name`. That is a pathological case, and no caller in
`lua/` passes a relative path (the terminal passes the file's folder
from `expand("%:p")` or `getcwd()`).
- A string that names an existing folder is taken as written, except
text that starts with `~` or `$`. That text always goes through `expand`
first, so a folder named `~` or `$HOME` in Neovim's current folder does
not take the place of the home folder, as on main. The cost: a relative
folder name that starts with `$` and reads as a variable (`$HOME-like`,
`$odd/sub`) is expanded and not found, as on main. A lone relative
`$odd` that names no variable is left unchanged by Vim and is still
found. An absolute path never starts with `~` or `$`, so every folder
name this PR fixes keeps working. The change applies to `git_root`'s
`start_dir` and to `cwd.resolve`. The terminal's own `expand` of a
static `cwd` and a provider result is a separate change.
- Neovim 0.8: the code uses `vim.fn.isdirectory`, `vim.fn.fnamemodify`
and `vim.fn.simplify`, which exist there. I ran the new spec on Neovim
0.8.0, 0.10.0 and 0.12.3. The `..` case fails on 0.8.0 and 0.10.0
without the `simplify()` call, and passes on 0.12.3 either way.
- `covered.lock`: `cwd.lua` is the terminal's working-directory lookup.
`check.sh` printed STALE for the file hash of `cwd.lua` only, with no
changed block or call site. The change cannot reach diff state, an
openDiff answer, a disconnect or a diff command. The model passed
(`main`: 89205 distinct states, `live-DisconnectedCallsAnswered`: 5269).
`check.sh --record` wrote the new lock and `check.sh --hash-only`
reports that it matches.

### Merge with coder#539 (F12)

coder#539 landed first. This PR's merge commit with `origin/main` resolves
the interplay: it re-records `covered.lock` and changes the one stale
test in coder#539's `cwd_spec.lua` (see Verification). F11 (coder#535) merges with
this PR without a conflict.

## Verification

Red before the fix, green after (same spec, only
`lua/claudecode/cwd.lua` swapped):

| Spec | origin/main | with the fix |
| --- | --- | --- |
| `tests/integration/cwd_path_characters_spec.lua` (37 cases, real
Neovim, 0.8.0 and 0.12.3) | 20 successes, 17 failures | 37 successes, 0
failures |
| the new case "goes up from a start folder that ends in .." at
`ede2f733` (before the `simplify()` call) | fails on 0.8.0 and 0.10.0,
passes on 0.12.3 | passes on 0.8.0, 0.10.0 and 0.12.3 (35 successes when
added, 37 at the final head, 0 failures on each) |
| the two cases "expands a leading ~ / $ even when Neovim's cwd holds a
folder named ~ / $HOME", with `cwd.lua` from `ca9e61c0` (before the
guard) | 35 successes, 2 failures on 0.8.0 and 0.12.3 | 37 successes, 0
failures on each |

The spec makes real folders and runs `cwd.git_root` on them, in two
modes. In the first mode git is off `PATH` (the shell keeps its absolute
path), so `git` ends with "not found" and a list command raises E475, as
on a host without git. In the second mode a real `git init` makes the
repository. The folder names are: a space, a comma, a semicolon, single
and double quotes, brackets and braces, parentheses, non-ASCII letters,
a star and a question mark, a percent sign and a hash, a backslash, a
dollar sign, a leading tilde, and a plain name. Each name runs from the
repository folder and from a folder two levels below it, and the answer
must equal the repository path with no trailing slash. A folder that is
not a repository returns nil, whatever its name. Seven more cases cover
a relative start folder, a start folder that ends in `..`, `cwd.resolve`
with `git_repo_cwd`, a static `cwd` with brackets and a star in
`cwd.resolve`, `~` and `$VAR` in a setting of `cwd.resolve`, and two
cases where Neovim's current folder holds a folder named `~` or `$HOME`
and the setting must still expand. The 17 red cases are 10 of the no-git
names, 4 of the git names, the relative start, `resolve`, and the static
`cwd`.

Gates on the final head `f12368bd`, which contains a merge of
`origin/main` (05b9785, with coder#551, coder#535, coder#494, coder#545, coder#553, coder#555 and
coder#557) (private XDG folders). An independent verifier passed `c73ab044`.
Since then the branch has three changes of its own and two merges with
main. The changes are a docs commit (the README sentence about where the
walk starts), the fix for text that starts with `~` or `$` (step 4, with
two spec cases and the README clause), and the `covered.lock` record for
it. For the lock I ran the full `check.sh`: the model passed (89205
distinct states, 5269 live states, every fact TRUE), and only the file
hash of `cwd.lua` was stale, with no changed block or call site.

- `mise run all`: native exit 0. luacheck: 0 warnings / 0 errors in 186
files. busted: 1756 successes / 0 failures / 0 errors / 29 pending.
treefmt: 0 files changed. Since coder#545, `mise run all` also runs the
static proofs of the `verify-claudecode-nvim` skill, and they pass.
- `mise run format-check`: native exit 0 (428 files, 0 changed).
- `scripts/run_integration_tests_individually.sh` on Neovim 0.12.3: 564
successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 427
successes, 0 failed, 0 errors, native exit 0 (the 0.8.0 run skips the
specs that need 0.9). The new spec gives 37 successes on both. It starts
about 100 processes (git and a shell), so it takes about 6 s on a quiet
host and can pass the runner's 40 s limit at a load average above 100. A
TIMEOUT line there means the host was slow, not that the spec hung.
- All four `.agents/skills/verify-*/check.sh --hash-only` match their
`covered.lock`.

The merge with `origin/main` had two parts to resolve in an earlier
commit. `covered.lock` conflicted on the hash line of `cwd.lua`, so I
ran the full `check.sh` and `check.sh --record`. coder#539's
`tests/unit/cwd_spec.lua` had a test that stubbed `vim.fn.finddir`,
which the fallback no longer calls, so that test now walks up real
folders. It does not depend on where `TMPDIR` is: it passes with
`TMPDIR` inside a git checkout too. `cwd.lua` itself merged without a
text conflict. The two latest merges needed no `covered.lock` change:
coder#551 conflicted only in `tests/mocks/vim.lua`, where the branch keeps
both its `simplify` entry and coder#551's `wordcount`, and coder#535 and coder#494
merged without a conflict.

An independent verifier found the `..` case, the README wording and the
other points that this body now states. The `terminal.lua` expand and
`.git` files in worktrees are gaps that main already has. They are
follow-ups, not part of this PR.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…coder#559)

## Result

`selection_held_spec.lua` failed in 24 of 144 runs under load. With this
change it failed in 0 of 144 runs, on the same tree, in the same load
windows.

The load was 16 spec runs at the same time, 8 disk writers (`dd
conv=fsync` loops), Neovim 0.12.3, three rounds that alternate the old
and the new spec (round 1: 18 of 48 against 0 of 48, round 2: 1 against
0, round 3: 5 against 0). Without load the spec takes 9 s instead of 23
s, because it no longer sleeps.

This PR is only test code (two spec files). It does not change the
plugin.

## Cause

Every wait in the spec was a fixed `vim.wait(N)`. A wait ends at its
deadline. When the main loop stalls for 230 to 390 ms (a loaded disk or
CPU), the wait can end in the loop iteration that fires the timers. The
`vim.schedule_wrap` callbacks of those timers have not run yet. And a
late debounce arms the demotion timer too late for the deadline. The
next step then reads a plugin that is not done.

I traced this with a copy of the spec that logs the plugin's calls, adds
a 5 ms heartbeat timer (it reports every stall of the loop), and dumps
the log when a test fails. Each failure below comes from a wait that
ended too early. The last row also exposes a plugin gap (see "Found
while tracing"). The traces are in the details below.

| Old test | What failed | What the trace shows |
|---|---|---|
| keeps one listener on the file through reloads (after `enew`) |
`selection.isEmpty` was `false` | the loop stalled 334 ms, the wait
ended in the iteration that fired the debounce, and `update_selection`
sent the empty cursor 0 ms after the assertion |
| ends the selection without an error when the buffer you visited is
wiped (both kinds) | `selection.isEmpty` was `false` | a 270 ms or 389
ms stall, the debounce ran late and armed the 50 ms demotion, and the
wait ended before it fired |
| does not overflow the stack when the buffer of the held selection is
taken for the Claude terminal | a `selection_changed` for
`term://x//1:claude` was sent | the demotion timer fired in the stall,
and its callback ran after the test had renamed the buffer and edited it
(I reproduced this with an injected stall) |
| keeps the selection when the Claude terminal first opens (delay 50) |
held text was `""`, not `"alpha"` | the stall ended the wait with the
demotion pending, the terminal opened (2.7 s on the loaded disk), and
the demotion fired while the terminal was the current buffer |

The old spec failed these in 24 of 144 runs: 18 times the overflow test,
7 times the first-opens test, 2 times a wiped-buffer test (27 failed
tests in 24 runs). The listener test failed in other runs: 12 of 60, 5
of 60 and 15 of 48 traced runs, and 2 of 36 untraced runs.

<details>
<summary>Traces (times in ms, from a traced copy of the old
spec)</summary>

Listener test, a stall straddles the end of `wait(300)`:

```
28502 on_cursor_moved(buf=24)                  :enew arms the 100 ms debounce
28853 STALL: loop did not run for 334 ms (28519..28853)
28853 shadow fast timer (100 ms from 28502) fired
28853 wait(300) took 349 ms
28853 cycle 1: isEmpty after wait(300) = false   <- the assertion point
28853 shadow scheduled timer (100 ms from 28502) ran
28853 update_selection(buf=24)
28853 send_selection_update text= file=         <- the cursor of the new buffer
28853 isEmpty became true 0 ms after the assertion point
```

Wiped-buffer test (`nvim_buf_delete`), the debounce runs late and its
demotion misses the deadline:

```
32138 on_cursor_moved(buf=35)                  the file buffer is current again, debounce armed
32488 STALL: loop did not run for 270 ms (32218..32488)
32493 update_selection(buf=35)                  arms the 50 ms demotion
32542 wait(400) took 404 ms                     the demotion would fire at about 32543
```

Overflow test. I reproduced it by putting a 220 ms sleep between two
loop iterations of the wait after Esc (the natural failure has the same
assertion message: `selection_changed`, `filePath =
'term://x//1:claude'`, `text = ''`):

```
12210 update_selection                          the debounce read the release, arms the demotion
12226 STALL injected between two loop iterations: the process sleeps 220 ms
12452 STALL: loop did not run for 226 ms (12226..12452)
12452 wait(300) took 347 ms
12452 handle_selection_demotion(buf=16)         runs in the NEXT wait, after rename + edit
12452 send_selection_update text= file=term://x//1:claude
      stack: selection.lua:1170 send_selection_update < :711 handle_selection_demotion < :622 timer callback
```

First-opens test (delay 50):

```
 296 update_selection                           arms the 50 ms demotion
 547 STALL: loop did not run for 231 ms (316..547)
 547 wait(300) took 351 ms                      the wait ends with the demotion pending
 748 on_cursor_moved(demotion_timer=true, buf=2)   the terminal opens
3306 STALL: loop did not run for 2759 ms (547..3306)
3306 handle_selection_demotion(buf=2)           fires while the terminal is the current buffer
3721 send_selection_update(buf=1) text=         after you return: the cursor replaces the selection
```

</details>

## What changed

`tests/integration/selection_held_spec.lua`:

- `settle(keep_demotion)` waits until the plugin has no debounce timer
and no demotion timer pending, and until every `vim.schedule` callback
queued before the call has run (it queues a flag and waits for the flag,
and the queue is first in, first out). Every `wait(N)` that waited for
the plugin is now `settle()`.
- `select_and_release(keep_demotion)` settles after `ve` (the debounce
reads the selection) and after Esc (the debounce reads the release, and
the demotion runs). The two specs that need a long demotion still
pending (`visual_demotion_delay_ms` of 1000) pass `keep_demotion`, and
the helper asserts that the demotion is pending, so a stall cannot
silently end that premise.
- The one `wait(20)` stays, with its comment: the popup is closed inside
the debounce on purpose.

`tests/integration/selection_visual_edit_spec.lua` (two follow-ups to
coder#553, F1 and F2):

- F1: `wait_for_cursor` waits 400 ms after the cursor (it was 200) and
then drains the queued callbacks, so a phantom selection sent up to 400
ms after the operator shows again.
- F2: a new case, "sends a cursor for d after the debounce sent the
selection in visual mode". It waits until the debounce has sent the
selection, before `d`. The two cases above it press `d` within the
debounce, so their early-selection checks usually have nothing to look
at. This one checks the text and the range of the early selection and
that the last message is a cursor, on every run.

## The tests still test the same things

One thing was lost in the first version and is back in round 2. The
three tests that expect nothing to be sent (overflow, pending demotion,
sends nothing in another terminal) lost the late-send window of their
old fixed waits, because `settle()` returns as soon as no timer is
pending. The verifier's mutant sends the cursor of the terminal buffer
200 ms late with `vim.defer_fn`. It failed 3 tests of the old spec and
none of the first version of the new spec. Each of the three tests now
calls `wait_for_late_send()`: it waits 300 ms after `settle()` and then
settles again, because a timer that fires in the last iteration of that
wait has not run its callback yet (Codex found this in round 2). The
mutant fails the same 3 tests again, and the unchanged plugin passes all
14.

I changed `selection.lua` ten ways (one at a time, in a scratch copy)
and ran the old and the new spec. None of these ten is a late send. The
set of failing tests is the same for nine of them, and the new spec also
kills one that the old spec did not:

| Mutant of `selection.lua` | Old spec fails | New spec fails |
|---|---|---|
| overflow: the terminal branch calls `update_selection` | 3 | 3 (same
tests) |
| no demotion cancel in the terminal branch | 1 | 1 |
| `from_terminal` ignores the terminal name | 1 | 1 |
| no `check_soon` in `on_reload` | 1 | 1 |
| no `check_soon` in `on_lines` | 6 | 6 |
| the listener forgets its buffer on reload | 1 | 1 |
| the demotion ignores an unmoved cursor | 13 | 13 |
| no `caf.left` mark | 2 | 2 |
| the terminal update keeps the pending demotion | 0 | 1 (first opens,
delay 1000) |
| no re-watch in `on_detach` | 0 | 0 (survives both, as before) |

For the coder#553 follow-ups, on coder#553's tree: removing the flush guard fails
5 tests of the old spec and 6 of the new one. A range off by one at the
selection end (the text stays right) survives the old spec and fails the
new F2 case. A phantom selection sent 350 ms after the operator fails 4
of the 12 tests of coder#553's spec with `wait(200)` in every run of the
independent verifier (I saw 0 failures in one run, so the old margin
depends on timing), and 6 tests with `wait(400)`.

## Found while tracing: a plugin bug that this PR does not fix

At the default `visual_demotion_delay_ms` of 50, Claude loses a held
selection when you enter the Claude terminal 100 to 150 ms after Esc and
then come back. The demotion timer fires while the terminal is the
current buffer, and Condition 1 of `handle_selection_demotion` sets
`last_active_visual_selection = nil`. When you return,
`update_selection` finds no recent visual exit and sends the cursor.

A probe with no stall shows it: release `alpha`, wait until the debounce
arms the demotion, open the terminal at once, wait for the demotion,
`wincmd p`. At delay 50 the held text after the return is `""`. At delay
200 it is `"alpha"` (the terminal's own debounce cancels the demotion).
Removing the `last_active_visual_selection = nil` block in Condition 1
keeps `"alpha"` at both delays, and this spec stays 14 of 14. That
change touches a file that `verify-selection-tracking` covers, so it is
its own PR: coder#562. The old test only failed because a stall moved the
demotion into that window.

## Gates

- `selection_held_spec.lua` 14 of 14 and
`selection_visual_edit_spec.lua` 13 of 13 on Neovim 0.8.0, 0.9.5,
0.10.4, 0.11.4 and 0.12.3 before the coder#540 merge, and 21 of 21 on 0.8.0
and 0.12.3 after it (a verifier rerun). Under the load of the first
table (16 runs at once, 8 disk writers) the spec with the added waits
failed 0 of 48 runs.
- The integration runner as CI runs it (a private `HOME`, normal and
`odd tmp 50%` `TMPDIR`): 0.12.3 528 of 528 in both runs, v0.8.0 391 of
391 in both runs, 0 failures.
- coder#553's spec under the same load, with F1 and F2: 0 of 96 runs failed,
and 0 of 96 for coder#553's spec alone.
- `luacheck` 0 warnings, `treefmt --ci` 0 changes. No unit test changes,
so I did not run the unit suite.
- I did not run the load comparison on Neovim 0.8.0. The comparison
above is 0.12.3 only.

## Notes for the reviewer

- coder#540 is merged. This branch merged `main` without a conflict, and F1
and F2 sit on the helpers that coder#540 moved to the outer scope of
`selection_visual_edit_spec.lua`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant