Skip to content

fix(terminal): keep a BufLeave autocmd from redirecting where Claude's terminal and new-tab diffs are placed - #598

Merged
ThomasK33 merged 72 commits into
mainfrom
scratch/term-placement
Nov 13, 2026
Merged

ThomasK33 merged 72 commits into
mainfrom
scratch/term-placement

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Summary

A BufLeave autocmd of the user's that enters another window no longer makes Neovim load the Claude terminal into the user's window. The terminal lands in the split that the plugin made for it. The providers and the diff put the terminal into that split with a new helper, utils.win_set_buf_in_split.

This PR replaces #518, which is closed. #518 repaired the swap after a user's BufLeave autocmd had already redirected it, and each round of verification found a new problem in that repair. This PR fires BufLeave before the swap, so the autocmd cannot redirect it.

The helper fires BufLeave of the old buffer itself, in the split, before the swap. It lets Neovim fire every other event of the swap, with Neovim's own locks and in Neovim's own order. It fires no other event by hand and it enters no other window to get them.

Why

The providers make a split. The split shows the user's buffer. The provider then calls nvim_win_set_buf(split, terminal_buffer). Neovim runs the BufLeave autocmds of the user's buffer inside that call. When one of them enters another window (a window-switch autocmd, a session plugin), Neovim loads the terminal into that window. The call named the split, but the terminal lands in the window of the user's file, in two windows, or in none.

Nine earlier versions of this fix broke other behavior, or left a defect, and independent verifiers found each one:

  • Version 1 fired BufLeave, BufWinLeave and BufHidden by hand before the swap. It decided once, before the user's autocmds ran, whether the buffer lost its last window. A hand-fired event also does not lock the buffer.
  • Version 2 let Neovim fire the events, but it moved the cursor to the other window and back to do it. BufLeave then ran in the window of the user's file, so a BufLeave autocmd that closes the window made Neovim fire no BufWinLeave or BufHidden (an autosave did not run, and a pending diff stayed pending with its proposal in no window). The extra WinEnter ran a user's startinsert autocmd for terminal windows with the terminal in the split. A WinEnter autocmd that closes the split made :ClaudeCodeOpen fail. When 'eventignore' listed the four window events, the terminal's BufWinEnter autocmd did not run.
  • Version 3 asked whether it ran inside an autocmd with a bare expand("<abuf>"). Outside an autocmd that call raises E496 when 'verbose' is above 0 (:verbose, nvim -V1, set verbose=1), so every placement that goes through the helper failed with that option set: no new terminal, no hidden terminal shown, no terminal in a diff's new tab. (The first start of a Snacks terminal does not go through the helper, and it did not fail.) Under :silent! the call raises nothing and sets v:errmsg to E496 instead. Version 4 left it there, and main leaves it empty. The helper now gives v:errmsg back as it was. A verifier found it. An earlier commit (7c525f6) had removed the pcall around the call, and its message said the call raises in no context. The message was wrong. The pcall is back, and a spec runs the commands with 'verbose' at 1 and at 15 and under :verbose.
  • Version 4 fixed version 3, and added a one-line warning for a closed split. A verifier found defects in it: under :silent! with 'verbose' set the placement left E496 in v:errmsg (main leaves it empty), the toggles logged an error after the warning, a wipe of the terminal's buffer logged "closed the terminal's window" where no window closed, and a guard (the cursor goes back to the window the show started in only while that window exists) had no test.
  • Version 5 fixed those. Its guard on the warning read the provider's record of the buffer after a call that runs the user's autocmds. When a BufLeave autocmd closed Claude (:ClaudeCodeClose or terminal.close()), that record was nil, and the show raised E5108 in 16 of 16 native rows, where main raises nothing. A BufLeave autocmd that wiped the buffer and started a new terminal drew a false message the same way.
  • Version 6 removed the warning and read the terminal's buffer into a local before the autocmds of a show. A verifier found one gap in its tests: a mutant that put term.buf back at the focused Snacks call passed every spec, while it raised Invalid 'buf' in 12 of 12 rows of a WinNew or WinEnter autocmd that closes Claude. My own mutant of that line changed the focused and the unfocused call in one edit, so the unfocused case killed it. Two comments in the toggles ("When it fails, it has said why.") and one README sentence (":ClaudeCode and :ClaudeCodeFocus log what they log on main") were also false.
  • Version 7 added those tests and fixed the comments of the toggles and the shows. The readiness check of this PR then found a path that loses text, which version 6 introduced in the helper. After BufLeave, a user's autocmd can show another buffer X in the split. The helper then swapped with buffer!, which skips the E37 check of a plain swap. buffer! unloads or wipes X with its text when X is changed, no other window shows it and its 'bufhidden' is unload, delete or wipe, where a plain swap keeps X. In the probe of this path (two shapes: an autocmd that creates X, and one that moves the user's unsaved file into the split), version 7 lost the text in 24 of 40 pairs, and main in none.
  • Version 8 refused the swap in that case: the helper returned false and left the buffer in the split. A verifier found three problems. A new native terminal raised E37, because a second swap after termopen tried to put the terminal into the split that showed the buffer. A diff in a new tab was built in another tab when the autocmd had entered a window there. And in 28 of 40 probe pairs the terminal did not show where main shows it, while main keeps the text.
  • Version 9 replaced the refusal with a hide: the helper set the 'bufhidden' of a changed buffer to hide for the swap and gave it back. A verifier then found that buffer! unloads or wipes a buffer with no change too, when that buffer does not stay loaded on its own ('hidden' off, or its 'bufhidden' is unload, delete or wipe). A terminal with a running job lost its job, a scratch buffer lost its text, and a normal file lost its undo history, its marks and its buffer-local options, where main keeps the buffer loaded. In the verifier's probe of that path version 9 unloaded or wiped the buffer in 42 of 80 pairs (main in none), and 84 rows on 0.12.3 and 56 on v0.8.0 of its caller matrix with a terminal or a scratch buffer as X were worse than main.

Version 9 replaced the refusal of version 8 with a hide for a changed buffer, and this version (10) hides every buffer that a plain swap would leave loaded and that buffer! would unload or wipe (see How, item 2). The helper gives its 'bufhidden' back. The diff enters the new tab again when the helper returns false. Version 7 had added the tests that version 6 lacked and fixed two comments and the README sentence. The code of the two shows is still the code of version 6, and only their comments changed: the comments of the toggles and of the two shows now say that a false return can also mean that the terminal's buffer is gone, when a BufLeave autocmd wiped it or closed Claude. The comments of the toggles also say that the next toggle then starts a new terminal. Version 6 removed the warning: the show logs what main logs. It reads the terminal's buffer into a local before the autocmds that a show runs. The :silent! fix and the cursor guard stay. The failure of version 5 is gone: a BufLeave autocmd that closes Claude during a show raises no error in 24 of 24 rows of the verifier's probe (main raises in the 8 Snacks rows), and a WinNew or WinEnter autocmd of the show's own split that closes Claude raises no error in 8 of 8 rows (main raises in all 8).

How

utils.win_set_buf_in_split(win, buf, opts?) does this, when it can:

  1. It fires BufLeave for the buffer that win shows, with :doautocmd <nomodeline> BufLeave, in win, while that buffer still shows there. :doautocmd passes on the error of a raising autocmd, as a plain swap does.
  2. The buffer that win shows now can differ from the old one: an autocmd of that BufLeave showed another buffer X there (a terminal with a running job, a scratch buffer, a changed or an unchanged file), or closed the other window of the old one. A plain swap leaves X loaded and hidden, but buffer! unloads or wipes X when X is not kept by 'hidden' and no other window shows it. A changed buffer loses its text only when its 'bufhidden' is unload, delete or wipe. Otherwise a terminal loses its job, a scratch buffer its text, and an unchanged file its undo history (a wipe also loses its marks). The helper then sets X's 'bufhidden' to hide for the swap, with :noautocmd so that no OptionSet autocmd runs, and sets the old value again after the swap, also when the swap raises. The old buffer, when it is still the buffer in the split, gets the same only when it is changed and an autocmd closed its other window: a plain swap decided before that autocmd ran, and then unloads or wipes it with its text when its 'bufhidden' says so (d1, below). An old buffer with no change is left as it is, because a plain swap unloads or wipes it in the same way. The helper does not use :buffer without !, because that raises E37 or opens the 'confirm' dialog.
  3. When an autocmd of that BufLeave moved the cursor, it puts the cursor back in win under :noautocmd, with no event.
  4. It swaps with buffer! under :noautocmd. For the swap, 'eventignore' holds the caller's value plus BufLeave (it ran already) and OptionSet (so setting the option is silent). :noautocmd sets the caller's value again afterwards, also when the swap fails, with no OptionSet.
  5. Neovim then fires BufWinLeave, BufHidden and BufUnload for a buffer that lost its last window (also when a BufLeave autocmd closed the other window of it), BufEnter and BufWinEnter. They run after the BufLeave autocmds are done, with the locks that Neovim holds.

The helper makes the plain nvim_win_set_buf instead when it runs inside an autocmd (it asks with pcall(vim.fn.expand, "<abuf>"), and an error of that call means "outside an autocmd"), when win is not the current window, when win shows the buffer already, in the command-line window, with 'winfixbuf', and when a plain swap could refuse to leave the old buffer (it is changed, not hidden and shown in no other window).

When a BufLeave autocmd closes the split, the helper shows nothing and returns false. The providers then leave the terminal hidden, as for any hidden terminal, and the next :ClaudeCodeOpen shows it. The show logs nothing about it, and neither do :ClaudeCode and :ClaudeCodeFocus. Without focus, the show puts the cursor back in the window it started in, when that window still exists (the autocmd can have closed it too). When a BufLeave autocmd wipes the terminal's buffer, no window closed and nothing is left to show. The helper shows nothing and returns false, and the show logs nothing, as on main. Both providers read the terminal's buffer number into a local before the split and the swap, because a user's autocmd of either can close Claude, which clears the provider's record of the buffer. A plain swap loads the terminal into the window that the cursor ends in.

opts.ignore_bufenter adds BufEnter to the ignored events, as before, for the unfocused Snacks show and the diff.

Call sites: the new terminal, the swap after termopen, and the hidden terminal in terminal/native.lua. The re-shown split, focused and unfocused, in terminal/snacks.lua. The terminal in a diff's new tab in diff.lua.

What a user's autocmd sees, compared with main

  1. BufLeave for the old buffer runs in the split, as on main. The events and their order are a plain swap's: the sequence of events is equal to main's in 27 of 27 shapes on Neovim 0.12.3 and 21 of 21 on v0.8.0. In 21 and 18 of them the trace is exactly equal, and in the other 6 and 3 (the shapes that ignore BufEnter: the diff in a new tab, the unfocused Snacks show) main also fires OptionSet for 'eventignore' twice and this branch fires none (the shapes: no redirect, defaults, 'nohidden', an unsaved file. The paths: native new, hidden and repaired, focused and unfocused, Snacks hidden, and openDiff in a new tab). No extra WinLeave or WinEnter runs.
  2. A BufLeave autocmd that closes the other window of the file (the BufHidden autosave shape), and one that closes the window that the pending diff's proposal is in, make Neovim fire BufWinLeave and BufHidden after the autocmd returns, as on main. The autosave runs and the diff is answered, nested or not.
  3. A WinEnter term://* autocmd that runs startinsert leaves the user in Normal mode after a diff in a new tab, as on main.
  4. A raising BufLeave autocmd fails the command with its error and shows no terminal, as on main. The message has more frames around it than on main.
  5. When 'eventignore' lists WinLeave, BufLeave, WinEnter and BufEnter, the BufWinEnter autocmd for terminal buffers still runs, because Neovim fires it.

Differences from main that remain, all measured:

  • No OptionSet event runs while the swap runs. This includes the OptionSet events for options that a BufEnter or BufWinEnter autocmd of the user's sets during the swap. The helper adds OptionSet to 'eventignore' so that setting the option fires no event of its own.
  • A change of 'eventignore' that an autocmd makes during the swap is undone afterwards, because :noautocmd sets the caller's value again.
  • A BufLeave autocmd that closes the split leaves the terminal hidden. The helper returns false and the providers keep the terminal hidden (a new terminal starts and runs, a hidden one stays hidden, a diff in a new tab opens with no terminal). On main the terminal lands in the window that the cursor ends in. A pending diff then shows the difference: on main the terminal replaces the proposal in the user's window, the proposal is hidden and the diff is rejected. Here the proposal stays on screen and the diff stays pending, 20 of 44 pairs of Opus's handler repro (close the current window by nvim_win_close(0), :close or :hide, nested or not). The 24 pairs of the other handlers (:enew, :edit, :buffer, wincmd p, no-op, and the moves with no terminal) have the same outcome as main. In 12 of them (:enew, :edit, :bprevious, both focus modes, both builds) main fires BufWinLeave and BufHidden for the proposal while another window still shows it, and this branch does not.
  • A BufLeave autocmd that shows another buffer X in the split. The terminal shows as on main, and X stays loaded with its text, its running job and its 'bufhidden' as it was, whatever X is: a changed or an unchanged file, a terminal with a running job or an exited one, a scratch buffer, a help or a quickfix buffer, a buffer that a float or a window of another tab also shows. The helper hides X for the swap, and the probe sees no OptionSet run, as on main. Two differences remain. Neovim fires BufWinLeave and BufHidden for X when the swap leaves it (240 of the 288 pairs of the verifier's probe over 12 kinds of X, the pairs where X leaves its last window). Main fires none for X, and fires them for the user's file A instead, while A still shows in another window and X is the current buffer. And a BufHidden autocmd of X sees hide as its 'bufhidden' while the swap runs, when the helper hid X. In the verifier's probe of 12 kinds of X, 288 of 288 pairs equal main's in every field that the probe prints, and in the 80 pairs of the probe of version 9 (the unloaded or wiped X) 80 of 80 equal main's. In the lost-text probe all 40 pairs equal main's.
  • A show without focus, when the autocmd entered a third window before it closed the split: the cursor goes back to the window it was in, as after any show without focus (in version 3 it stayed in the third window). The show logs nothing, and neither do :ClaudeCode and :ClaudeCodeFocus. A new terminal that starts in this shape logs nothing either, and the cursor ends in the window it was in, with focus as well. When a hidden terminal is shown with focus, the cursor stays where the autocmd put it. On main the terminal lands over the third window's buffer, and with focus the call raises Invalid window id.
  • 'eventignore' holds BufLeave for the whole swap, because it cannot name the old buffer. A ++nested BufEnter or BufWinEnter autocmd that leaves a buffer during the swap gets no BufLeave (autocmd BufWinEnter term://* ++nested wincmd p). The layout and the mode are equal to main's. I did not fix this: a fix needs another mechanism, which the stop rule of this work forbids. The README and the helper's comment say it.
  • A BufLeave autocmd that shows another buffer X in the split, the events. Neovim fires BufWinLeave and BufHidden for X, the buffer it put there and left, whatever X is. On main the swap fires them for the user's file A instead, which still shows in another window while X is the current buffer, and fires none for X. A spec pins it.
  • Limits that follow from X really leaving the split. The user's own autocmd causes each one. Because the swap hides X, the user's BufWinLeave and BufHidden autocmds of X run, and each does what it was written to do. I found no way to remove them without a new mechanism. 'eventignore' cannot name X, so ignoring the two events would also drop them for the user's own file (the autosave and diff-answer specs pin them), and the verifier's trial of that change differed from main in 64 other pairs.
    • m1: an autosave autocmd on BufHidden or BufWinLeave that writes the buffer of the event writes X's typed text to disk. 32 of 32 rows, main 0. No work is lost. On main a curbuf-style autosave (:write when &modified) writes X too, because X is the current buffer when main fires the events for A.
    • m2: a BufWinLeave or BufHidden autocmd of X that raises makes the placement raise. 128 rows (the four kinds of X, both builds). The split keeps X, and its 'bufhidden' is back. Main raises nothing. The same autocmd raises on any hide of X.
    • m3: a user BufHidden autocmd that cleans up the hidden buffer removes X. 96 rows: 64 with a scheduled bwipeout! (a terminal's job ends in 16 of them) and 32 with nvim_buf_delete. Main 0. The user's autocmd asks for the removal. Version 9 unloaded or wiped X by itself in the same shapes, and now the user's autocmd decides.
    • m5 (v0.8.0 only): a user BufWinEnter autocmd of the terminal that runs bwipeout! X wipes X as asked. 32 pairs. Main on v0.8.0 keeps X, because it leaves X with a stale window count (:ls shows X as active with no window). Main on 0.12.3 wipes X like this branch.
  • d1 (better than main): a BufLeave autocmd closes the other window of a changed old buffer whose 'bufhidden' is unload, delete or wipe. A plain swap decided before the autocmd ran, so main unloads or wipes the buffer with its text (12 of 16 pairs, 'hidden' on and off, both builds). The helper hides it for the swap and keeps it (16 of 16).
  • Session shape of gpt's round-5 repro ('hidden' off, a BufLeave autocmd that enters the first window, a WinEnter autocmd that swaps the plugin's own buffer into it): the unsaved text stays and the job runs on both trees. On this branch the user's file shows in no window, because the session plugin's own swap replaced it in the first window, while the terminal is in the split. On main the file shows in one window. In 3 more rows per build of Opus's matrix (a hidden show without focus, with 'nohidden', 'bufhidden' unload or wipe), the user's own options unload (2 rows) or wipe (1 row) the unmodified file when the user's window swaps it out. Main keeps the file in a window but leaves the terminal in none. No text, write or job is lost in any of these rows.
  • Rows where this branch lacks an event that main fires: 179 of 1189 on 0.12.3 and 164 of 825 on 0.8.0 lack a BufWinLeave, BufHidden or BufUnload of the user's file by my count. The verifier's rerun with its own script counts 174 and 166. I did not find out why the two counts differ by 5 and 2 rows. They are the shapes where main loads the terminal over the user's file (the redirect: a BufLeave autocmd that enters another window, another tab or a float, or closes the split), so the file loses its window on main and keeps it here. The verifier found that in 161 and 154 of its rows this branch keeps the file in its window, so no event is owed there (I did not count that again). In 12 rows on 0.12.3 and 9 on 0.8.0 the file ends in no window on this branch with no event, and in 3 more rows per build (by the verifier's rerun) the user's own options unload or wipe it, see the session shape above: a WinEnter autocmd of the user's swaps a buffer into the user's window while the terminal is shown without focus. Neovim fires no event for a window change inside a non-nested autocmd. On main the redirect hid the file first.

Evidence

Labels such as F1, N1, m4, m9, P1 and P2 name findings from the independent verifiers' reports on earlier versions. Opus and gpt are the two models that ran verification lanes. Real Neovim 0.12.3 and v0.8.0, headless, a stub sleep as the Claude job, no real claude. Snacks runs on 0.12.3 only, from a clone in my scratch directory (Snacks is not a test dependency). The large matrices (Opus's, gpt's, my swap and edge matrices) ran on 4097d8da. Since then this branch changed lua/ in utils.lua (the pcall around expand removed, put back, and v:errmsg given back), and later the 'bufhidden' hide of the swap (f7dc2d87, 9e0ed389, d3484a9c), in diff.lua (the new-tab re-entry, 507e671a), in native.lua and snacks.lua (the closed-split branch of a hidden show: the cursor and its guard, and the buffer number read into a local) and in docs. The large matrices never ran against the 'bufhidden' hide or the re-entry. The merges of main brought in changes in diff.lua, diff_inline.lua, cwd.lua, terminal.lua, selection.lua, init.lua and others. The large matrices did not run again on those. These ran again in iteration 6, on c4951459 and on main 692b460c: the handler matrix, the F1 repro, real Snacks with 'verbose', and the probes of the verifiers of iterations 4 and 5 (the closed split with the user's window closed too, a wipe of the terminal's buffer, :silent!, :ClaudeCode and :ClaudeCodeFocus, a BufLeave autocmd that closes Claude, and a WinNew autocmd that does). The verifier of iteration 7 ran them again on 1ba631b9 and on main d05b0101, and got the same counts. "Main" is 9bfc39c9 for the large matrices, and 692b460c for everything that ran in iteration 6. The current main, d21f310a, has #536 and #602 among later commits, and #602 changed terminal files that this PR also touches. An independent verifier reran the caller, handler and restore matrices and the 12-kinds probe on the merged head ef27d623 and on main d21f310a: no row is worse than main, and the head's rows equal its earlier run apart from file paths in error text.

Check main this branch
Contract specs (terminal_placement_contract_spec.lua, 21 cases, and terminal_placement_diff_answers_spec.lua, 16 cases: events and order, autosave, mode, diff answers, raising BufLeave, BufWinEnter mapping, WinEnter autocmds, session shape, E937) 37 of 37 pass on both builds (692b460c) 37 of 37 pass on 0.12.3 and v0.8.0 (c4951459), and on v0.9.5 (an earlier head). Iteration 2 of this PR fails 30 of 37
'verbose' repro (F1 of the verifier of iteration 3): a new terminal, a hidden terminal and openDiff in a new tab with 'verbose' at 1 and at 15, and :verbose ClaudeCodeOpen for the first two, 8 rows per build 8 of 8 ok on both builds 8 of 8 ok on both builds, with the same fields as main (16 of 16 rows equal). Iteration 3 (aaeb33c8): 0 of 8 ok on each build, E496 in each. Levels 2 and 9 and the modifier ran too: 28 of 28 ok on the head before the last merges. Run again in iteration 6 with the script of the verifier of iteration 3 (32 runs): main 692b460c 8 of 8 per build, this branch 8 of 8 per build, 16 of 16 pairs equal to main. The spec: 8 of 8 on main and on this branch, on both builds. The verifier of iteration 7 ran the repro on 1ba631b9 and on d05b0101: 28 of 28 pairs equal, all ok. Real Snacks (a hidden terminal shown at 'verbose' 1 and 15 and under :verbose, and a new one at 1): 4 of 4 ok on main and on this branch in iteration 6 and in iteration 7, with no E496
The probes of the verifier of iteration 4, on native (0.12.3 and v0.8.0) and Snacks (0.12.3), focused and not. The autocmd closes the user's window, then enters W and closes the split Invalid window id in every row (the guard that main does not have) ok in every row, the cursor in W, nothing logged (6 of 6 rows: native on both builds, Snacks on 0.12.3, with and without focus)
The same, with an autocmd that wipes the hidden terminal's buffer and closes no window no message no message in 6 of 6 rows. Iterations 4 and 5 logged "closed the terminal's window" in some orders, which was false
:ClaudeCode and :ClaudeCodeFocus on a hidden terminal whose split a BufLeave autocmd closes the command fails with an error nothing logged. Iteration 4 logged a warning and then [ERROR] Failed to show hidden terminal
'verbose' 1 and :silent! ClaudeCodeOpen (v:errmsg) v:errmsg is empty, both builds empty, both builds. Iteration 4 left E496 in it
The probe of the verifier of iteration 5: a BufLeave autocmd closes Claude (:ClaudeCodeClose, terminal.close()) while a hidden terminal is shown by :ClaudeCodeOpen, :ClaudeCode, :ClaudeCodeFocus or ensure_visible, with auto_insert off and on. Native on 0.12.3 and v0.8.0, Snacks on 0.12.3: 24 rows the 16 native rows are ok, and the 8 Snacks rows raise Invalid window id 24 of 24 ok. The 16 native rows have the same fields as main (layout, cursor, mode). Iteration 5 raised Invalid 'buf': Expected Lua number in those 16 native rows
A WinNew or WinEnter autocmd of the show's own split closes Claude (:ClaudeCodeClose or terminal.close()), then :ClaudeCodeOpen on a hidden native terminal: 8 rows raises in 8 of 8 (Invalid 'buf', Expected Lua number) ok in 8 of 8 (the local read of the buffer number)
The verifier of iteration 6's probe: a WinNew, WinEnter or WinLeave autocmd of the show's split closes Claude (terminal.close() or :ClaudeCodeClose), four entry points, native on both builds and Snacks on 0.12.3 (the verifier of iteration 7 ran them again on d05b0101 and 1ba631b9, with the same counts) raises in 72 of 72 rows ok in 72 of 72 rows
A BufEnter or BufWinEnter autocmd of term://* closes Claude (terminal.close() or :ClaudeCodeClose) during a show, through the four entry points, native on both builds and Snacks on 0.12.3: 48 pairs the show raises Invalid window id in 38 of 48 rows the same 38 rows raise, and the 48 pairs are equal in every field (layout, cursor, mode, notes). See the follow-up P2
The probe of the lost-text path: a BufLeave autocmd shows another changed buffer X in the split. Two shapes (an autocmd that creates X, and one that moves the user's unsaved file into the split), 'bufhidden' empty, hide, unload, delete and wipe, 'hidden' on and off, the helper on 0.12.3 and v0.8.0: 40 pairs main keeps the text of X and shows the terminal in 40 of 40 pairs version 7 loses the text in 24 of 40 pairs. Version 8 shows the terminal in 12 of 40. This head keeps the text and shows the terminal in 40 of 40, and its rows equal main's in 40 of 40
The events of X in the first shape (20 pairs: BufLeave, BufWinLeave, BufHidden, BufUnload, BufWipeout, BufDelete, and the OptionSet runs for 'bufhidden') OptionSet 0 runs. X gets no leave event, because its own swap runs inside the swap of main OptionSet 0 runs in 20 of 20 pairs. X gets one BufWinLeave and one BufHidden, and no unload or wipe event. Its 'bufhidden' is as it was in 20 of 20
The caller matrix of the verifier of version 8 (native start and show, each with and without focus, Snacks show with and without focus, and the diff in a new tab, with 8 BufLeave handlers and option sets: 280 rows on 0.12.3 and 200 on v0.8.0, and I reran his scripts) reference Version 8: 86 rows worse than main on 0.12.3 and 56 on v0.8.0 (E37 in a new native terminal, the terminal not shown, the diff in another tab). Versions 9 and 10: 0 rows worse on both builds. Version 10 (d3484a9c): 0.12.3 has 120 better, 24 mixed and 136 same, and v0.8.0 has 92 better, 16 mixed and 92 same. The mixed rows are all closed-split rows (close-split, tab-close), where main shows the terminal in a window of the user's or raises, and this branch leaves it hidden, as the differences above say. The next :ClaudeCode shows it in a new split with one job
The same caller matrix with a terminal or a scratch buffer as X (the verifier of version 9: 560 rows on 0.12.3 and 400 on v0.8.0) reference Version 9: 84 rows worse than main on 0.12.3 and 56 on v0.8.0. Version 10: 0 rows worse on both builds (0.12.3: 240 better, 48 mixed, 272 same. v0.8.0: 184 better, 32 mixed, 184 same). The 48 and 32 mixed rows are the same closed-split rows, once for each kind of X
The probe of the unloaded or wiped X (the verifier of version 9: a BufLeave autocmd shows X in the split, X a changed file, a terminal with a running job, a scratch buffer or an unchanged file, 5 'bufhidden' values, 'hidden' on and off, both builds: 80 pairs) main keeps X loaded, with its job and text, in 80 of 80 pairs version 9: X unloaded or wiped in 42 of 80 pairs (the job dead in 14, the scratch text gone in 14). Version 10: 80 of 80 pairs equal main's
The probe of 12 kinds of X (the verifier of version 10: a changed buffer, a running terminal, an exited terminal, a nofile scratch buffer, an unchanged file, a help buffer, a quickfix buffer, an unlisted changed buffer, a scratch buffer from nvim_create_buf(false, true), X also in a float, X in a float that the autocmd closes, X also in a window of another tab; 6 'bufhidden' values, 'hidden' on and off, both builds: 288 pairs) reference version 9: 116 pairs worse. Version 10: 288 of 288 pairs equal in every field (X loaded, text, job, call, split, 'bufhidden', 'buflisted', X's window count, current window, mode, OptionSet runs). The only difference is the events: BufWinLeave and BufHidden for X in 240 pairs, and none on main
The restore of 'bufhidden' (the verifier of version 10: a changed file, an unchanged file, a terminal and a scratch buffer as X, 15 scenarios (a raising BufWinLeave, BufHidden or BufWinEnter, a wipe in the terminal's BufWinEnter, a show in it, a BufHidden that sets 'bufhidden', the nested placement from X's BufHidden, autosaves, mkview), 4 'bufhidden' values, 'hidden' on and off, both builds: 960 pairs) reference hide left on X: 0 pairs. OptionSet runs: 0. The 'bufhidden' values that differ from main's come only from the user's own wipe or the user's own autocmd that sets the option
The old buffer whose other window an autocmd closes (d1: a changed buffer, 'bufhidden' empty, unload, delete, wipe, 'hidden' on and off, both builds: 16 pairs) main keeps the text in 4 of 16 pairs and loses it in 12 version 10 keeps the text in 16 of 16 pairs
The K1 probe of that verifier (a new native terminal starts, and a BufLeave autocmd shows a changed buffer with 'bufhidden' wipe or unload, with and without focus, on both builds: 8 rows) Version 8 raises E37 in 8 of 8 rows. Main raises in none This head raises in none, shows the terminal in 8 of 8, keeps the text, and a second entry adopts the one Claude job
The K2 probe of that verifier (the real native terminal, a diff with open_in_new_tab, and a BufLeave autocmd that enters the first tab and closes the new split) Main builds the diff in the new tab on 0.12.3, and replaces the user's window in tab 1 with the terminal. On v0.8.0 main fails the open (wincmd E16). Version 8 builds the diff in tab 1 and leaves the new tab empty This head builds the diff in the new tab on both builds, and tab 1 keeps the file and the terminal
The orders probe of the verifier of iteration 5 (60 rows: the autocmd closes the split and wipes the buffer, closes Claude through the API, runs :ClaudeCodeOpen or :ClaudeCode, or wipes the buffer and runs :ClaudeCode, on native and Snacks, with 4 entry points) reference no row raises where main does not, and 14 are better. 13 are equal, and 33 differ: the shapes where the autocmd runs :ClaudeCodeOpen or :ClaudeCode itself (see the follow-ups), and the mode after a focused show
The verifier's own_probe (m1, m2, 'switchbuf', a nested option): 6 scenarios × 2 builds reference layout and mode equal to main's in 8 of 12 rows. The other 4 are the closed-split rows (the terminal is hidden here, see above), where the cursor after an unfocused show is now in the window it was in, as on main (iteration 3: the third window). The events differ as listed above (the BufLeave of m1, the OptionSet events of main, the events of main's redirect) and in the two nested rows (autocmd BufWinEnter term://* ++nested wincmd p). In the focused nested row this branch fires 12 events and main fires 9: four more and one fewer. The four more: after the nested wincmd p, the provider's focus step enters the split again (BufLeave and WinLeave of the file, then WinEnter and BufEnter of the terminal), where main's API swap had restored the window with no events. The one fewer is the BufLeave of the terminal that main fires for the nested wincmd p (the 'eventignore' limit above). In the unfocused nested row main fires 13 events and this branch fires 8. Main fires five more: two BufLeave of the terminal, and a second WinLeave of the terminal, WinEnter and BufEnter of the file. The counts are the same on both builds. I did not find out why main fires that set. The final layout and mode are equal on both builds. I did not change this: a change needs a mechanism beyond this one
Opus's matrices, run by his scripts (1189 scenarios on 0.12.3, 825 on 0.8.0: redirect, event traces, 17 adversarial autocmds × 5 option sets, nested context) reference 0.12.3: 461 same, 728 different, 0 worse, 339 better. 0.8.0: 269 same, 556 different, 0 worse, 255 better. A first run had 19 worse rows (a hidden new terminal when the user's BufLeave autocmd wiped the user's file raised Invalid window id). Fixed in 4097d8da, with a spec. The 19 rows ran again
The same matrices, a richer score (events of a hidden buffer, mode, writes, text, extra WinEnter) reference the flags that remain: 12 and 9 rows lose BufWinLeave and BufHidden of a file that the user's own non-nested WinEnter autocmd hid (see above). 28 and 19 rows have one more WinLeave and WinEnter than main. In the rows I opened, one for each of the six actors, main raised, crashed or lost the terminal, or the provider goes back to the user's window after the user's autocmd closed the split. Exit codes of the matrices: main 32 runs on 0.12.3 and 4 on 0.8.0 end with a signal or an error, this branch 2 and 0 (the 2 are Snacks with 'bufhidden' wipe, which main crashes in too)
gpt's matrices (108 pairs: redirect, autosave, public actors, diff in a new tab, Snacks fixbuf) reference 0 worse. Redirect rows without the terminal in the user's window: 10 of 30 on main, 26 of 30 here on 0.12.3 (the 4 left are the first start of a Snacks terminal), 6 of 18 and 18 of 18 on 0.8.0. Diff in a new tab: 0 of 12 on main, 12 of 12 here. Autosave rows and public actors: equal. All the rows that differ with the same status are rows where main's terminal is in the user's window
gpt's focused scenarios (F1 autosave, F2 pending diff, F3 BufWinEnter mapping, F4 WinEnter that closes the split, raising autocmds, 32 scenarios × 2 builds) reference equal in 62 of 64 pairs. Different: the pending diff with a close-the-current-window autocmd (see above), on both builds
Opus's WinEnter startinsert repro (diff in a new tab, unfocused hidden show, Snacks, 4 event sets, 40 runs) the mode and the typed key equal in 40 of 40
Opus's F2 repro: a pending diff and 11 BufLeave handler kinds × focused and not × 2 builds 44 pairs 24 equal, 20 different (the close-the-split kinds)
Opus's stale-diff repro (a pending diff and a BufLeave autocmd that closes the other windows of the proposal), 8 runs the diff is rejected and answered equal. In iteration 2 it stayed pending
gpt's hidden-autosave repro disk saved, 3 of 3 per build disk saved, 3 of 3 per build. The assert of the script that BufLeave runs inside the split holds again
gpt's visible-scratch BufHidden repro, BufWinEnter facts (16 pairs), Opus's round-5 Snacks repro (24 runs) equal. 24 of 24 shown with the terminal in its split, where main redirects
Handler matrix: autocmds of 19 shapes that enter, create, close, wipe or raise, × 6 option sets, per path (912 rows on 0.12.3, 708 on 0.8.0), with a mode, text and job check reference 0 worse. 178 and 142 rows better. 0 rows with a wrong mode, a lost file, text or job. 89 rows on 0.12.3 and 60 on 0.8.0 end in another mode than main. In each of them main put the terminal in the user's window or in the other tab, or raised, and this branch ends in the mode that the focus asks for (the verifier's list). Run again in iteration 6 against main 692b460c: the same counts, and every row has the same result as in the run of iteration 5 (0 of 912 and 0 of 708 differ). The verifier of iteration 7 ran it on 1ba631b9 against main d05b0101: the same counts, and the head's rows equal the run of iteration 6 in 912 of 912 and 708 of 708. I ran it again on 8b28b377: 912 rows with 0 worse, 178 better, 610 same and 124 changed with the same score, and 708 rows with 0 worse, 142 better, 484 same and 82 changed. The rows of main are those of 692b460c, and no file of lua/ changed on main since. Main ends with no result (a crash) in 5 rows per build, and this branch in none. The m2 shape is not one of its handlers. On d3484a9c the rows are byte-identical to those of cd136bbf on both builds: 912 rows with 0 worse, 178 better, 610 same and 124 changed, and 708 rows with 0 worse, 142 better, 484 same and 82 changed. This matrix has no handler that shows another buffer in the split, so the probes above cover that shape
My swap matrix (native 18 rows, Snacks 12 rows per build) 10 of 30 ok on 0.12.3, 6 of 18 on 0.8.0 26 of 30 and 18 of 18
My edge matrix: 216 rows on 0.12.3, 144 native rows on 0.8.0 ok 28 of 217 and 16 of 146 ok 216 of 216 and 144 of 144
openDiff in a new tab with a BufLeave autocmd that enters another window, 24 runs per build ok 6 of 24 ok 24 of 24
Event traces, no redirect: 27 shapes on 0.12.3, 21 on 0.8.0 reference the sequence of events is equal in all of them, and the trace is exactly equal in 21 and 18. In the other 6 and 3 (the shapes that ignore BufEnter) main also fires OptionSet for 'eventignore' twice. Iteration 2 had one more WinLeave and WinEnter pair in each
Snacks fixbuf repro, 24 runs terminal in the user's window terminal in its split
#485's close probe, 24 runs, local trial merge of this branch into #485's head #485's head alone switched the user's window in 20 of 24 trial merge: 24 of 24 ok, 0 switched. The 10 diff_new_tab_* specs of #485 and the 3 placement specs pass in it, on both builds

Not covered

  • Snacks.terminal.open for a terminal that Snacks creates for the first time. Snacks makes that window itself, so a BufLeave autocmd still redirects it, as on main. It is 4 of the 12 Snacks rows.
  • WinNew autocmd that opens and enters its own window during the provider's :vsplit. The terminal replaces that window's buffer, on main and here (12 of 12 rows). I did not change it.
  • Callers inside an autocmd. The helper makes the plain swap there, so an autocmd that calls :ClaudeCode from a nested BufEnter still redirects, as on main. A spec pins that the plain swap fires BufLeave once.
  • An unsaved file that no other window shows, with 'hidden' off. A plain swap raises E37 there, and the helper makes the plain swap, so it raises E37 too, as on main.
  • Neovim 0.10 and 0.11. I ran 0.12.3, v0.9.5 (the four placement and diff specs only) and v0.8.0. Snacks ran on 0.12.3 only, and has no integration spec, because snacks.nvim is not a test dependency. The Snacks sites have unit specs with a fake window model, and the Snacks rows above ran against a real clone.
  • The large matrices after 4097d8da. Later commits of this branch change lua/ in utils.lua, native.lua, snacks.lua and diff.lua (see Evidence), and main's merges changed other files. The unit and integration specs, the handler matrix, the F1 repro, the N1 and orders probes, and the closed-split and nested rows of own_probe ran again (on c4951459, before the 'bufhidden' hide, the diff's re-entry of the new tab and main's fix(diff): answer a call that a close handler lets register while a diff is replaced #536 and fix(terminal): wait for the child's exec before the snacks close and the owned stop #602. The caller, handler, restore and 12-kinds matrices ran again on ef27d623). The handler matrix ran again on 8b28b377. Opus's, gpt's and my swap and edge matrices did not.
  • A user's BufHidden autocmd on X that closes Claude now runs during the swap, and it closes Claude. Nothing is lost.
  • Follow-up (m9, from iteration 3, not worse than main): a BufLeave autocmd that runs :ClaudeCodeOpen or :ClaudeCode while a hidden terminal is shown. This branch shows the terminal in two windows (the split and the nested split). Main shows it in the nested split only and leaves the split with the user's file. Each tree ends with one extra window, and neither raises. I did not change it.
  • Follow-up (P1, pre-existing, not worse than main): a BufLeave autocmd that closes the window a native show started in, while the split survives. The show without focus ends with nvim_set_current_win(original_win) (native.lua:500 at ef27d623) and raises Invalid window id, on main and on this branch (the same row on 0.12.3 and v0.8.0, 12 of 12 rows equal in the verifier's probe). Snacks does not raise. I did not change it.
  • Follow-up (P2, pre-existing, not worse than main): a BufEnter or BufWinEnter autocmd of the terminal that closes Claude during a show. The autocmd runs inside the swap, after the terminal is in the split. The show then enters the window that the close removed and raises Invalid window id (native.lua:494 at ef27d623 in the focused native show, and snacks.lua:438, the full-height nvim_win_set_height step of the Snacks show). 38 of 48 rows raise on main and on this branch, and the 48 pairs are equal. I did not change it. AGENTS.md says not to add a check of the split after the swap for a close that a user autocmd made.
  • The swap after termopen in a native start is a plain swap when the split then shows a changed buffer that a plain swap can refuse to leave, as on main. The verifier of version 10 probed it: a TermOpen autocmd that shows another buffer in the split never reaches that swap (nvim_buf_call puts the terminal buffer back into the split when it returns), and a BufWinEnter autocmd of the first placement does reach it. 96 of 96 pairs equal main's for each.
  • No integration spec runs Snacks (snacks.nvim is not a test dependency). The 'verbose' integration spec covers the native provider and the diff. The Snacks sites have unit cases with a fake window model and a fake expand, and I ran real Snacks with 'verbose' by hand (4 of 4 ok, in the Evidence table).

Tests

  • tests/integration/terminal_placement_contract_spec.lua (21 cases) and terminal_placement_diff_answers_spec.lua (16 cases): what a plain swap gives in shapes where it already works. They use the public commands and openDiff, so they pass on main: 37 of 37 on Neovim 0.12.3 and v0.8.0. They failed 30 of 37 on iteration 2 of this PR. A shared runner, tests/helpers/placement_child.lua, starts one headless Neovim for each case, so a crash or a hang fails one case.
  • tests/integration/terminal_placement_spec.lua (114 cases) and terminal_placement_shapes_spec.lua (13 cases): the redirect, the events of the swap against a plain swap, the closed split (the cursor after a hidden terminal's show and that the show logs nothing, the user's window closed too, :ClaudeCode and :ClaudeCodeFocus, a wipe of the terminal's buffer), Claude closed by a BufLeave autocmd (:ClaudeCodeClose or terminal.close(), through each of the four entry points) or by a WinNew or WinEnter autocmd of the show's own split (each through the four entry points, with and without focus), the buffer that an autocmd showed in the split (two shapes of a changed buffer with each 'bufhidden' value, for the helper and for each caller: a new native terminal, a native show with and without focus, the Snacks show with and without focus, and the diff in a new tab; and, for the helper, a scratch buffer, a terminal with a running job and an unchanged file with 'hidden' off and each 'bufhidden' value), the 'eventignore' value, the shapes where the helper makes a plain swap, and the diff in a new tab. Red on main 692b460c, counted when the placement spec had 102 cases (it now has 114): raw, 61 of 102 placement cases and 12 of 13 shapes cases fail on 0.12.3 (60 and 12 on 0.8.0), and most of them fail for lack of the helper. With a plain-swap alias for the helper in a copy of main (it honors ignore_bufenter), 31 of 102 on 0.12.3 and 32 on 0.8.0, and 12 of 13 shapes cases, fail, and they fail for the behavior. On iteration 5 (bdd389db), 37 of 102 and 2 of 13 fail on both builds, and 17 of the 74 unit cases of the helper and the Snacks show fail. On version 8 (8b28b377), 17 of 102 and 2 of 13 fail on both builds, and 10 of the 74 unit cases fail: the cases of a changed buffer in the split for each caller, and the diff case of an autocmd that enters the first tab. On version 6 (64c43323) all 33 pass, because its Lua is right: only a mutant shows the gap.
  • tests/integration/terminal_placement_verbose_spec.lua (8 cases): the native new terminal with 'verbose' at 1 and at 15, under :verbose ClaudeCodeOpen and under :silent!, the hidden terminal at 1, under the modifier and under :silent!, and openDiff in a new tab at 1. Each case also asserts that v:errmsg is empty. It goes through the public commands, so it passes on main (8 of 8 on both builds). On iteration 3 (aaeb33c8) it fails 6 of 6 of the cases that existed then, and on iteration 4 the 2 :silent! cases fail. On this head it passes 8 of 8.
  • tests/unit/utils_win_set_buf_spec.lua (44 cases, a fake that logs the calls in order, with a fake expand that raises outside an autocmd when 'verbose' is set, or sets v:errmsg under :silent!), and tests/unit/terminal/snacks_unfocused_show_spec.lua (35 cases: the Snacks unfocused show through the real helper, with the same fake expand, a fake nvim_set_current_win that raises for an invalid window as Neovim does, a fake nvim_buf_is_valid that raises for a nil buffer as Neovim does, the closed split with the cursor and nothing logged, the user's window closed too, a wipe, and Claude closed by a BufLeave autocmd or by an autocmd of the split, each with and without focus). The fake has no 'buftype', so it cannot tell a file from a scratch buffer: the integration cases pin the unchanged file. The other provider specs fake a window model with no buffer options, so they use tests/helpers/plain_placement.lua, a plain swap for the helper.
  • Mutants of the helper and its call sites: 100 mutants, each one text edit of utils.lua, native.lua, snacks.lua or diff.lua, with a syntax check of the result before it runs. They are my mutants of the earlier rounds (a guard dropped or inverted, an event not fired or not ignored, the swap by API or without !, a validity check dropped, a call site back on a plain swap, v:errmsg not given back, the toggles' error back), the 13 of the verifier of iteration 4 (V1 to V13), and 7 for this round: the helper's call sites that read the module's record of the buffer (bufnr, term.buf) after the split or the swap, and the warning put back with and without that read (L1 to L7). All 100 fail at least one spec: 74 fail the unit specs, and 26 fail only the integration specs, on Neovim 0.12.3. The unmutated control passed in each of the 3 workers. V6 and V10, the two mutants that drop nvim_win_is_valid(original_win) in the native and the Snacks closed-split branch, survived every test of iteration 4. V6 fails the integration case with the user's window closed too, and V10 fails the matching Snacks unit case. These 100 ran on c4951459. They had no mutant at the focused Snacks call on its own (my L2 changed both Snacks calls in one edit), and the focused call had no test, which the verifier of iteration 6 found (his mutant Lk). On 1ba631b9 my run of the verifier's 19 mutants and an unmutated control gave: all 19 die (9 by the unit specs, 10 by the integration specs only), and the control is clean on both builds. The verifier of iteration 7 ran 25 (those 19 and 6 new ones, for example the local read moved after the split): all 25 die, and his control stays green. I ran the verifier's 19 mutants again on an earlier head of this branch (not rerun on the final head ef27d623): all 19 die with the same split (9 by the unit specs, 10 by the integration specs only), and the control is clean on both builds. For the helper that hides the buffer for the swap and the fix of the diff I ran 16 mutants of my own: the restore of 'bufhidden' dropped, the hiding dropped, the restore without its validity check, no pcall around the swap, the error re-raised with a position, the old value not restored, the condition inverted or dropped, the option set by assignment (which runs OptionSet autocmds) for the hide and for the restore, the check of the buffer without its 'hidden', other-window or changed leg, and the three changes of the diff's re-entering of the new tab. All 16 die. The 13 mutants of the helper die by the unit specs, and 10 of them by the integration specs too. The restore without its validity check dies by the unit case only, because setbufvar on a wiped buffer does nothing on both builds, and the check is a guard after user autocmds. The error re-raised with a position survived my first run, because the unit case compared the error without its position, and it dies now. The three mutants of the diff die by the integration specs only. Lk dies by the new Snacks case with focus, and Lk2 (the unfocused call) by its twin. For d3484a9c I ran 33 more mutants of the helper and the diff (the new condition and each clause of it, old read after the autocmds, the hide and the restore, the guards of the plain swap, and the diff's re-entry of the new tab): 30 die by the unit specs, and the 3 of the diff die by the integration specs. The 15 mutants of the verifier of version 9 that apply (H1 to H5, H7 to H11, H13, H14, K2a to K2c) die. The verifier of version 10 ran V1 to V10 on the condition. V10 (skip the hide for an unchanged buffer with an empty 'buftype', a normal file) survived every unit and integration spec until the 4 cases of an unchanged file were added. Those 4 fail under V10 on both builds.
  • verify-terminal-lifecycle: the facts of the native start read utils.win_set_buf_in_split instead of nvim_win_set_buf. check.sh passes and covered.lock is recorded. In iteration 6 I read the model for show_hidden and cc_show before I recorded: it describes the show of a hidden terminal as "a window shows the terminal" and does not model the closed-split branch. The change removes the warning there, keeps the cursor return, reads the buffer number into a local, and adds no tracked state (winid, term.win, the job). verify-diff-lifecycle: check.sh passes, and covered.lock has only file hash changes (no block of the model changed).

Gate

On ef27d623 (this branch with main d21f310a merged), with GNU timeout first on PATH, a stub claude after it (no real claude) and a scratch CLAUDE_CONFIG_DIR:

  1. mise run clean && mise run all exits 0. treefmt changes no file. Lint: 0 warnings and 0 errors in 213 files. Busted: 2013 successes, 0 failures, 0 errors, 43 pending.
  2. The CI steps for the integration job, run locally (scripts/run_integration_tests_individually.sh): Neovim 0.12.3, 1041 successes, 0 failures, 0 errors, in a plain run and in a run under a TMPDIR with a space and a %. Neovim v0.8.0: 1037 successes, 0 failures, 0 errors, in both runs. No $XDG_CACHE_HOME/nvim appears (the real-cache check). The runs include the contract, diff-answers, verbose, shapes and placement specs.
  3. All five check.sh pass (verify-diff-lifecycle, verify-terminal-lifecycle, verify-mention-queue, verify-selection-tracking, verify-ws-connection). Since version 9 the change in utils.lua is the condition of the hide and comments. The helper adds no tracked state (winid, term.win, the job, the active diffs) and no new caller path. No fact reads FALSE, and I changed no fact. Both covered.lock files were recorded again after the last merge (main's fix(terminal): wait for the child's exec before the snacks close and the owned stop #602 had taken the terminal lock first), after I read the diff. check.sh --mutants passed on iteration 4's head (31bd8931), and I did not run it again.
  4. The mutants are in Tests: 33 of mine on the condition and the diff die, the verifier's 15 of version 9 that apply die, and V1 to V10 of the verifier of version 10 die, V10 only since the 4 cases of an unchanged file.
  5. The handler matrix (912 rows on 0.12.3 and 708 on v0.8.0), the caller matrices (280 and 200 rows with a changed X, 560 and 400 with a terminal or a scratch buffer as X) and the probes of the unloaded or wiped X (80 pairs), of 12 kinds of X (288 pairs) and of the restore (960 pairs) ran on d3484a9c, and the verifier of version 10 ran them on it too: 0 rows worse than main in every one. Since d3484a9c this PR changed lua/ only in comments of utils.lua (checked by hash of the file without its comment lines) and in the README of terminal/. The other changes of lua/ since then (server/tcp.lua, diff.lua, terminal/native.lua, terminal/owned.lua, terminal/snacks.lua and READMEs) came with main's merges.

The verifier of version 10 ran mise run all on d3484a9c and on a merge of it with main f28eba7d: rc 0 and 1958 successes, 0 failures, 0 errors, 41 pending.

Main moved since bcf1f084 by eight commits (skills, the format check, the CI workflow spec, the integration runner, #536 and #602), and I merged them. The last merge took #602 after it had recorded the terminal lock.

No item failed on any complete run of this head.


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

…e autocmd can redirect it

`nvim_win_set_buf` runs the BufLeave autocmds of the buffer it replaces inside the call. When one of them enters
another window, Neovim loads the new buffer into that window, even though the call names another one. A user autocmd of
that kind put the Claude terminal into the user's window.

`utils.win_set_buf_in_split` runs the events of the leaving buffer before the swap, returns the cursor to the split,
makes the swap under `:noautocmd`, and runs BufEnter and BufWinEnter in the split. An autocmd that enters another window
now only moves the cursor. The events and their order are those of a plain swap, so an autosave autocmd still runs before
the cursor leaves the file, no autocmd sees a change of 'eventignore', and a BufWinEnter autocmd for `term://*` runs in
the split. A buffer that the swap would unload gets the plain swap.

The native provider (new, hidden and repaired swaps), the Snacks provider (focused and unfocused show) and the diff's
terminal in a new tab use the helper. It replaces `win_set_buf_without_bufenter`, which only the diff and the unfocused
Snacks show called.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…eovim

A BufLeave autocmd of the user's that enters another window used to make Neovim load the terminal into that window. The
cases run the native provider (new, hidden and repaired swaps) with a run-once and an every-time autocmd, and the helper
itself with an autocmd that enters a window that showed the buffer, one it created, or one it closed. Further cases pin
that the helper fires the events of a plain swap with the same current window and buffer, that an autosave still runs, that
no OptionSet autocmd for 'eventignore' runs, and that a BufWinEnter autocmd for `term://*` runs in the split.

A second file runs each shape in its own headless Neovim, so a crash or a hang fails one case: the window the autocmd
enters is in another tab or the same tab, and 'hidden', 'bufhidden' of the file or of the terminal, and an unsaved file
decide whether a buffer is unloaded, hidden or refused with E37. It includes the diff's terminal in a new tab. A last case
pins what happens when the autocmd also takes the file's other window away and 'hidden' is off: Neovim refuses the swap
with E37, nothing is lost and the next open works.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
… leave event for a closed split

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…ocmd and that no modeline is read

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…ent helper

The change replaces `nvim_win_set_buf` calls that show the Claude terminal in a split with `utils.win_set_buf_in_split`.
`diff.lua` changes at one call in `display_terminal_in_new_tab`, which no covered block contains. The model describes how
an openDiff answer resolves, and the call neither resolves, closes, registers nor answers a diff.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…swap does after BufLeave

Neovim checks that `nvim_win_set_buf` can leave the old buffer before it runs BufLeave autocmds. The placement runs
BufLeave first and swaps after, so an autocmd that closes the old buffer's other window made the swap raise E37 for an
unsaved file under 'nohidden', where a plain swap went on and loaded the terminal into the original window.

The helper only takes the placed path when that check passes at the start: the buffer shows in another window, or it
stays loaded when hidden. The swap now uses `:noautocmd buffer!`, which is the same swap without the second check. The
file stays loaded and hidden with its edits, as after a plain swap, and the terminal lands in its split.

The two cases in the spec that pinned E37 now pin that the open works.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/README.md
…placed in its split

The placement fired BufLeave, BufWinLeave and BufHidden by hand and decided once, before a user's BufLeave autocmd ran,
whether the split was the last window of the old buffer. An autocmd that closed or showed a window of that buffer then
made the helper skip events Neovim owed, fire events for a buffer that was still visible, and swap with `buffer!` so that a
buffer got hidden, unloaded or wiped with no event. A diff proposal then missed its reject-when-hidden step, an autosave on
BufHidden did not run, and a cleanup autocmd could wipe a visible buffer. A hand-fired event also does not lock the buffer,
and it fires inside a non-nested autocmd, where Neovim fires none.

`utils.win_set_buf_in_split` now fires no leave event. Under `:noautocmd` it puts the terminal buffer into the split while
the old buffer still shows in the window that the split was made from, so no buffer loses its last window. It goes back to
that window under `:noautocmd` and enters the split with `nvim_set_current_win`. Neovim fires the events of that move:
BufLeave for the old buffer, WinLeave, WinEnter and BufEnter for the terminal. A BufLeave autocmd that enters another
window only moves the cursor, and the helper puts the cursor back in the split, as a plain swap leaves it.

Neovim fires no BufWinEnter for that move, so the helper fires it, in the split, for the buffer in the split, but only when
Neovim fired some event of the move. Inside a non-nested autocmd Neovim fires none, and neither does a plain swap.

The events differ from a plain swap in two things: WinLeave and WinEnter run once more, and BufLeave runs in the other
window. With no other window of the tab that shows the old buffer, the helper makes the plain swap.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…shapes that broke it

The events of the placement are pinned against a plain swap, with the one more WinLeave and WinEnter and the BufLeave in the
other window. They are exactly the plain swap's when no other window shows the buffer. BufWinEnter fires inside a nested
autocmd and not inside a non-nested one, as for a plain swap.

A BufLeave autocmd that closes the window of the file, with 'hidden' on, off and with 'bufhidden' wipe, must leave the
events of the file's last window to Neovim: a BufHidden autosave runs once, and BufWinLeave, BufUnload, BufDelete and
BufWipeout fire once each. A pending diff whose proposal loses its last window resolves. A scratch buffer that a BufLeave
autocmd keeps visible gets no BufHidden cleanup.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
A call that an autocmd of the move starts, such as a WinEnter autocmd that runs :ClaudeCode, cleared the group of the call
that was still running, so that call could miss the enter events and skip its BufWinEnter.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…ws, and the diff terminal's BufEnter

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
#	lua/claudecode/terminal/README.md
#	lua/claudecode/terminal/native.lua
…another tab

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
# Conflicts:
#	.agents/skills/verify-diff-lifecycle/covered.lock
…lain swap

Events and their order, an autosave on BufHidden, the mode after a startinsert autocmd, the answer of a pending diff, a raising BufLeave autocmd, a BufWinEnter terminal mapping, WinEnter autocmds, a session plugin's buffer switch and the E937 refusal. They go through the public commands, so they pass on main (37 of 37 on Neovim 0.12.3 and v0.8.0) and on any helper that keeps a plain swap's behavior. Iteration 2 of the helper fails 30 of them.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…lets Neovim fire the rest

The events of the swap are a plain swap's, BufLeave runs in the split, an autocmd that closes the split leaves the terminal hidden, and the helper makes a plain swap where one could refuse. On main, with a plain-swap alias for the helper, 16 of the 51 placement cases and 8 of the 10 shape cases fail, on Neovim 0.12.3 and v0.8.0. The provider specs that fake a window model use a plain swap for the helper, which has its own specs.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…other events of it

The helper fires BufLeave of the old buffer with :doautocmd, in the split, outside autocmds, puts the cursor back in the split when an autocmd moved it, and swaps with buffer! under :noautocmd with BufLeave and OptionSet in 'eventignore'. Neovim fires BufWinLeave, BufHidden, BufUnload, BufEnter and BufWinEnter itself, with its locks. No extra WinEnter or WinLeave runs, so a startinsert autocmd for terminal windows, a WinEnter autocmd that closes the split, and a BufHidden autosave keep what they had with a plain swap. When an autocmd closes the split or wipes the terminal buffer, the helper returns false and the providers leave the terminal hidden.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…hen a new terminal stays hidden

When a BufLeave autocmd of the user's wipes the file, the split and the window it was made from close, and the terminal stays hidden. open_terminal then went back to the closed window and raised Invalid window id. The fix checks the window first.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…ufLeave autocmd closes the terminal's split

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
expand("<abuf>") raises in no context that I probed (autocmds of a dozen events, timers, job callbacks, Neovim 0.8 and 0.12), so the pcall hid nothing.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…the native start

open_terminal puts the new buffer into its split with utils.win_set_buf_in_split now, so the two facts that read the swap after the mark look for that call. SKILL.md and the model comment name it, and one more line lists a BufLeave autocmd that closes the split as not modeled.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…what happens to a wiped buffer

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…e models

Both read the placement helper now. Only file hashes changed for the diff model: its covered blocks did not change. For the terminal model the open_terminal, show_hidden_terminal and cc_show blocks changed, the two facts that read the swap after the mark look for utils.win_set_buf_in_split, check.sh --mutants catches every guard, and check.sh passes.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
…elper made unneeded

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
@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 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: 8b28b3777f

ℹ️ 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".

Comment thread lua/claudecode/terminal/native.lua Resolved
Comment thread lua/claudecode/diff.lua Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

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

Reviewed commit: 8b28b3777f

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.

… changed buffer in the split, and document the callers of a false return
… swap, whether or not it is changed

A plain swap leaves a terminal with a running job, a scratch buffer and an unchanged file that a BufLeave autocmd showed in the split. buffer! unloaded or wiped them when 'hidden' is off or their 'bufhidden' says so.
…fer whose other window an autocmd closed, and that the swap fires BufWinLeave and BufHidden for X
@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 Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: ef27d623f6

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

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

Reviewed commit: ef27d623f6

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
ThomasK33 added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit 4b788d5 Nov 13, 2026
9 checks passed
@ThomasK33
ThomasK33 deleted the scratch/term-placement branch November 13, 2026 15:37
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 14, 2026
## Why

Claude can call `close_tab` on a diff that opened in a new tab
(`diff_opts.open_in_new_tab = true`) while the user is in Normal mode in
their file. The user then lands in **Insert** mode in that file, and the
next keys they type edit it. With the default `terminal.auto_insert =
true` this happens with the native and the Snacks provider, in the split
and the unified layout.

It needs the Claude terminal of the diff tab to be the current window of
that tab: the user focuses it (it enters terminal mode, as documented),
leaves terminal mode with `<C-\><C-N>`, and Claude then closes the diff.
The same happens when the user goes back to the first tab with
`:tabfirst` after that, so the diff tab waits behind with the terminal
current. A user who is still in terminal mode when `close_tab` arrives
is not affected: `mode()` reads `t` during the cleanup, so the handler's
`n` check fails. With `terminal.auto_insert = false` the diff registers
no handler and the file stays in Normal mode.

Two more cases leave the user in the wrong place because the diff tab
stays open after `:tabclose`:

- The user closed their other tabs, so the diff tab is the last tab page
and `:tabclose` refuses it (E784). The cleanup deleted the proposal
buffer, the cursor fell into the Claude terminal, and the diff's handler
started terminal mode there. The handler stayed registered after the
diff.
- `'nohidden'` is set and a modified buffer of the user's in the diff
tab stops `:tabclose` (E37). On main the cursor ended in Normal mode,
with no handler left, when it was in the proposal window, and in Insert
mode in the user's file when the terminal was the diff tab's current
window.

coder#473 kept a new diff in Normal mode when it opens, and coder#478 drops the
terminal's `BufEnter` handler when the diff's terminal window closes.
The close itself still had the hole coder#473 closed for the open.

## Cause

`_cleanup_diff_state` and the unified layout's `cleanup_inline_diff`
made the diff tab current again only to run `:tabclose` there. Entering
a tab page runs `BufEnter` for the buffer in its current window. When
that window shows the Claude terminal, the diff's own `BufEnter` handler
(and Snacks' `auto_insert` handler) call `startinsert`. `:tabclose` then
moves the cursor to the user's file, and the pending insert starts
there.

A `startinsert` and `ModeChanged` trace of `close_tab` on main (native
provider, split layout, cursor left the terminal with `<C-\><C-N>`):

```
BufEnter     win=<terminal> tab=2
startinsert  from the diff's BufEnter handler < _cleanup_diff_state win=<terminal> tab=2
ModeChanged  nt->n   hello.txt tab=1
ModeChanged  n->i    hello.txt tab=1
```

Snacks adds its own `vim.cmd.startinsert` from its `auto_insert` handler
at the same point.

## What changed

- `utils.close_tabpage_without_entering(tab)` closes a tab page with
`:tabclose N` while the cursor stays in the tab the user is in, so no
`BufEnter` handler runs for the diff tab's windows.
`_cleanup_diff_state` and `cleanup_inline_diff` use it. The two
setup-failure paths keep their own code: the cursor is already in the
new tab there, so nothing is entered again.
- When the tab survives that close, the cleanup releases it, in both
layouts. It drops the terminal's `BufEnter` handler by its id first, so
the handler goes whether or not the terminal window closes. Then it runs
every window and buffer step with all autocmds blocked (`:noautocmd`,
through `utils.without_autocmds`): it gives the `unified` proposal
window an empty buffer, turns diff mode off in the windows of that tab
that show the file (a window the user split from a diff window, or a
`diffopt` without `closeoff`, kept `'diff'`, scrollbind and
`foldmethod=diff`), moves a cursor that sits in a window of the diff to
the window the diff stored for the file, and closes the proposal window
of a split diff (after the user closed the file window, it keeps that
window with the empty buffer) and the terminal window the diff added. It
keeps that terminal window when it is the only window that shows the
Claude terminal and the cleanup shows the terminal again afterwards (the
terminal was visible before the diff). A float that Snacks parked with
`hide` does not count as showing it. Once every close is done, it fires
`WinEnter` and `BufEnter` for the window the cursor ended in, or
`BufEnter` alone when only its buffer changed.
- The release touches the proposal window and the terminal window only
while each still shows the buffer the diff put there. A window the user
switched to a file of their own stays open with its file and its cursor.
The record of a diff gets `terminal_buffer_in_new_tab` and
`terminal_enter_handler` for this. Before this change the release acted
on the stored windows, so it blanked such a proposal window or closed
such a terminal window.
- Rounds 4 and 5 found a user autocmd that ran inside one of the
release's own window or buffer calls, after the check that made the call
safe: a `BufLeave` inside the proposal swap that moved the cursor, so
Neovim loaded the empty buffer into the user's window, a `BufNew` from
creating that buffer that closed the tab, and a `WinEnter` that entered
the terminal while Snacks queued an insert. A recheck before each call
cannot close that, because the autocmd runs inside the call. So those
calls run no autocmd at all. `utils.without_autocmds` uses `:noautocmd`
and not `'eventignore'`, because restoring `'eventignore'` fires an
`OptionSet` event. The cursor's `WinEnter` and `BufEnter` come last,
after the closes, so an autocmd that runs then finds no window of the
diff to switch, enter or close, and nothing in the release follows it,
not even when it closes the tab. The release fires no leave event for
the windows of the diff.
- `lua/claudecode/README.md` states the rule, the events a close fires
for every position of the user, and both reasons `:tabclose` can leave
the tab open.
- Round 6 found one step behind two symptoms. In the last-tab sequence
the release closed the diff's terminal window, the only window that
showed the terminal, so the cleanup's `ensure_visible` had to open
another one. Main never reaches that call there, because it leaves the
terminal window in place. The provider's new window fires `BufLeave` and
`BufEnter`. A user `BufLeave` autocmd that enters a window made the
provider put the terminal into that window, so the user's edited file
lost its window (the verifiers' `actor-blank.sh`, native and Snacks,
both layouts). A user autocmd `BufEnter term://* startinsert` queued an
insert that started in the user's file once the provider moved the
cursor back (native provider, both layouts). The release now keeps the
terminal window in exactly that case, so `ensure_visible` finds a
visible terminal and opens nothing, as on main. It still closes the
window when another window shows the terminal (E37 with the original
tab's terminal) or when the cleanup does not show the terminal again
(the user hid it before the diff). When it closes the window, it removes
that window's `WinClosed` autocmd, which `:noautocmd` kept from running.
- The new-tab specs drive a child Neovim over RPC through
`tests/helpers/new_tab_diff_child.lua` and run in eight files (counts in
Evidence), each under the runner's 40 s limit: open and mode, close, the
tab that stays open, the user's own windows, `:tabclose` stopped by E37,
the terminal handler from the original tab, autocmds of the user during
the release, and the diff's terminal window in a tab that stays open.
The `BufEnter` handler is covered by main's in-process spec, which this
PR does not change. `tests/integration/utils_without_autocmds_spec.lua`
covers the helper.
- Close cases: split and unified, with and without a Snacks-style
auto-insert handler on the terminal buffer, user in the diff tab's
terminal or back in the first tab. A control case, close while still in
terminal mode, passes on main and here.
- Last-tab cases: both layouts, a cursor in the proposal, in the
terminal, or in a window of the user's own, a `:split` of the file
window with and without `closeoff`, a `diffopt` without `closeoff`, a
file window the user closed (a vertical diff whose original pane is
gone), and every window but the terminal closed by the user (the
terminal cannot close, E444). In every last-tab case the diff's terminal
window stays, because it is the only window that shows the terminal.
- Terminal-window cases (both layouts): a user autocmd `BufEnter
term://* startinsert` leaves Normal mode in the file, a `BufLeave`
autocmd that enters the user's window leaves it showing the user's
edited file (the actor-blank sequence), the terminal window closes when
the user hid the terminal before the diff, and it stays when the only
other window that shows the terminal is a float parked with `hide`
(Neovim 0.10 and later).
- User-window cases: the user switches the proposal or the terminal
window to `b.txt`, in both layouts. The view, the text and the cursor
must stay, and the handler must go, also when a modified buffer and
`'nohidden'` make the window close fail. Two more: no empty buffer is
left when `'winfixbuf'` stops the swap (Neovim 0.10 and later), and two
windows of the user's in diff mode keep it.
- Autocmd cases (the user's autocmds, each armed before `close_tab`): no
autocmd runs while the proposal window, or a terminal window that the
release closes, is open, and the only events are `WinEnter` and
`BufEnter` for the window the cursor ends in (`BufEnter` alone when it
stays in the proposal window of a `unified` diff, none when it was in a
window of the user's), in both layouts. A `BufLeave` autocmd of the
proposal buffer that switches its window never runs during the swap, so
the swap cannot blank a window of the user's. A nested `WinEnter`
autocmd that enters a second window on the terminal buffer finds the
diff's handler gone. With the user's terminal hidden, so that the
terminal window closes, a `WinEnter` autocmd that enters any terminal
with Snacks' auto_insert leaves Normal mode, and the events come after
both windows of the diff are gone. The cursor goes to the window the
diff stored for the file, not to a window of the user's next to the
terminal or next to the proposal window.
- E37 cases: the diff's windows go, the user's stay, no window keeps
`'diff'`, and with the terminal on the left `:tabclose` stops before it
closes the terminal window, so the release must close it and drop its
handler. With the modified window at the right every window of the diff
survives `:tabclose`: a `BufNew` autocmd that closes the tab (unified)
or a `WinClosed` autocmd that does (split) never runs, and one that
closes the tab when the cursor leaves the proposal window leaves no
error and no record. The terminal window that the release closes leaves
no `WinClosed` autocmd behind. A release called for a tab that is gone
does nothing.
- Three defensive checks that round 5 found untested: the unused empty
buffer is deleted when the swap fails (the `'winfixbuf'` case, red when
the delete goes), the cursor goes to the window the diff stored for the
file (the cursor-destination case, red when the cursor is not moved),
and `:diffoff` runs only in windows that show the file (the
two-windows-in-diff-mode case, red when it runs in every diff window).
The old per-window validity check before `:diffoff` is gone: the loop
lists the windows of the tab right before it, and no autocmd can run in
it.
- `tests/unit/utils_close_tabpage_spec.lua` covers the helper. The `vim`
mock now honours `:tabclose N`, which it ignored.
- `journey-new-tab-diff-close-mode.sh` in `verify-claudecode-nvim`
replays the user's keys and Claude's `close_tab` in agent-tty, with a
last-tab phase and `VCN_RTP` for Snacks. The feature step in
`open-diff.md` uses a fresh launch and fresh ids.
- This branch merges main at 4b788d5, which has the terminal-placement
fix coder#598 (`utils.win_set_buf_in_split`). The merge conflicted in five
files. `utils.lua` and `diff.lua`: main's code stays and this PR's own
functions (`without_autocmds`, `close_tabpage_without_entering`, the
release of a surviving diff tab) follow it. `lua/claudecode/README.md`:
main's text stays, with this PR's two helpers added to the `utils.lua`
line and this PR's paragraph about closing the diff tab added to the
new-tab bullet, next to main's sentence about the new tab that a
`BufLeave` autocmd can leave.
`tests/integration/diff_new_tab_mode_spec.lua`: this PR's version, which
runs through the shared helper, because main's one-line change to the
old inline code (`fnameescape` of the `edit` path) now lives in the
helper. `covered.lock`: main's file first, then recorded again in its
own commit after `check.sh` printed STALE for `verify-diff-lifecycle`
(the other four skills print PASS).
- Main's new check `no-source-run-env` (`proof-static.sh`) rejected
`journey-new-tab-diff-close-mode.sh`, which sourced a `run.env`. It
reads `OUT` and `WORK` through `vcn.sh get` now, as
`journey-new-tab-diff-mode.sh` does. That is a shell script, not Lua.
- The two temp paths that the tests pass to Ex commands go through
`fnameescape` (`tests/helpers/new_tab_diff_child.lua`,
`tests/integration/diff_new_tab_tabclose_stops_spec.lua`), so the specs
pass under the `TMPDIR` with a space and a `%` that main's integration
job uses.
- The README sentence about the E37 case is corrected (see Known limit).
This push adds no other Lua.

## Behavior change: events at close

Closing the diff tab no longer enters it. Which events fire depends on
where the user is:

- **In the original tab**, the tab the diff was opened from: `close_tab`
fires `WinClosed`, `BufWinLeave`, `BufHidden` and `TabClosed`, and no
`BufLeave`, `WinLeave`, `TabLeave`, `BufEnter`, `WinEnter` or
`TabEnter`. On main it also fired `BufLeave`, `WinLeave` and `TabLeave`
on the user's file and the enter and leave events of the terminal.
- **In the diff tab or in a third tab**: `close_tab` goes back to the
original tab first, as before this PR. It fires `BufLeave`, `WinLeave`
and `TabLeave` for the window the user is in, then `WinEnter`,
`TabEnter` and `BufEnter` for the original tab's window. A `BufLeave`
autocmd on the user's file in the third tab runs.
- **With the original tab gone**: `close_tab` goes to no tab first. A
user in a third tab stays there. A user in the diff tab goes to the
neighbouring tab, as `:tabclose` does, and `TabLeave` and `TabEnter`
fire.
- **When `:tabclose` cannot close the diff tab** (the last tab, or E37):
the release's own steps fire no event. Afterwards, with the cursor in
another window, it fires `WinEnter` and `BufEnter` for that window, with
only its buffer changed (a `unified` proposal window that got the empty
buffer under the cursor) `BufEnter` alone, and with the cursor in a
window of the user's own nothing. `ensure_visible` opens no window there
when the terminal has another window or `:tabclose` left the diff tab's
terminal window open. When `:tabclose` closed that window before it
stopped at E37 and no other window shows the terminal, the close shows
the terminal again, as the next bullet says.
- **When the close leaves no window that shows the Claude terminal**
(the user hid it in the original tab, or the original tab is gone and
the diff tab closes): the close shows the terminal again
(`ensure_visible`). That fires `WinLeave`, `WinEnter`, `BufLeave` and
`BufEnter` for the user's window and enter and leave events for the
terminal, as when the user opens the terminal. A `BufLeave` autocmd,
such as an autosave, can run then. A diff tab that stays open keeps its
own terminal window in that case, so the close opens nothing there,
unless `:tabclose` closed that window before E37 stopped it
(`e37-hidden`, see Known limit). This PR does not change how
`ensure_visible` runs. It is provider code, and suppressing the events
there would hide them from the provider's own autocmds as well.

A trace of the events of `close_tab` (native provider, split layout,
Neovim 0.12.3, the positions original tab, diff tab, third tab, third
tab with the original tab gone, terminal hidden, last tab and E37) shows
the events above in each of the 7 positions (see Evidence).

## Known limit, not changed here

When the user closes every other window of the last diff tab (for
example `:q` in the diff window), the Claude terminal is the only window
left. It cannot close (E444), so it stays. The release now drops the
diff's `BufEnter` handler there, which main left registered. The user
keeps the mode they had in the terminal. With the native and the Snacks
provider no second terminal window opens: `ensure_visible` opens a
terminal only when no window shows its buffer.

The terminal-placement fix coder#598 is merged on main, and this branch has
it. It removes the one problem that the earlier heads of this PR had: a
user `BufLeave` autocmd that enters another window made the provider put
the Claude terminal into the user's window when `close_tab` shows the
terminal again with `ensure_visible`. That happens in three positions:
the user hid the terminal in the original tab (`hidden`), the original
tab is gone and the diff tab closes from a third tab (`third-gone`), and
E37 with the terminal hidden in the original tab (`e37-hidden`). The
probe runs the three positions with a run-once and with an every-time
`BufLeave` autocmd, on the native and the Snacks provider, in both
layouts. Before the merge (`d00e1be2`) the terminal went into the user's
window in 20 of 24 runs on Neovim 0.12.3 and in 10 of 12 native runs on
v0.8.0. With main merged, it goes there in 0 of 24 and 0 of 12, the same
as main (`4b788d58`). The rows are in Evidence.

One README sentence on the earlier heads overstated the E37 case: it
said that the terminal window stays in the diff tab's window, but
`:tabclose` can close that window first. The sentence now says: when
`:tabclose` leaves the diff's terminal window open, a terminal that was
visible before the diff and that no other window shows stays in that
window, because closing it would make `terminal.ensure_visible` open
another window, which fires the events of a new terminal window.
`:tabclose` closes the windows of the tab one after the other and stops
at the one it cannot close (E37), so a terminal window that it reaches
before that one is already closed, as on main, and the provider shows
the terminal again. A probe on Neovim 0.12.3 and v0.8.0 (`'nohidden'`, a
modified buffer in one window, a terminal in the other, `:tabclose` from
another tab) shows the order: with the terminal window on the right and
the modified window on the left, `:tabclose` closes the terminal window
and stops with E37. With the terminal window on the left and the
modified window on the right, `:tabclose` stops with E37 and the
terminal window stays. The result is the same on both builds.

## Evidence

Main is `4b788d58`. The head is `45d280b8`. Real Neovim, `sleep` as the
Claude job, a stub `claude` first on `PATH` in every run. Trees, probes
and logs of this round ran from one temporary folder.

| Check | main `4b788d58` | this head |
| --- | --- | --- |
| Position probe (the user's mode after `close_tab`): 14 positions,
native and Snacks, split and unified, no autocmd: 56 runs on Neovim
0.12.3, 28 native runs on v0.8.0 | mode `n` in 32 of 56 and 16 of 28,
`i` (Insert in the user's file) in 12 of 56 and 6 of 28, `t` in 4 of 56
and 2 of 28 | mode `n` in 56 of 56 and 28 of 28. No row is worse than
main: 28 and 14 rows go from not OK to OK, 24 and 12 have the same
status, and 4 and 2 rows keep the status `USERWIN-CLOSED` (the user
stays in the diff tab, whose windows close) without the Insert mode |
| F1 probe: the positions `hidden`, `third-gone` and `e37-hidden`, one
`BufLeave` autocmd that enters a window of the user's (run-once or
every-time), native and Snacks, split and unified: 24 runs on Neovim
0.12.3, 12 native runs on v0.8.0 | OK in 24 of 24 and 12 of 12 | OK in
24 of 24 and 12 of 12. Before the merge, `d00e1be2` was OK in 4 of 24
and 2 of 12 (the terminal in the user's window in 20 and 10) |
| The same rows, field by field (the 12 run-once rows on 0.12.3) | the
autocmd runs once in all 12 rows. The cursor ends in the window that the
autocmd entered (`other.txt`) in the 8 `hidden` and `third-gone` rows |
the autocmd runs once in all 12 rows. In the 8 `hidden` and `third-gone`
rows the cursor ends in the user's own window (`a.txt` or `b.txt`), not
in the autocmd's. In the 4 `e37-hidden` rows every field equals main's.
The 12 every-time rows have the same status: the autocmd runs once less
in the 8 `hidden` and `third-gone` rows, where the cursor also ends in
the user's own window, and in the 4 `e37-hidden` rows it still fires but
its callback returns at the probe's tab check, because the close does
not enter the diff tab |
| Events of `close_tab` by position (trace, native, split, 0.12.3):
original tab, diff tab, third tab, third tab with the original tab gone,
terminal hidden, last tab, E37 | | each position fires the events that
Behavior change lists: 7 of 7 |
| `:tabclose` order probe (`'nohidden'`, a modified window and a
terminal window), 0.12.3 and v0.8.0 | | terminal window on the right: it
closes, then E37. Terminal window on the left: E37 first, and it stays.
Same on both builds |
| New-tab spec files, counts per file on 0.12.3 / v0.8.0: autocmds 14 /
14, bufenter 8 / 8, close 10 / 10, last tab 12 / 12, mode 12 / 12,
original tab 10 / 10, `:tabclose` stopped by E37 7 / 7, terminal window
8 / 8, user windows 10 / 10 | | all pass: 91 on 0.12.3 and 91 on v0.8.0
in the nine files, 0 failed and 0 errors, plus the helper spec (4 / 4) |
| The eight specs that use `new_tab_diff_child.lua`, under a `TMPDIR`
with a space and a `%` (0.12.3) | all eight fail, 83 tests, with E499
from the helper's `edit` (the merge result before the `fnameescape`
edits) | all pass. The spec's own `:split` raises no error without its
`fnameescape`, so no test catches that one edit |
| Normalized `-U0` digest of this PR's own change over `lua/` and
`tests/` (index lines removed, `@@` numbers removed):
`d10fecf..d00e1be` and `4b788d58..head` | 2205 lines each, sha256
`c043ff2c0a95` and `421088cdb561` | 14 lines differ: the two README
lines with main's wording (`utils.lua` names, `win_set_buf_in_split`,
main's sentence about the new tab) and the corrected sentence, the two
`fnameescape` lines, and the old inline `edit` line that this PR removes
in `diff_new_tab_mode_spec.lua` (main's copy of it has `fnameescape`,
from coder#510) |

Gate on `45d280b8`, which has main `4b788d58` merged, with GNU `timeout`
first on `PATH`, a stub `claude` after it (no real `claude`) and a
scratch `CLAUDE_CONFIG_DIR`. I read the native exit code of each
command:

1. `mise run clean && mise run all` exits 0. treefmt changes no file.
Lint: 0 warnings and 0 errors in 223 files. Busted: 2015 successes, 0
failures, 0 errors, 51 pending.
2. The integration runner
(`scripts/run_integration_tests_individually.sh`) exits 0 on Neovim
0.12.3 (1106 successes, 0 failures, 0 errors) and on v0.8.0 (1102
successes, 0 failures, 0 errors), in a plain run and in a run under a
`TMPDIR` with a space and a `%`. No `$XDG_CACHE_HOME/nvim` appears (the
real-cache check).
3. All five `check.sh` pass (`verify-diff-lifecycle`,
`verify-terminal-lifecycle`, `verify-mention-queue`,
`verify-selection-tracking`, `verify-ws-connection`), and `covered.lock`
of `verify-diff-lifecycle` matches. The changes in `_cleanup_diff_state`
are only how its tab closes and what stays in a tab that cannot close,
which the lifecycle model lists as not modeled (window and tab layout).
4. Rows worse than main: none in the position probe or the F1 probe.

---
_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