Repository navigation
fix(terminal): stop tracking an adopted snacks Claude when its job exits - #482
Conversation
After a reload, the snacks provider adopts the Claude that the earlier copy of the module started. That instance's TermClose handler belongs to the earlier copy, so it cleared only the earlier copy's state. An open between the exit and the scheduled close then showed the dead terminal, and the close wiped it. With auto_close, the adopting copy now adds its own TermClose handler.
… an adopted close
|
@codex review |
|
@codex security review |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6878658479
ℹ️ 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".
… only in its TermClose handler A TermClose autocmd that runs before the provider's handler can call open while the exited terminal is still tracked. Each snacks entry now forgets a tracked terminal whose job ended (with auto_close), so it starts a new Claude. This replaces the handler that adoption added in the previous commit, and covers the same case without a reload.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Snacks keys an instance by its command, directory, env and count, and clears the key when a buffer is wiped. forget_ended_terminal and the TermClose handler dropped an exited Claude A without wiping it. An open in the gap started Claude B with the same key, and the late wipe of A cleared B's key, so B left Snacks.terminal.list() and a later reload started a third Claude beside B. Both now wipe A at once, before any open can start B. The test mocks keep Snacks' keyed list and raise for an invalid buffer like Neovim. New tests cover get_active_bufnr, ensure_visible's path, close, and a closed tracked instance. README: the entry check and the handler wipe.
Wiping the instance inside the TermClose handler deletes the buffer-local autocmds that a user defined after it, in the middle of the event (a probe showed such an autocmd no longer ran). The handler schedules the close as before and no longer forgets the instance. Every entry wipes a tracked terminal whose job ended before it starts a new Claude, so the Snacks key is free by then, whichever autocmd order runs.
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b2b399931
ℹ️ 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".
… exited one when an entry runs [Option A, local only] Snacks keys an instance by cmd, cwd, env and count and clears the key when a buffer is wiped. Adoption now reads a _G registry of started instances first. Entries forget a tracked terminal whose job ended (no wipe).
…hrough Snacks.terminal.list() [Option C, local only] Snacks keys an instance by cmd, cwd, env and count and clears the key when any buffer with that key is wiped. Adoption now takes a marked buffer with a live job and tracks it through a stand-in for the Snacks instance. Entries forget a tracked terminal whose job ended (no wipe).
…its start options Instead of a stand-in handle, an adopted terminal gets a new Snacks window around its buffer (Snacks.win with buf, shown later) built from the window options that the first start stored in b:claudecode_snacks_window. A split or a float comes back as it started, also when its window is gone. A terminal that an older version started has no variables: the entry's config decides its window options and auto_close. The mocks keep Snacks.win.
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d8b514018
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cff28b7aa9
ℹ️ 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".
… window events for a wrapped split A float that is relative to a window kept no anchor in the stored window options, so after a reload Snacks anchored the re-created float to the window that was current at the adoption. The anchor is stored now, and the wrapper keeps it while that window exists. A wrapper never runs Win:show, and cc_show re-creates a split without it, so the BufWinEnter handler that moves a file that you open in the terminal's window to another window (fixbuf) was missing, and an :edit in the window replaced Claude's buffer. The wrapper registers the Snacks events and the handler at its first native re-creation.
…per's window events
…ed snacks terminal
# Conflicts: # .agents/skills/verify-diff-lifecycle/covered.lock
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
…s.win After a reload the snacks provider finds the Claude by the mark on its buffer and wraps it in a Snacks window, so the stub needs Snacks.win. The stub instance is built by one function now, for terminal.open and for win.
…lit, and test the upgrade of a wrapper Snacks' WinClosed handler is bound to the id of one window. An instance that snacks started never has it for a window that cc_show re-created, so the cursor stays where Neovim puts it when that window closes. The wrapper registered it for the re-created window, which moved the cursor to the previous window (wincmd p). It now registers the events that do not belong to a window, and the BufWinEnter handler. The upgrade of a wrapper must not keep the filetype of the terminal style, and must happen once: both are tested now.
…at its next re-creation, and which window events the wrapper registers
|
Review-cap waiver for this PR (recorded before round 9): the maintainer allowed one more push on #482 and Codex round 9 on that head. The push carries the test-only Snacks.win stub fix in #447's integration spec plus the verifier findings on 5c092d2. A new security flaw, data loss or broken core behavior stops this PR and goes back to the maintainer. Generated with |
# Conflicts: # .agents/skills/verify-diff-lifecycle/covered.lock
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
…d mock Main (#482) added the same mock function that this branch added for the window snapshot of the swap helper, and luacheck flags the second one. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…de terminal lifecycle (coder#525) ## What this adds `.agents/skills/verify-terminal-lifecycle/`: a TLA+ model of the Claude terminal in `claudecode.nvim`, checked by TLC against 34 guards that `check.sh` reads from the Lua code, on the shared harness from coder#450. It covers the `native` and `snacks` providers, the code that finds a Claude after a plugin reload (`:Lazy reload`), the wrapper that adopts a snacks Claude, and the provider choice. A change that removes or rewords a guard makes a rule break, or makes the covered code read STALE (exit 3) until someone confirms the model. The model describes `main` as it is now. The bugs that it found are fixed first: coder#451, coder#464, coder#482 and coder#527 are merged. coder#527 fixed a bug that a review comment on this PR found, and the model has a step for it now. ## The eight rules, as plain sentences | Rule | Plain sentence | | --- | --- | | `AtMostOneClaude` | At most one Claude runs, apart from the one that a close is stopping. | | `CloseStopsClaude` | When `:ClaudeCodeClose` returns, no Claude that ran when it began still runs. | | `CloseNeverReuses` | The plugin never tracks again a terminal that it began to close. | | `OpenShowsLiveClaude` | After an open, a show or a focus, you see a running Claude. A snacks terminal with `auto_close = false` is exempt, on purpose (it keeps an exited Claude). | | `HideKeepsClaude` | Hiding Claude's window keeps Claude running. | | `HideNoError` | Hiding Claude's window raises no error. | | `FailedStartLeavesNoWindow` | A failed start leaves no window, no buffer and no tracked terminal. | | `ExitClosesOnlyOwnWindow` | With `auto_close`, Claude's exit closes only Claude's own window, and never the last window. | ## Bugs this model found The first version of the model broke five rules on seven paths. I replayed each path on a real headless Neovim 0.12.3 (Claude is `sh -c 'sleep ...'`). Updates of the model after those fixes found an eighth and a ninth. Each fix is merged. | # | Rule | Bug | Fixed in | | --- | --- | --- | --- | | 1 | `AtMostOneClaude` | An autocmd that calls open while snacks starts Claude (`BufEnter`, `TermOpen`) started a second Claude. | coder#451 | | 2 | `AtMostOneClaude` | After a plugin reload, the next open started a second Claude (native adopted only a shown terminal, snacks adopted nothing). | coder#464 | | 3 | `CloseStopsClaude` | `:ClaudeCodeClose` after a reload left Claude running. | coder#464 | | 4 | `OpenShowsLiveClaude` | An open between Claude's exit and the scheduled cleanup (native) focused the dead terminal and started no Claude. | coder#451 | | 5 | `OpenShowsLiveClaude` | After `:e file` in Claude's window (native), `:ClaudeCodeOpen` focused your file and Claude stayed hidden. | coder#451 | | 6 | `HideNoError` | The native toggle raised `E444` when Claude's window was the only window. | coder#451 | | 7 | `ExitClosesOnlyOwnWindow` | After a wipe, the native exit cleanup closed the window that showed your file. | coder#451 | | 8 | `OpenShowsLiveClaude` | An exited snacks Claude stayed tracked until its scheduled close, and a Claude that started in that gap lost its Snacks key, so the next reload started a second Claude. | coder#482 | | 9 | `HideNoError` | A `BufEnter` or `TermOpen` autocmd that called `focus_toggle` while native started Claude made the open raise `Invalid window id`, and Claude kept running with no window. | coder#527 | Replays of each bug, against the code before its fix (`replays/`, exit 1 means findings): | Tree | Replays with a finding | | --- | --- | | before coder#451 (`8affa12`) | `snacks-reentrant-open` 2 of 2, `native-open-after-exit` 2 of 2, `native-open-after-swap` 2 of 2, `native-hide-only-window` 2 of 2, `native-wipe-closes-your-window` 1 of 1, plus the reload replays below | | before coder#464 (`14798d1`) | `native-reload-hidden` 1 of 1, `snacks-reload` 2 of 2, `auto-snacks-later` 1 of 2, `native-reload-close` 1 of 1 | | before coder#482 (`3498879`) | `snacks-reload-exit` 16 of 16, `snacks-exit-handlers` 1 of 4 | | this branch | 20 replays, 0 findings (`native-start-autocmd-hide` is new: 18 checks, 10 findings before coder#527 on Neovim 0.12.3 and on the 0.8-era build) | Eight of the 20 replays (`snacks-reload-adopted`, `snacks-reload-keys`, `-tabs`, `-backdrop`, `-older`, `-edit`, `-anchor`, `-focus`) guard the wrapper that coder#482 builds for an adopted snacks Claude: they are no model findings, because the model has no keys, tabs, backdrops or window sizes. Verification of the first drafts of coder#482 found each defect, and each fix landed before coder#482 did. SKILL.md lists them. ## Proof All on this branch (head `a639966`, `main` at `0d55c25` merged), Neovim 0.12.3, Java from mise: - `check.sh`: `main: pass (263060 distinct states found)`, `== RESULT: PASS`, all 34 facts `TRUE`. - `check.sh --mutants` (about 11 minutes on a loaded host): 34 mutant keys (26 single guards and 8 pairs), `== mutants: every guard is caught`, exit 0. Every rule breaks at least once (`AtMostOneClaude` 10, `CloseStopsClaude` 10, `OpenShowsLiveClaude` 9, `CloseNeverReuses` 5, `HideNoError` 3, `FailedStartLeavesNoWindow` 2, `ExitClosesOnlyOwnWindow` 2, `HideKeepsClaude` 1). I ran it on this head. - Scratch break: I removed the `owned.job_ended(terminal.buf)` check from `forget_ended_terminal` in `snacks.lua`. `check.sh` exits 1, `SnacksEntriesForgetEndedJob FALSE`, `main: BROKEN: OpenShowsLiveClaude`. After `git checkout lua`, `git diff --shortstat` is empty and `--hash-only` reads "covered.lock matches the code". - Scratch breaks for the two facts that Codex asked for in round 1: moving the native mark after `nvim_win_set_buf` makes `check.sh` exit 1 with `NativeMarkBeforeTermopen FALSE`, and removing the `jobstop` call of the stop helper in `owned.lua` makes it exit 1 with `OwnedStopOtherStops FALSE`. Both files were restored after each break. - Scratch breaks for the model step and the verifier fixes: with the native.lua from before coder#527, `check.sh` exits 1 (`NativeKeepsHiddenJob FALSE`, `NativeStartSurvivesClosedSplit FALSE`, `main: BROKEN: HideKeepsClaude`). With the `return` deleted from `hide_terminal`, it exits 1 (`NativeHideKeepsLastWindow FALSE`, `main: BROKEN: HideNoError`). The six rewrites that a verifier found to leave a fact TRUE with the guard gone (the adoption, `is_last_window`, the order of `forget_ended_terminal`, `job_ended`, the hide return, `if starting then`) read FALSE now. Files are restored after each break. - `scripts/tla/test-check-lib.sh`: `COUNT cases=44 failed=0 skipped=0`. - Replays: 20 scripts, 0 findings each, exit 0 (see the table above for the code before each fix). `run.sh` exits 2 for a missing Neovim, a replay that raises and one that hangs in `vim.wait` (a replay stuck in a CPU loop can outlive `timeout`'s SIGTERM), and it runs from a path with spaces, commas, pipes and backslashes (the repository and the snacks.nvim checkout). - `treefmt --ci` (stylua, prettier, shfmt -i 2, shellcheck, actionlint, zizmor): 383 files, 0 changed. `busted tests`: 1529 successes, 0 failures, 25 pending. `mise run test` (the CI file set, coverage, seed 1794460250): 1702 successes, 0 failures. luacheck: 0 warnings. - `covered.lock` is re-recorded on this head, which merges `main` at `0d55c25` (it had been recorded at `37eaa0f`). Changed since then in the covered files: `terminal.lua` (a warning message of coder#530, file line only), `native.lua` (coder#527, block `open_terminal`) and `snacks.lua` (coder#526 dead stores, block `snacks_open` and the wrapper blocks, and the comment of coder#534). I read each diff: none changes a guard that a fact reads, and every fact reads TRUE except for the one that coder#527 moved (`NativeKeepsHiddenJob`, updated). `--hash-only` reads "covered.lock matches the code" (about 10 s). ## Run it ```bash .agents/skills/verify-terminal-lifecycle/check.sh # about 1 min (30 to 50 s) .agents/skills/verify-terminal-lifecycle/check.sh --mutants # about 12 to 15 min, after you edit the model or a fact .agents/skills/verify-terminal-lifecycle/check.sh --hash-only # about 10 s, only compares covered.lock with the code SNACKS_NVIM=<snacks.nvim checkout> NVIM=<path to nvim> \ .agents/skills/verify-terminal-lifecycle/replays/run.sh snacks-reload-keys.lua ``` CI runs every `.agents/skills/verify-*/check.sh` in the `covered-lock` job, as for `verify-diff-lifecycle`. ## Notes for the reviewer - The diff is one new skill folder (SKILL.md, `check.sh`, `covered.lock`, `model/` with the TLA+ spec and a `/statechart` view, and `replays/`) and one sentence in `lua/claudecode/terminal/README.md`. The sentence says that a float of an older version has no backdrop "to hide or to show" until Snacks re-creates it (the text said only "to hide"). It touches no Lua code. - The skill does not model the items that SKILL.md lists under "Scope and bounds", each with what you would see if it broke. They include the `external`, `none` and custom table providers, a close that starts during another close's wait, more than one open or toggle per start from an autocmd, a `WinNew` or `WinEnter` autocmd that calls an entry while native's `vsplit` runs before the buffer has its mark (two Claudes run; open PR coder#544 proposes a fix), which window the plugin made (an exit with `auto_close` closes a window of yours that shows the terminal buffer), a cleanup that raises (`E444` in a failed start's window close), a `TermClose` autocmd of your own that closes the terminal, Snacks floats, tab pages, window sizes, a second reload, `:bdelete`, `:q` in Claude's window, and Neovim exit. It assumes Snacks' `fixbuf`, so `:e` in a snacks window is no step. - `DEVELOPMENT.md` is not edited here: the skill-listing change belongs to the PR that owns it. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
What users see
With the snacks provider and
auto_close, a:ClaudeCode,:ClaudeCodeOpenor:ClaudeCodeFocusthat runs right after Claude exits can show the dead terminal. The scheduled close then wipes it, so you end with no Claude and no window and have to run the command again. Now it starts a new Claude, and a later reload (:Lazy reload) still finds that Claude, so the next open does not start a second one beside it.The window is small. The entry must run after Claude's
TermClosestarts and before the provider's scheduled close. These can land there:TermCloseautocmd that you defined before Claude started. Autocmds run in definition order (I checked this on Neovim 0.12.3), so it runs before Snacks' handler. This needs no reload.TermCloseautocmd that you defined after Claude started. It runs after the handler.terminal.Cause
Two things in
snacks.luawent wrong together:TermClosehandler ofsetup_terminal_events. A handler cannot help an autocmd that runs before it, and after a reload it belongs to the earlier module copy, so it does not clear the new copy'sterminal. Until the scheduled close runs,terminal:buf_valid()is true and an entry shows the dead instance.Snacks.terminal.list(). Snacks keys an instance by its command, directory, env and count (Snacks.terminal.tid) and clears that key when a buffer is wiped, whichever instance holds it. A Claude B that starts while the exited Claude A waits for its close gets A's key, and A's late wipe clears it. B leaves the list, so after the next reload an open starts a third Claude while B keeps running. On main this already happened for an autocmd that runs after the handler.Fix
b:claudecode_snacks_terminaland whose job has not ended (owned.find).Snacks.terminal.list()is no longer asked, and there is no registry.Snacks.winwithbuf, nothing shown yet), patched like a window thatsnacksstarted. It is built fromb:claudecode_snacks_window, the resolved window options of the first start, which the provider now writes as plain data. It writes two more buffer variables next to it,b:claudecode_snacks_auto_closeandb:claudecode_snacks_backdrop, so there are three besides the mark. So a split or a float comes back as it started, also when its window is gone, and re-created splits getsnacks_winand the winbar again. For a float, the wrapper takes the Snacks backdrop window again, the one whose id the first start stored inb:claudecode_snacks_backdrop(it must still sit one z-index below the float and show asnacks_win_backdropbuffer), so a hide and a show reach it and not the backdrop of another Snacks float. The wrapper is built in theterminalstyle, soqandgfhide the terminal as before (the genericqwould close the instance, and with the scratch buffer it would stop Claude).closeclears the mark of an adopted terminal before it stops the job: theTermClosehandler of the instance that started the Claude cannot see the wrapper'sclosingflag, and would report the stop as a failure. The entry's config applies before the ended-job check, so a configless adoption of an older terminal (for exampleget_active_bufnr) does not hide the dead one.qdid nothing,gfleft the file in Claude's window, and a user'sself:hide()key did nothing after a float was closed and opened. The wrapper now callsSnacks.win:map(), with the keys of theterminalstyle and the entry's config (functions included, because the config is not stored). An entry without a config (get_active_bufnr, which selection tracking calls on a cursor move, andclose) maps no key, because a map without the user's keys would turn on a key that the user turned off, and Snacks never removes a map. The next entry that has a config upgrades the wrapper in place (upgrade_wrapper): it maps the keys, and it gives a terminal of an older version its window options (so a float comes back at its configured size, not at Snacks' default).win_findbuf(buf)[1]at every entry, which takes a copy that a diff tab shows (in another tab) for the terminal's window: after a hide in the first tab, an open jumped to the diff tab and left the first tab without Claude. Now the adoption takes a window that Snacks made for the buffer, one that carries thesnacks_winmarker (the copy of a diff tab, shown bynvim_win_set_buf, does not): one of the current tab first, then one of another tab (as an instance thatsnacksstarted tracks the window that it showed, in whichever tab), then an unmarked window of the current tab (a split that an older version re-created). After that the wrapper tracks only the window that it hides and shows itself, like an instance thatsnacksstarted.:close, open, reload" a hide left the new backdrop on screen.remember_backdropnow writes it after the first start and after each re-creation incc_show, and clears it when the new float has no backdrop.win, so after a reload Snacks anchored a re-createdrelative = "win"float to whichever window was current at the adoption. The stored options keepwinfor such a float, and the wrapper keeps it while that window exists.Win:show, andcc_showre-creates a split without one (a float goes throughWin:show), so Snacks' resize events and itsBufWinEnterhandler (fixbuf, which moves a file that you open in the terminal's window to another window) were missing: an:editin the re-created window replaced Claude's buffer, and a toggle opened a second split.register_window_eventsregisters them once, at the wrapper's first native re-creation of a split. It registers noWinClosedevent: that event is bound to the id of one window, an instance thatsnacksstarted never has it for a window thatcc_showre-created, and Snacks' handler would move the cursor to the previous window (wincmd p) when the window closes, where main leaves it to Neovim.adopt_existing_terminal()is the first call inopen,simple_toggle,focus_toggle,closeandget_active_bufnr. It starts withforget_ended_terminal(): withauto_closeand an ended job it setsterminal = nil. This is the snacks counterpart of native'sforget_ended_terminal(). TheTermClosehandler is unchanged: it forgets the instance and schedules the close.TermClose. A wipe while Neovim dispatchesTermClosedeletes the buffer-local autocmds that remain for that event. I first wiped in the entry (and, in a variant, in the handler). Codex round 3 and a probe showed that a user's later autocmd, and the provider's own handler with its exit-status log, then no longer ran. Nothing is wiped there now: the scheduled close does it.With
auto_close = falsenothing changes: Snacks keeps an exited Claude in that mode by design (terminal/README.md).Differences from main that I kept
I measured an adopted terminal against main with probes: 52 scenarios (a split and a float with a backdrop,
auto_closetrue and false, 0 to 3 reloads, a diff in a new tab or not, a window closed and opened, an older terminal), plus the advisory reviewer's two-tab scenarios (a hide in one tab and an open in another, a reload in a second tab). After I drop the thread address in an error line, the outputs are identical to main's (window kind, size,snacks_win, winbar, backdrop, keys, tab of each window, cursor after a hide, failed-exit reports) except for differences 2 and 3 below. Three differences stay, andterminal/README.mdstates the same three (its wording of difference 3 gets "or to show" in the skill PR, because this head is final):on_winandon_bufcallbacks, and any function insidesnacks_win_optsother than a key, do not run for the wrapped window, because a function cannot be stored in a buffer variable. Keys come from the entry's config. Before the first entry that has a config, the user's function keys are those of the first start.FileTypeis not set again (the float that comes back fires oneFileTypeless than main).auto_closeis the entry'sauto_close. The window options andauto_closecome from the entry's config until that terminal ends. The missing backdrop lasts only until Snacks re-creates the float, which records the new backdrop.The wrapper is a new Snacks instance. The instance that the first start created stays alive for its
TermCloseandBufWipeouthandlers on that buffer.Not changed, on purpose: a job that
closestopped withjobstopreads as ended at once, so an entry in the next moments starts a new Claude while the stopped one dies within a few seconds. The stopped job cannot resume, so you get one live Claude. I also did not find a way to make the key clash happen fromclose's own wait, and without the Snacks list it cannot matter.I checked the native provider for the same shape (a handler of the earlier copy that clears only its own state). It has none: #464 adds the
ClaudeCodeNativeAdoptedhandler, and native's entries runforget_ended_terminal()first. A real-Neovim probe with aTermCloseautocmd that callsopen,ensure_visible,simple_toggle,focus_toggleorclosegave the same result with and without a reload, forauto_closetrue and false. So no native change.How I found it
A TLA+ model of the terminal lifecycle (
verify-terminal-lifecycle, a separate PR that waits for this one) brokeOpenShowsLiveClaudeon main: open, reload, open (adopts), Claude exits, open. The verifier of this PR found the key clash, and Codex round 3 found the two ways that closing earlier goes wrong. I replayed all of them on real Neovim 0.12.3. Claude is a stub script that exits after 0.5 s.For each of
open,ensure_visible,simple_toggleandfocus_toggle, and for aTermCloseautocmd defined before and one defined after the handler (8 scenarios, 16 checks), the replay runs: open, reload, adopt, Claude exits, the entry runs, reload, open. The promise: exactly one Claude runs, and it is the one that the entry started. A second replay runs the two Codex round-3 scenarios (snacks-exit-handlers.lua, 4 checks), and a third compares an adopted split and float with the first start (snacks-reload-adopted.lua, 8 checks, with termguicolors on so that Snacks makes a backdrop, and a second Snacks float with its own backdrop open on top of Claude's): (1) the entry runs insideTermClose, and a later buffer-local autocmd and the provider's exit-status log must still run. (2) The plugin reloads, no entry adopts the Claude, it exits, an entry in the gap starts a new one, the plugin reloads, open: one Claude.The table counts the checks that show a finding. The seven replays that the skill PR adds for the wrapper (
snacks-reload-keys12 checks,-tabs6,-backdrop9,-older3,-edit4,-anchor1,-focus6) show 6, 4, 4, 1, 0, 0 and 0 findings on 45c5c04, the head that the first two verifiers rejected.-editand-anchorshow 1 finding each on cff28b7, the head of round 7, and-focusshows 2 on 5c092d2, the head of round 8. All seven show 0 on this branch and on main. The 8-check replay finds no bug on main: it guards the adopted window. It found 3 problems in 6d8b514 (a backdrop left after a hide, and a failed-exit report after a reload and a close, for a split and a float), which I fixed after Codex round 4. With the other float open, it found 3 problems in ecb636d (the other float's backdrop hidden instead of Claude's, aqkey that closes, and the other float's backdrop deleted byclose), which I fixed after round 5.The other snacks replays (
snacks-reload,snacks-reentrant-open,auto-snacks-later) and the six native replays show no finding either. The replay scripts go into the skill PR. The skill is not in this PR, so no gate here runs its model. I ran it by hand: on the exact tree off18407awith the skill branch atf601c2d,check.shreads 32 of 32 facts TRUE and the model passes (258,982 distinct states), but only after a scratch--record(the skill's owncovered.lockis stale against this tree, and the skill branch needs a sync with main before it passes its own gate, which the skill PR does after this PR merges). The--mutantsrun, which caught every guard, was on skill commitc2edd8dmerged with fix commitafba2c3. I did not rerun--mutantson later heads.check.shon the exact tree off18407areads the same 32 facts TRUE.Review record
qdid nothing andgfopened the file in Claude's window, because the keys of the buffer closed over the first instance (also a user'sself:hide()key after a float was closed and opened). With a diff tab that shows the terminal, a hide in the first tab and an open jumped to the diff tab. A re-created float left its new backdrop on screen after the next reload. Smaller findings: an older float came back at Snacks' default size, a toggle in a diff tab hid the copy, and a missing test for the backdrop variable and for the key strip. This head fixes all of them.relative = "win"float, and the missing window events of a wrapped split. This head fixes both (replies on the threads). Round 8 is the last the waiver allows.tests/integration/diff_terminal_float_reload_spec.lua, whose stub of Snacks has noSnacks.win, and this PR's wrapper calls it after a reload (6 of 9 cases). Real Snacks has it, and a probe on real Snacks passes 9 of 9. This head gives the stub aSnacks.win.WinClosedevent now), one README sentence said that a wrapper never runsWin:show(a float does), and the upgrade happening once had no test.afaf05cfixes both: a wrapper that an entry without a config made (selection tracking runsget_active_bufnron a cursor move) turned on a key that the user had turned off, and a Claude window in another tab was no longer tracked.Tests
tests/unit/terminal/snacks_reload_spec.luagoes from 27 to 92 tests. The mocks changed: they keep Snacks' keyed list and clear the key on a wipe,Snacks.winexists (an instance for an existing buffer, with no buffer or window of its own, a close that wipes only its scratch buffer,keysand amapthat records which instance mapped the buffer, like Snacks), windows belong to tabs (nvim_get_current_tabpage,nvim_win_get_tabpage) and the first start's window carries Snacks'snacks_winmarker, a map only adds or replaces a key (Snacks never removes one),vim.tbl_deep_extendmerges maps deeply,vim.boraises for an invalid buffer like Neovim (before, it returned channel 0 and hid anilbuffer), andsnacks_close_spec.luamocksjobpidand raises the same way.The 65 new tests cover:
open,simple_toggleandfocus_toggle, each with and without a reload, with Snacks' handler run or not yet run (12).Snacks.terminal.list()does not hold it (1).closeraise no error once the exited instance is closed (2).get_active_bufnris nil and an open without focus starts a Claude, which is the path ofensure_visible(2), andclosestops nothing twice (2).auto_close = falsekeeps the exited Claude (1).auto_close(2).auto_close(2), gets the default winbar, and is forgotten by the next entry's config after a configless adoption (1).closeafter a reload reports no failed exit (1), the backdrop of an adopted float is hidden and shown again, also when the float was hidden before the reload (2), and the Claude that an entry started in the gap is found after a later reload when no entry had adopted the exited one (1). The adopted float does not take another Snacks float's backdrop (1), and the wrapper is in the terminal style (1).qhides after a hide and a show, after one reload and after two (2), the entry's config keys are mapped with their functions (1), a terminal of an older version gets the real keys, not a copy without functions (1), a configless adoption maps no key (1) and takes the user's keys at the next entry (1), and an older terminal takes the window options of the next entry's config (1).BufWinEnterhandler at its first re-creation of a split (1), registers no event that is bound to a window (1), registers them once however often it hides and shows (1), and registers nothing for a terminal that the provider started (1).relative = "win"window while it exists (1), drops it when that window is gone (1), and a float that is not relative to a window stores none (1).Against main's
snacks.lua, 41 of the 92 are red (32 fail, 9 raise). Against 45c5c04, the head that the first two verifiers rejected, 17 are red, and they are the new tests for keys, tabs, backdrop re-creation, older-terminal options, window events and the anchor. Against cff28b7, the head of round 7, 4 are red, and against 5c092d2, the head of round 8, 3 are red (the filetype of the upgrade, the upgrade happening once, and the events that are bound to a window). Against my first local fix (a777129), an advisory review found two regressions (a wrapper made without a config turned on a key that the user had turned off, and a window of Snacks in another tab was no longer tracked), and 3 of them are red there. Against 6d8b514, which round 4 reviewed, 6 of the earlier 66 are red.Mutations of the fix, each run against both terminal specs:
forget_ended_terminal()callowned.findresult ignored)scratch_bufauto_closevariable ignoredauto_closecheckjobpidowned.job_endedchecksnacks_close_spec.lua(a live Claude would be dropped)if not terminal theninstead of thebuf_validguardget_active_bufnrwithoutadopt_existing_terminalclosekeeps the mark of an adopted terminalterminalstyle, or itsbokeptmap()in the upgradeplain(functions dropped)win_findbufscan at every entry (the old behavior)snacks_winmarkerremember_backdropincc_showremember_backdropnever clearsremember_backdropat the first start| no event registration for a wrapped split | 2: "registers the Snacks events and the BufWinEnter handler of the wrapper" and "registers them once" |
| the upgrade keeps the filetype of the terminal style | 1: "takes the keys of the next entry's config after a configless adoption" |
| the configless flag stays set after the upgrade | 1: the same test, which counts the instances that
Snacks.winbuilt || the wrapper registers the events that are bound to a window | 3: "registers no event that is bound to a window" and the two tests that count the registered events |
| events registered for every instance | 1: "registers no window events for a terminal that the provider started" |
| events registered at every re-creation | 1: "registers them once, however often it hides and shows" |
| no
BufWinEnterhandler, or no_onof the events | 2 each || the anchor is never stored, or always dropped | 1 each: "keeps the anchor while that window exists" |
| the anchor is stored for every float | 1: "stores no anchor for a float that is not relative to a window" |
| the anchor is never checked against the window list | 1: "drops the anchor when that window is gone" |
closeforgets an exited terminal whether or not the forget runs first, so skipping the forget there changes nothing a test can see. The close tests check the end state.Checks
busted testson the final head: 1390 successes, 0 failures, 0 errors, 19 pending. The file set ofmise run test(every*_spec.luaand*_test.lua, seed 4247): 1555 successes, 0 failures, 0 errors, 19 pending. The integration runner (scripts/run_integration_tests_individually.sh) passes on Neovim 0.12.3 (311 successes) and on a 0.8-era build (224 successes), with no failure. The stub gap that failed CI on 5c092d2 is fixed indiff_terminal_float_reload_spec.lua(the stub of Snacks getsSnacks.win, built by the same function asterminal.open).qafter a reload and a hide and a show 0 of 12 broken (45c5c04: 4 of 12), a user'sself:hide()key 0 of 12 broken (45c5c04: 4 of 12), a float re-created before a reload 0 of 9 broken, the two-tab hide and open stays in the first tab, an older float keeps its configured size, a key that the user turned off stays off after a cursor move that adopts without a config, Claude in another tab is found,:editin Claude's window after a reload and a hide and a show moves the file to the other window (cff28b7 replaced Claude's buffer), and arelative = "win"float keeps its anchor (cff28b7 anchored it to the other window). The 52 matrix scenarios, and the verifier's lists (46 window and diff, 28 operations, 32 exit scenarios), equal main's except differences 2 and 3, and the cursor after a hide in the layouta.txt | b.txt | Claudeis where main leaves it (b.txt) after 0 to 2 reloads.snacks.luaandterminal/README.mdmerged without a conflict (8527a65merged main at d10fecf, andf18407a, this head, merges main at b1a5e55: test: stop the luacov config spec reading the host's TMPDIR #513 and ci: drop the apt build-dependency step that the runner image does not need #514, which change only the workflow and a luacov spec). A probe on the merged tree (float, 0 to 2 reloads, the diff'sget_active_terminal_bufnrandhide_float, then a toggle through the plugin or through the original Snacks instance, thenq) gives the same result as main: the float and its backdrop hide, show and hide again. Onlycovered.lockconflicted. I recorded it on the merged tree, andverify-diff-lifecycle/check.shpasses (89,205 states).luacheck lua/ tests/ --no-unused-args --no-max-line-length: 0 warnings, 0 errors.stylua --check lua testsand prettier onterminal/README.md: clean.terminal/README.mddocuments the mark-based adoption, the three buffer variables and the mark, the keys of the wrapper, the window choice, the window events, the anchor, the entry check, why nothing is wiped there, and the three differences above.Generated with
xum• Model:anthropic:claude-sonnet-5-5• Thinking:xhigh