-
Notifications
You must be signed in to change notification settings - Fork 195
fix(diff): reject diffs on window close so :q works #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # `issue-238` fixture — repro for issue #238 | ||
|
|
||
| Reproduces [#238 "[BUG] Rejecting with `:q` does not work"](https://github.com/coder/claudecode.nvim/issues/238): | ||
| the README documents two ways to reject a Claude diff — `:q` **or** `<leader>ad` | ||
| (`:ClaudeCodeDiffDeny`). The keymap works; **`:q` does not reject**. The proposed | ||
| window closes but Claude is never told `DIFF_REJECTED`, and (with | ||
| `open_in_new_tab = true`) the diff tab lingers. | ||
|
|
||
| This fixture uses the reporter's exact config: | ||
|
|
||
| - `terminal.provider = "none"` (Claude runs in an external terminal, e.g. | ||
| sidekick.nvim, so claudecode manages no terminal of its own), and | ||
| - `diff_opts = { layout = "vertical", open_in_new_tab = true }`. | ||
|
|
||
| It mirrors [`remote-diff`](../remote-diff) but adds a JSON `:DiffStateFile` | ||
| inspector that records window/tab counts, each diff's status, **and the proposed | ||
| buffer's `bufhidden`** — the smoking gun for this bug. | ||
|
|
||
| > Set `REPRO238_NEW_TAB=0` to launch in the **default** same-tab layout and | ||
| > confirm the bug is not tab-specific (it reproduces there too — the split just | ||
| > silently collapses to the original file). | ||
|
|
||
| ## Files | ||
|
|
||
| - `init.lua` — claudecode.nvim config matching the issue + `:DiffState`/`:DiffStateFile`. | ||
| - `example/target.txt` — a sample file to diff against. | ||
|
|
||
| ## Root cause (verified) | ||
|
|
||
| The proposed buffer is created with `vim.api.nvim_create_buf(false, true)` | ||
| (a scratch buffer ⇒ `bufhidden = "hide"`) and rejection is wired **only** through | ||
| buffer-destruction autocmds (`BufDelete` / `BufUnload` / `BufWipeout` → | ||
| `_resolve_diff_as_rejected`). Because `bufhidden = "hide"`, `:q` merely **hides** | ||
| the still-loaded buffer instead of destroying it, so none of those autocmds fire | ||
| and the diff is never resolved. (`:ClaudeCodeDiffDeny` works because it calls | ||
| `_resolve_diff_as_rejected` directly, bypassing the autocmds.) | ||
|
|
||
| ## Quick start — headless one-liner (no WebSocket needed) | ||
|
|
||
| The fastest way to see the bug. Drives the real `diff.lua` and performs a genuine | ||
| `:q`, for both `open_in_new_tab` layouts: | ||
|
|
||
| ```sh | ||
| nvim --headless -u NONE -l scripts/repro_issue_238.lua; echo "exit: $?" | ||
| ``` | ||
|
|
||
| Exit code **1** = bug reproduced (current code), **0** = fixed. On current code it prints: | ||
|
|
||
| ``` | ||
| [default config (open_in_new_tab=false)] | ||
| proposed buffer bufhidden = "hide" | ||
| after :q -> rejected=false status=pending proposed_buf_still_loaded=true tabpages 1->1 | ||
| => BUG: `:q` did NOT reject the diff (Claude never receives DIFF_REJECTED) | ||
| [reporter config (open_in_new_tab=true)] | ||
| proposed buffer bufhidden = "hide" | ||
| after :q -> rejected=false status=pending proposed_buf_still_loaded=true tabpages 2->2 | ||
| => BUG: ... | ||
| ``` | ||
|
|
||
| ## Quick start — live, playing the role of Claude over MCP | ||
|
|
||
| ```sh | ||
| # Terminal 1 — the editor under test: | ||
| source fixtures/nvim-aliases.sh | ||
| vv issue-238 example/target.txt | ||
| # (equivalently: NVIM_APPNAME=issue-238 XDG_CONFIG_HOME=fixtures nvim fixtures/issue-238/example/target.txt) | ||
| # The server auto-starts; check the lock file: ls ~/.claude/ide/*.lock | ||
|
|
||
| # Terminal 2 — open a diff and HOLD the socket open while you reject in Neovim: | ||
| scripts/repro_issue_238.sh --file "$PWD/fixtures/issue-238/example/target.txt" --hold 30 | ||
| ``` | ||
|
|
||
| A diff opens in a new tab. In Neovim, try to reject it with **`:q`**. Then: | ||
|
|
||
| - Run `:DiffState` in Neovim → it still shows the diff as `[pending]` | ||
| (`bufhidden=hide`), and the tab is still open. | ||
| - The Terminal-2 script reports `no DIFF_REJECTED / FILE_SAVED in window` — Claude | ||
| never learned the diff was rejected. | ||
|
|
||
| Contrast: reject with `:ClaudeCodeDiffDeny` (or `<leader>ad`) instead → the script | ||
| prints `DIFF_REJECTED was received` and `:DiffState` shows the diff `[rejected]`. | ||
|
|
||
| > If `websocat` is a `mise` shim that refuses to run in this directory, pass the | ||
| > real binary: `WEBSOCAT="$(mise which websocat)" scripts/repro_issue_238.sh ...`. | ||
|
|
||
| ## Inspector commands (added by this fixture) | ||
|
|
||
| - `:DiffState` — notify window/tab count + each active diff's status, `created_new_tab`, and proposed `bufhidden`. | ||
| - `:DiffStateFile [path]` — write the same info as JSON (for automation; defaults to `stdpath('cache')/diff_state.json`). | ||
| - `<leader>as` — run `:DiffState`. | ||
| - `<leader>aa` / `<leader>ad` — accept / deny the focused diff. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| line one | ||
| line two | ||
| line three | ||
| line four | ||
| line five |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| -- Repro fixture for issue #238: "[BUG] Rejecting with `:q` does not work". | ||
| -- | ||
| -- Scenario this fixture is built to demonstrate: | ||
| -- 1. claudecode.nvim is configured exactly like the reporter: | ||
| -- - terminal.provider = "none" (Claude runs in an *external* terminal, | ||
| -- e.g. sidekick.nvim — Neovim manages no terminal of its own) | ||
| -- - diff_opts.open_in_new_tab = true | ||
| -- - diff_opts.layout = "vertical" | ||
| -- 2. Claude opens a diff via the `openDiff` MCP tool. It lands in a NEW tab | ||
| -- with the original file on the left and the proposed buffer on the right. | ||
| -- 3. The user tries to REJECT the change with `:q` (as the README documents: | ||
| -- "Reject: `:q` or <leader>ad"). | ||
| -- 4. EXPECTED: the diff is rejected (Claude is told DIFF_REJECTED) and the | ||
| -- tab closes. | ||
| -- ACTUAL (the bug): `:q` only closes the proposed window; the buffer is | ||
| -- merely *hidden* (it is a scratch buffer => bufhidden=hide), so none of | ||
| -- the BufDelete/BufUnload/BufWipeout autocmds that drive rejection fire. | ||
| -- The diff stays "pending" forever and the tab lingers. | ||
| -- | ||
| -- This fixture mirrors `remote-diff` but uses the reporter's exact config and | ||
| -- exposes a `:DiffStateFile` command that writes a machine-readable JSON | ||
| -- snapshot (window/tab counts, per-diff status, and the proposed buffer's | ||
| -- bufhidden) so automation can assert on the bug without scraping the screen. | ||
| -- | ||
| -- Usage (from repo root): | ||
| -- source fixtures/nvim-aliases.sh | ||
| -- vv issue-238 example/target.txt | ||
| -- # or: NVIM_APPNAME=issue-238 XDG_CONFIG_HOME=fixtures nvim fixtures/issue-238/example/target.txt | ||
| -- | ||
| -- Then drive the MCP side (play the role of Claude) with: | ||
| -- scripts/repro_issue_238.sh | ||
|
|
||
| local config_dir = vim.fn.stdpath("config") | ||
| local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") | ||
| vim.opt.rtp:prepend(repo_root) | ||
|
|
||
| vim.g.mapleader = " " | ||
| vim.g.maplocalleader = "\\" | ||
|
|
||
| local ok, claudecode = pcall(require, "claudecode") | ||
| assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) | ||
|
|
||
| -- The reporter's exact config uses open_in_new_tab = true, but the underlying | ||
| -- bug is not tab-specific. Set REPRO238_NEW_TAB=0 to probe the default | ||
| -- (same-tab) layout and confirm `:q` rejection is broken there too. | ||
| local open_in_new_tab = os.getenv("REPRO238_NEW_TAB") ~= "0" | ||
|
|
||
| claudecode.setup({ | ||
| auto_start = false, | ||
| -- Quiet logging keeps the diff UI clean for screenshots / automation and | ||
| -- avoids the hit-enter prompt that long :messages can trigger. | ||
| log_level = "warn", | ||
| terminal = { | ||
| -- The reporter uses sidekick.nvim to run Claude in an external terminal, | ||
| -- so claudecode itself manages no terminal: provider = "none". | ||
| provider = "none", | ||
| }, | ||
| diff_opts = { | ||
| layout = "vertical", | ||
| open_in_new_tab = open_in_new_tab, | ||
| keep_terminal_focus = false, | ||
| }, | ||
| }) | ||
|
|
||
| local function ensure_started() | ||
| local ok_start, started_or_err, port_or_err = pcall(function() | ||
| return claudecode.start(false) | ||
| end) | ||
| if not ok_start then | ||
| vim.notify("ClaudeCode start crashed: " .. tostring(started_or_err), vim.log.levels.ERROR) | ||
| return false | ||
| end | ||
| if started_or_err or port_or_err == "Already running" then | ||
| return true | ||
| end | ||
| vim.notify("ClaudeCode failed to start: " .. tostring(port_or_err), vim.log.levels.ERROR) | ||
| return false | ||
| end | ||
|
|
||
| ensure_started() | ||
|
|
||
| -- Build a snapshot of everything that matters for this bug. | ||
| local function diff_state() | ||
| local diff = require("claudecode.diff") | ||
| local active = diff._get_active_diffs() | ||
|
|
||
| local diffs = {} | ||
| for tab_name, data in pairs(active) do | ||
| local proposed_bufhidden = nil | ||
| if data.new_buffer and vim.api.nvim_buf_is_valid(data.new_buffer) then | ||
| proposed_bufhidden = vim.api.nvim_buf_get_option(data.new_buffer, "bufhidden") | ||
| end | ||
| diffs[#diffs + 1] = { | ||
| tab_name = tab_name, | ||
| status = data.status or "?", | ||
| created_new_tab = data.created_new_tab or false, | ||
| new_buffer = data.new_buffer, | ||
| new_buffer_valid = data.new_buffer and vim.api.nvim_buf_is_valid(data.new_buffer) or false, | ||
| new_buffer_loaded = data.new_buffer and vim.api.nvim_buf_is_loaded(data.new_buffer) or false, | ||
| proposed_bufhidden = proposed_bufhidden, | ||
| } | ||
| end | ||
| table.sort(diffs, function(a, b) | ||
| return tostring(a.tab_name) < tostring(b.tab_name) | ||
| end) | ||
|
|
||
| return { | ||
| windows = #vim.api.nvim_list_wins(), | ||
| tabpages = #vim.api.nvim_list_tabpages(), | ||
| active_diffs = #diffs, | ||
| diffs = diffs, | ||
| } | ||
| end | ||
|
|
||
| -- Human-readable variant. | ||
| vim.api.nvim_create_user_command("DiffState", function() | ||
| local s = diff_state() | ||
| local lines = { | ||
| ("windows=%d tabpages=%d active_diffs=%d"):format(s.windows, s.tabpages, s.active_diffs), | ||
| } | ||
| for _, d in ipairs(s.diffs) do | ||
| lines[#lines + 1] = (" [%s] new_tab=%s bufhidden=%s loaded=%s %s"):format( | ||
| d.status, | ||
| tostring(d.created_new_tab), | ||
| tostring(d.proposed_bufhidden), | ||
| tostring(d.new_buffer_loaded), | ||
| d.tab_name | ||
| ) | ||
| end | ||
| vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) | ||
| end, { desc = "Show window/tab count + active claudecode diffs" }) | ||
|
|
||
| -- Scriptable variant: writes the state as JSON to a file so external automation | ||
| -- can assert on it without scraping the message area. | ||
| vim.api.nvim_create_user_command("DiffStateFile", function(opts) | ||
| local path = opts.args ~= "" and opts.args or (vim.fn.stdpath("cache") .. "/diff_state.json") | ||
| local s = diff_state() | ||
| vim.fn.writefile({ vim.json.encode(s) }, path) | ||
| end, { nargs = "?", desc = "Write window/diff state as JSON to a file" }) | ||
|
|
||
| vim.keymap.set("n", "<leader>aa", "<cmd>ClaudeCodeDiffAccept<cr>", { desc = "Accept diff" }) | ||
| vim.keymap.set("n", "<leader>ad", "<cmd>ClaudeCodeDiffDeny<cr>", { desc = "Deny diff" }) | ||
| vim.keymap.set("n", "<leader>as", "<cmd>DiffState<cr>", { desc = "Show diff state" }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -867,6 +867,39 @@ local function register_diff_autocmds(tab_name, new_buffer) | |
| end, | ||
| }) | ||
|
|
||
| -- WinClosed: reject when the proposed window is closed (`:q`, `:close`, `<C-w>c`, `:tabclose`). | ||
| -- The proposed buffer is scratch (bufhidden="hide"), so closing its window merely HIDES the | ||
| -- still-loaded buffer and none of BufDelete/BufUnload/BufWipeout below fire -> `:q` would never | ||
| -- resolve the diff and Claude would never receive DIFF_REJECTED (issue #238). | ||
| -- | ||
| -- We do NOT bind to a single window-id pattern: the proposed buffer may be split into several | ||
| -- windows (<C-w>v), and rejecting just because the *tracked* window closed would prematurely | ||
| -- reject while the user still views a clone (and closing a clone would never match the pattern). | ||
| -- Instead we reject only once the proposed buffer is displayed in NO window (across all tabs; | ||
| -- a copy the user split into another tab defers rejection until that copy is closed too). | ||
| -- WinClosed fires BEFORE the closing window leaves the layout, so we exclude it (args.match). | ||
| -- _resolve_diff_as_rejected no-ops once status != "pending", so this is harmless after :w | ||
| -- (accept) or during _cleanup_diff_state (which deletes these autocmds before closing windows). | ||
| autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("WinClosed", { | ||
| group = get_autocmd_group(), | ||
| callback = function(args) | ||
| if not vim.api.nvim_buf_is_valid(new_buffer) then | ||
| return | ||
| end | ||
| local closing_win = tonumber(args.match) | ||
| local still_visible = false | ||
| for _, win in ipairs(vim.fn.win_findbuf(new_buffer)) do | ||
| if win ~= closing_win then | ||
| still_visible = true | ||
| break | ||
| end | ||
| end | ||
| if not still_visible then | ||
| M._resolve_diff_as_rejected(tab_name) | ||
| end | ||
| end, | ||
| }) | ||
|
Comment on lines
+883
to
+901
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Auto-teardown on client disconnect (close_diffs_for_client at diff.lua:1601) and on server stop (close_pending_diffs at diff.lua:1587) only match status=="pending", so a rejected-but-not-yet-cleaned-up diff is skipped — if Claude disconnects in the window between DIFF_REJECTED being sent and close_tab being received, the new tab + proposed buffer linger until VimLeavePre. However, this is a pre-existing design choice, not a regression introduced by this PR: Extended reasoning...AnalysisThe synthesis describes a real behavioral gap: after this PR, Why this is pre-existing, not a regressionThe refuting verifier makes a load-bearing point: The synthesis frames this as a regression by comparing against pre-PR Step-by-step proof (pre-existing reachability via
|
||
|
|
||
| -- Buffer deletion monitoring for rejection (multiple events to catch all deletion methods) | ||
|
|
||
| -- BufDelete: When buffer is deleted with :bdelete, :bwipeout, etc. | ||
|
|
@@ -902,6 +935,9 @@ local function register_diff_autocmds(tab_name, new_buffer) | |
| return autocmd_ids | ||
| end | ||
|
|
||
| -- Exposed for testing the reject-on-window-close (WinClosed) behavior. | ||
| M._register_diff_autocmds = register_diff_autocmds | ||
|
|
||
| ---Create diff view from a specific window | ||
| ---@param target_window NvimWin|nil The window to use as base for the diff | ||
| ---@param old_file_path string Path to the original file | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The new WinClosed handler at lua/claudecode/diff.lua:883-901 falsely rejects pending diffs whenever the proposed buffer was previously navigated away from (e.g. via
:e other_file,:b other_buf, or a file picker like snacks/telescope opening a file in the proposed window). Because the proposed buffer is a scratch buffer (bufhidden="hide"), navigating away merely hides it — none of BufDelete/BufUnload/BufWipeout fire, and the diff staysstatus="pending". Then any later unrelated WinClosed in the editor (a help split, a sidebar, a tabclose) makeswin_findbuf(new_buffer)return{}, the still_visible check fails, and_resolve_diff_as_rejectedsends a spurious DIFF_REJECTED to Claude — and on the next close_tab the proposed buffer carrying Claude'''s edits is silently wiped. Fix: track whether the closing window itself was showing the proposed buffer, and reject only when it was AND no other window is.Extended reasoning...
The bug
The
WinClosedautocmd registered inregister_diff_autocmds(lua/claudecode/diff.lua:883-901) is intentionally window-wide (nobuffer=/pattern=scope) — everyWinClosedanywhere in the editor invokes the callback for every active diff. The handler is supposed to reject the diff iff the close was the proposed buffer'''s last visible window. But the check it implements — "after excludingargs.match, is the buffer still in any window?" — conflates two scenarios thatwin_findbufreports identically when the result is{}:The handler'''s own comment at
diff.lua:880says "WinClosed fires BEFORE the closing window leaves the layout, so we exclude it (args.match)". That nails down case (a)'''s semantics: for a legitimate last-window close,win_findbuf(new_buffer)returns{closing_win}. Sowin_findbufreturning{}is exclusively case (b) — the bug path.How it triggers
The proposed buffer is created at
diff.lua:1267viavim.api.nvim_create_buf(false, true). The scratch flag setsbufhidden="hide", and the subsequentbuf_set_option("buftype", "acwrite")does not changebufhidden— the PR'''s own fixture (fixtures/issue-238/init.lua) explicitly capturesbufhiddenand confirms"hide". So when the user runs:e other_file/:b other_bufin the proposed window, or a file picker (snacks-picker default, common telescope/fzf-lua configs) opens a file into the current window, the proposed buffer becomes hidden but stays loaded. None of BufDelete/BufUnload/BufWipeout fires; the diff staysstatus="pending". Then any unrelated WinClosed anywhere triggers the spurious rejection.Step-by-step proof
bufhidden="hide", displayed in window 1001. Diff state:status="pending".select_default), or just runs:b some_other_bufferin window 1001. Window 1001 now shows a different buffer; buffer 42 is hidden but still loaded. No autocmd fires; diff status stays "pending".WinClosedfires withargs.match="9999".nvim_buf_is_valid(42)→true(still loaded, just hidden).closing_win = 9999.vim.fn.win_findbuf(42)→{}(buffer 42 is shown in zero windows).forloop body never executes;still_visibleremainsfalse.if not still_visible then M._resolve_diff_as_rejected(tab_name)runs._resolve_diff_as_rejectedseesstatus=="pending"and proceeds: it builds aDIFF_REJECTEDpayload and resumes the deferred coroutine with it. Claude is told the diff was rejected. On the nextclose_tabcleanup,_cleanup_diff_statewipes buffer 42 — silently destroying Claude'''s proposed edits.Why the existing guards do not catch this
status~="pending"guard inside_resolve_diff_as_rejecteddoesn'''t fire — status is still "pending" (no other resolution happened).nvim_buf_is_valid(new_buffer)only checks existence; it returnstruefor hidden-but-loaded buffers.BufHidden/BufWinLeaveautocmd to mark the diff as user-abandoned.Why this snuck past the tests
The first unit test in
tests/unit/diff_reject_on_quit_spec.lua("rejects the diff when the proposed window closes and the buffer is no longer visible", lines 80–92) locks in this bug. It mockswin_findbuf={}whileargs.match="1001"and asserts rejection. But per the comment ondiff.lua:880(which the very next test cites: "WinClosed fires BEFORE the window leaves the layout, so win_findbuf still lists the closing window"), the legitimate last-window-close case is the one the second test models —win_findbuf={1001}.win_findbuf={}only models case (b) — the user-navigated-away scenario — and the test asserts that this rejects. So the test name implies case (a) but the mock encodes case (b), and that case (b) is precisely the bug.Fix
Track whether the closing window itself was showing the proposed buffer, and reject only when it was AND no other window is:
Equivalently, check
vim.api.nvim_win_get_buf(closing_win) == new_bufferwhile the window still exists (WinClosed fires before layout change, so it does). Either approach makes the spurious-close case (b) a no-op while preserving the legitimate case (a) — and the existing tests for split-still-visible and unrelated-window-no-reject keep passing. The first unit test should then be updated to model the legitimate case (win_findbuf={1001}) and asserts the same rejection.