Skip to content

[BUG] Rejecting with :q does not work #238

Closed

Description

@vinicius507

Bug Description

The help section :help claudecode.nvim-working-with-diffs mentions that we can both accept and reject changes using :w or :q. Accepting seems to work fine, but rejecting simply closes one of the diff buffers and then proceeds to do absolutely nothing.

To Reproduce

Steps to reproduce the behavior:

  1. Setup claudecode.nvim with sidekick.nvim:
    return {
      {
        "coder/claudecode.nvim",
        dependencies = { "folke/snacks.nvim" },
        event = "User SidekickCliAttach",
        keys = {
          { "<leader>aA", "<cmd>ClaudeCodeDiffAccept<cr>", desc = "Accept Diff" },
          { "<leader>aR", "<cmd>ClaudeCodeDiffDeny<cr>", desc = "Reject Diff" },
        },
        opts = {
          terminal = {
            provider = "none",
          },
          diff_opts = {
            layout = "vertical",
            open_in_new_tab = true,
          },
        },
      },
      {
        "folke/sidekick.nvim",
        dependencies = { "copilot.lua", "snacks.nvim" },
        event = { "BufReadPre", "BufNewFile", "VeryLazy" },
        keys = {
          {
            "<tab>",
            function()
              if not require("sidekick").nes_jump_or_apply() then
                return "<Tab>"
              end
            end,
            expr = true,
            desc = "Goto/Apply Next Edit Suggestion",
          },
          {
            "<c-.>",
            function()
              require("sidekick.cli").toggle({ filter = { installed = true } })
            end,
            desc = "Sidekick Toggle",
            mode = { "n", "t", "i", "x" },
          },
          {
            "<leader>as",
            function()
              require("sidekick.cli").select({ filter = { installed = true } })
            end,
            desc = "Select CLI",
          },
          {
            "<leader>ad",
            function()
              require("sidekick.cli").close()
            end,
            desc = "Detach a CLI Session",
          },
          {
            "<leader>at",
            function()
              require("sidekick.cli").send({ msg = "{this}" })
            end,
            mode = { "x", "n" },
            desc = "Send This",
          },
          {
            "<leader>af",
            function()
              require("sidekick.cli").send({ msg = "{file}" })
            end,
            desc = "Send File",
          },
          {
            "<leader>av",
            function()
              require("sidekick.cli").send({ msg = "{selection}" })
            end,
            mode = { "x" },
            desc = "Send Visual Selection",
          },
          {
            "<leader>ap",
            function()
              require("sidekick.cli").prompt()
            end,
            mode = { "n", "x" },
            desc = "Sidekick Select Prompt",
          },
          {
            "<leader>ac",
            function()
              require("sidekick.cli").toggle({ name = "claude", focus = true })
            end,
            desc = "Sidekick Toggle Claude Code",
          },
          {
            "<leader>ag",
            function()
              require("sidekick.cli").toggle({ name = "gemini", focus = true })
            end,
            desc = "Sidekick Toggle Gemini",
          },
          {
            "<leader>ao",
            function()
              require("sidekick.cli").toggle({ name = "opencode", focus = true })
            end,
            desc = "Sidekick Toggle OpenCode",
          },
        },
        ---@type sidekick.Config
        opts = {
          cli = {
            win = {
              keys = {
                buffers = false,
                files = false,
                hide_n = false,
                hide_ctrl_q = false,
                hide_ctrl_dot = false,
                hide_ctrl_z = false,
                prompt = { "<M-p>", "prompt", mode = "t", desc = "Insert prompt or context" },
                stopinsert = { "<c-q>", "stopinsert", mode = "t", desc = "Enter normal mode" },
                nav_left = vim.env.ZELLIJ and false,
                nav_down = vim.env.ZELLIJ and false,
                nav_up = vim.env.ZELLIJ and false,
                nav_right = vim.env.ZELLIJ and false,
              },
            },
          },
          nes = {
            enabled = function(buf)
              local bufname = vim.api.nvim_buf_get_name(buf)
              local ignore_buffers = { "%.env.*" }
    
              return not vim.iter(ignore_buffers):any(function(pattern)
                return string.match(bufname, pattern)
              end)
            end,
          },
        },
      },
    }
  2. Open claude with sidekick.nvim (i.e. <leader>ac on the above configuration)
  3. Trigger a file edit with claude
  4. When the diff opens, try to reject with :q
  5. The current diff buffer closes but the tabpage remains open with the remaining buffer and the change is not rejected

Expected Behavior

The tabpage with the diff closes and claude refuses the edit.

Environment

  • Neovim version: 0.12.1
  • Claude Code CLI version: 2.1.107
  • OS: Linux <redacted> 6.18.22-1-lts #1 SMP PREEMPT_DYNAMIC Sat, 11 Apr 2026 17:16:25 +0000 x86_64 GNU/Linux
  • Plugin version: commit 432121f

Activity

  1. ThomasK33 commented on Jun 8, 2026

    @ThomasK33
    Member

    Note

    This triage report is AI-generated using Claude Code

    Summary

    This was confirmed as a real bug: rejecting a Claude diff with :q does nothing — the change is never rejected and Claude is never told. The <leader>ad / :ClaudeCodeDiffDeny path works fine. The behaviour is not specific to the reported configuration (open_in_new_tab = true + terminal.provider = "none"): it also reproduces with the default open_in_new_tab = false. It also appears to have never worked, rather than being a recent regression.

    Reproduction

    The issue was reproduced two independent ways: by driving a real Neovim while a WebSocket client played the role of Claude over the MCP socket, and by a headless script that exercises the real diff code path and performs a genuine :q. Both agree:

    Scenario DIFF_REJECTED sent to Claude? Diff status afterwards
    New-tab :q (reported config) No still pending; tab lingers
    New-tab :ClaudeCodeDiffDeny Yes rejected
    Same-tab :q (default config) No still pending

    In the new-tab case the proposed window closes but the tab stays open showing the original file — matching the report. In the same-tab case the split simply collapses back to the original file with no other feedback, so the silent failure is even easier to miss there.

    Root cause

    In lua/claudecode/diff.lua, the proposed ("new") buffer is created as a scratch buffer (vim.api.nvim_create_buf(false, true)), which implicitly sets bufhidden = "hide", and it is left modified. Rejection is wired only to buffer-destruction autocommands — BufDelete / BufUnload / BufWipeout → _resolve_diff_as_rejected. Because the buffer is bufhidden = "hide", running :q merely hides the still-loaded buffer instead of destroying it, so none of those autocommands fire and the diff is never resolved (the blocking openDiff call parks forever). :ClaudeCodeDiffDeny works only because it calls _resolve_diff_as_rejected directly, bypassing the autocommands.

    A tempting one-line fix — switching the buffer to bufhidden = "wipe" — was tested and found not to work: the proposed buffer is modified, so :q then aborts with E37: No write since last change and the window never closes at all.

    Recommended fix

    A WinClosed autocommand (registered alongside the existing reject autocommands, so the standard cleanup tears it down) resolves the diff as rejected when the proposed window is closed — covering :q, :close, <C-w>c, and closing the tab, regardless of bufhidden. To avoid a premature rejection when the proposed buffer has been split into several windows, the handler rejects only once the buffer is displayed in no window (WinClosed fires before the closing window leaves the layout, so it is excluded from the count). This mirrors the event other Neovim AI-diff plugins use for the same "user closed the proposed window" case.

    The candidate fix was applied and verified end-to-end:

    • the headless reproduction flips from failing (exit 1) to passing (exit 0) for both layouts;
    • the multi-window edge case behaves correctly — closing one split does not reject, closing the last one rejects exactly once;
    • accept (:w), close_tab / closeAllDiffTabs, client-disconnect cleanup, new-file diffs, and double-resolution are unaffected (cleanup deletes the autocommand before it closes the window, and _resolve_diff_as_rejected no-ops once the diff is not pending);
    • luacheck is clean and the full test suite passes (523/523).

    The exact diff and the reproduction tooling are in the collapsible sections below for review.

    Documentation note

    The plugin does not appear to ship a Vim help file, so the :help claudecode.nvim-working-with-diffs section referenced in the report does not seem to exist in the repository; the ":q to reject" behaviour is documented in the README instead. Once a fix lands, that documentation will finally match behaviour. It may also be worth stating explicitly that closing the proposed diff window by any means (:q, :close, <C-w>c, closing the tab) counts as a rejection — matching the "close the diff = reject" contract of the official IDE integrations.

    Reproduction artifacts

    A reproduction fixture and two scripts were created during triage and screened for sensitive content before posting. They are not committed; they are included below so the reproduction and the candidate fix can be reviewed independently. The fastest path to see the bug is the headless one-liner, which needs no WebSocket:

    nvim --headless -u NONE -l scripts/repro_issue_238.lua   # exit 1 = bug reproduced, exit 0 = fixed
    Recommended fix — diff for lua/claudecode/diff.lua
    diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua
    index 028ed14..ae558c0 100644
    --- a/lua/claudecode/diff.lua
    +++ b/lua/claudecode/diff.lua
    @@ -867,6 +867,38 @@ 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. WinClosed fires
    +  -- BEFORE the closing window leaves the layout, so we exclude it (args.match) from the count.
    +  -- _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,
    +  })
    +
       -- Buffer deletion monitoring for rejection (multiple events to catch all deletion methods)
     
       -- BufDelete: When buffer is deleted with :bdelete, :bwipeout, etc.
    scripts/repro_issue_238.lua — headless reproduction / regression gate
    -- Reproduction / verification for issue #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.
    --
    -- Root cause: the proposed ("new") buffer is created with
    --   vim.api.nvim_create_buf(false, true)   -- scratch => bufhidden = "hide"
    -- and rejection is wired ONLY through buffer-destruction autocmds
    --   BufDelete / BufUnload / BufWipeout  ->  _resolve_diff_as_rejected
    -- Because bufhidden = "hide", running `:q` on the proposed window merely HIDES
    -- the buffer (the window closes, the buffer stays loaded), so none of those
    -- autocmds fire and the diff is never resolved as rejected. Claude is never
    -- told DIFF_REJECTED and (with open_in_new_tab=true) the tab lingers.
    --
    -- This script drives the REAL diff.lua (the exact path the openDiff MCP tool
    -- uses, M._setup_blocking_diff) and performs a genuine `:q` on the proposed
    -- window — no WebSocket / Claude CLI needed. It both reproduces the bug (on
    -- unfixed code) and verifies a fix.
    --
    -- Run from the repo root:
    --   nvim --headless -u NONE -l scripts/repro_issue_238.lua
    --
    -- Exit code: 1 if the bug is reproduced (any scenario fails to reject on `:q`),
    -- 0 if `:q` rejects in every scenario (fixed). The verdict is printed either way.
    
    local script_path = debug.getinfo(1, "S").source:sub(2)
    local repo_root = vim.fn.fnamemodify(script_path, ":h:h")
    vim.opt.rtp:prepend(repo_root)
    
    local function out(msg)
      io.stdout:write(msg .. "\n")
    end
    
    local diff = require("claudecode.diff")
    
    -- Match the reporter's terminal config (Claude runs in an external terminal, so
    -- claudecode manages no terminal of its own). Best-effort: the bug does not
    -- depend on the provider, but this keeps the repro faithful.
    pcall(function()
      require("claudecode.terminal").setup({ provider = "none" }, nil)
    end)
    
    ---Write a real on-disk file so old_file_exists = true (editing an existing file).
    ---@return string path
    local function make_target()
      local path = repo_root .. "/__issue_238_repro__.txt"
      vim.fn.writefile({ "line one", "line two", "line three", "line four", "line five" }, path)
      return path
    end
    
    ---Reset to a single empty window/tab between scenarios.
    local function reset_editor()
      pcall(function()
        diff._cleanup_all_active_diffs("repro reset")
      end)
      pcall(vim.cmd, "silent! tabonly")
      pcall(vim.cmd, "silent! only")
      pcall(vim.cmd, "silent! enew!")
    end
    
    ---Open a diff for an existing file, then reject it with a genuine `:q`.
    ---@param open_in_new_tab boolean
    ---@return table report
    local function run_scenario(open_in_new_tab)
      reset_editor()
      diff.setup({
        diff_opts = { layout = "vertical", open_in_new_tab = open_in_new_tab, keep_terminal_focus = false },
        terminal = { provider = "none" },
      })
    
      local target = make_target()
      local tab_name = ("REPRO238 target.txt (new_tab=%s)"):format(tostring(open_in_new_tab))
    
      -- The resolution callback is what the deferred-response system uses to send
      -- the result back to Claude. If `:q` rejects correctly it fires with
      -- DIFF_REJECTED; if the bug is present it never fires.
      local captured = nil
      diff._setup_blocking_diff({
        old_file_path = target,
        new_file_path = target,
        new_file_contents = "line one\nline two\nline three (EDITED BY CLAUDE)\nline four\nline five\n",
        tab_name = tab_name,
      }, function(result)
        captured = result
      end)
    
      -- Inspect the freshly-created diff state.
      local active = diff._get_active_diffs()
      local data = active[tab_name]
      assert(data, "diff state was not registered for " .. tab_name)
      local proposed_buf = data.new_buffer
      local bufhidden = vim.api.nvim_buf_get_option(proposed_buf, "bufhidden")
      local tabs_before = #vim.api.nvim_list_tabpages()
    
      -- Make the proposed window current and reject with a genuine `:q`, exactly as
      -- the user would. setup_new_buffer leaves the proposed window focused, but be
      -- explicit so the test does not depend on that.
      if data.new_window and vim.api.nvim_win_is_valid(data.new_window) then
        vim.api.nvim_set_current_win(data.new_window)
      end
      pcall(vim.cmd, "quit")
    
      -- Measure BEFORE any cleanup so we see exactly what `:q` alone did.
      local status_after = (diff._get_active_diffs()[tab_name] or {}).status
      local buf_loaded_after = vim.api.nvim_buf_is_loaded(proposed_buf)
      local tabs_after = #vim.api.nvim_list_tabpages()
      local rejected = captured ~= nil
        and captured.content ~= nil
        and captured.content[1] ~= nil
        and captured.content[1].text == "DIFF_REJECTED"
    
      return {
        open_in_new_tab = open_in_new_tab,
        proposed_bufhidden = bufhidden,
        rejected = rejected,
        status_after = status_after,
        buf_loaded_after = buf_loaded_after,
        tabs_before = tabs_before,
        tabs_after = tabs_after,
      }
    end
    
    out("== issue #238 reproduction: reject-with-:q ==")
    out(("Neovim: %s"):format(vim.version and tostring(vim.version()) or "?"))
    
    local scenarios = {
      { label = "default config (open_in_new_tab=false)", new_tab = false },
      { label = "reporter config (open_in_new_tab=true)", new_tab = true },
    }
    
    local any_bug = false
    for _, sc in ipairs(scenarios) do
      local r = run_scenario(sc.new_tab)
      out("")
      out(("[%s]"):format(sc.label))
      out(("  proposed buffer bufhidden = %q"):format(tostring(r.proposed_bufhidden)))
      out(
        ("  after :q -> rejected=%s  status=%s  proposed_buf_still_loaded=%s  tabpages %d->%d"):format(
          tostring(r.rejected),
          tostring(r.status_after),
          tostring(r.buf_loaded_after),
          r.tabs_before,
          r.tabs_after
        )
      )
      if r.rejected then
        out("  => OK: `:q` resolved the diff as DIFF_REJECTED")
      else
        out("  => BUG: `:q` did NOT reject the diff (Claude never receives DIFF_REJECTED)")
        any_bug = true
      end
    end
    
    reset_editor()
    pcall(os.remove, repo_root .. "/__issue_238_repro__.txt")
    
    out("")
    out("== verdict ==")
    if any_bug then
      out("BUG REPRODUCED: `:q` fails to reject the Claude diff (issue #238).")
    else
      out("FIXED: `:q` rejects the Claude diff in every scenario.")
    end
    
    io.stdout:flush()
    vim.cmd("cquit " .. (any_bug and 1 or 0))
    scripts/repro_issue_238.sh — WebSocket (MCP) driver that plays the role of Claude
    #!/usr/bin/env bash
    #
    # repro_issue_238.sh — Reproduce GitHub issue #238
    # "[BUG] Rejecting with `:q` does not work"
    #
    # Symptom: with terminal.provider = "none" and diff_opts.open_in_new_tab = true,
    # rejecting a Claude diff with `:q` closes the proposed window but does NOT
    # reject the change — Claude is never told DIFF_REJECTED and the tab lingers.
    #
    # This script acts as the MCP client (i.e. it plays the role of Claude). It:
    #   1. discovers the running claudecode.nvim server from its lock file,
    #   2. sends the MCP initialize handshake,
    #   3. opens ONE diff via `openDiff` (blocks server-side — deferred response),
    #   4. KEEPS THE SOCKET OPEN for --hold seconds, logging every server->client
    #      frame to --out, so you can `:q` in Neovim and observe whether a
    #      DIFF_REJECTED response ever comes back.
    #
    # Unlike repro_issue_248.sh (which disconnects to test on_disconnect cleanup),
    # this script stays connected so the rejection signal — if any — is captured.
    #
    # Usage:
    #   scripts/repro_issue_238.sh --file /abs/path/to/target.txt \
    #       --lock-dir "$CLAUDE_CONFIG_DIR/ide" --out /tmp/frames.jsonl --hold 30
    #
    # Env:
    #   CLAUDE_CONFIG_DIR   if set, lock files are read from "$CLAUDE_CONFIG_DIR/ide"
    #                       (matches lua/claudecode/lockfile.lua get_lock_dir()).
    #
    # Requirements: websocat, jq.
    
    set -euo pipefail
    
    FILE=""
    LOCK_DIR="${CLAUDE_LOCKFILE_DIR:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/ide}}"
    LOCK_DIR="${LOCK_DIR:-$HOME/.claude/ide}"
    OUT=""
    HOLD=30
    TAB="✻ [Claude Code] target.txt (issue238) ⧉"
    
    while [[ $# -gt 0 ]]; do
      case "$1" in
      --file)
        FILE="$2"
        shift 2
        ;;
      --lock-dir)
        LOCK_DIR="$2"
        shift 2
        ;;
      --out)
        OUT="$2"
        shift 2
        ;;
      --hold)
        HOLD="$2"
        shift 2
        ;;
      --tab)
        TAB="$2"
        shift 2
        ;;
      -h | --help)
        sed -n '2,40p' "$0"
        exit 0
        ;;
      *)
        echo "Unknown arg: $1" >&2
        exit 2
        ;;
      esac
    done
    
    # Allow overriding the websocat binary (e.g. to bypass a mise shim that refuses
    # to run in an untrusted directory): WEBSOCAT=/abs/path/to/websocat scripts/...
    WEBSOCAT="${WEBSOCAT:-websocat}"
    command -v "$WEBSOCAT" >/dev/null || {
      echo "ERROR: websocat not found (looked for: $WEBSOCAT)" >&2
      exit 1
    }
    command -v jq >/dev/null || {
      echo "ERROR: jq not found" >&2
      exit 1
    }
    [[ -n "$FILE" ]] || {
      echo "ERROR: --file is required" >&2
      exit 2
    }
    [[ -f "$FILE" ]] || {
      echo "ERROR: file not found: $FILE" >&2
      exit 2
    }
    OUT="${OUT:-$(mktemp -t repro238.frames.XXXXXX)}"
    
    # --- discover the running server -------------------------------------------
    LOCK_FILE=$(find "$LOCK_DIR" -maxdepth 1 -name '*.lock' -type f 2>/dev/null | head -1 || true)
    if [[ -z "$LOCK_FILE" ]]; then
      echo "ERROR: no lock file in $LOCK_DIR — is the fixture's server running?" >&2
      exit 1
    fi
    PORT=$(basename "$LOCK_FILE" .lock)
    TOKEN=$(jq -r '.authToken // empty' "$LOCK_FILE")
    [[ -n "$TOKEN" ]] || {
      echo "ERROR: lock file missing authToken: $LOCK_FILE" >&2
      exit 1
    }
    
    echo "server     : ws://127.0.0.1:$PORT"
    echo "lock file  : $LOCK_FILE"
    echo "file       : $FILE"
    echo "frames out : $OUT"
    echo "hold       : ${HOLD}s"
    echo
    
    # --- build the MCP message stream ------------------------------------------
    REQ=$(mktemp -t repro238.req.XXXXXX)
    trap 'rm -f "$REQ"' EXIT
    emit() { printf '%s\n' "$1" >>"$REQ"; }
    
    emit '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"repro-issue-238","version":"1.0.0"}}}'
    emit '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
    
    # Proposed contents: original file with one line changed, so the diff is real.
    contents="$(sed 's/line three/line three (EDITED BY CLAUDE)/' "$FILE")"
    msg=$(jq -nc \
      --arg old "$FILE" --arg new "$FILE" --arg contents "$contents" --arg tab "$TAB" \
      '{jsonrpc:"2.0",id:101,method:"tools/call",params:{name:"openDiff",arguments:{old_file_path:$old,new_file_path:$new,new_file_contents:$contents,tab_name:$tab}}}')
    emit "$msg"
    echo "  -> openDiff: $TAB"
    
    # --- run the connection, holding it open while we capture server frames -----
    # URL must come BEFORE --header (websocat --header is variadic).
    : >"$OUT"
    (
      tail -n +1 -f "$REQ" &
      TAIL_PID=$!
      sleep "$HOLD"
      kill "$TAIL_PID" 2>/dev/null || true
    ) | "$WEBSOCAT" -t "ws://127.0.0.1:$PORT" --header "x-claude-code-ide-authorization: $TOKEN" 2>/dev/null |
      tee "$OUT" || true
    
    echo
    echo "=== server -> client frames captured in $OUT ==="
    if grep -q DIFF_REJECTED "$OUT" 2>/dev/null; then
      echo "RESULT: DIFF_REJECTED was received — rejection WORKED."
    elif grep -q FILE_SAVED "$OUT" 2>/dev/null; then
      echo "RESULT: FILE_SAVED was received — diff was accepted."
    else
      echo "RESULT: no DIFF_REJECTED / FILE_SAVED in window — diff was NOT resolved (the #238 bug)."
    fi
    fixtures/issue-238/init.lua — minimal fixture config matching the report
    -- 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" })

    Reproduced and verified on Neovim 0.13.0-dev with the current main (commit 8ec3212).

  2. added a commit that references this issue on Jun 8, 2026
    de68a85
  3. added a commit that references this issue on Oct 31, 2026
  4. added 2 commits that reference this issue on Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions