Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Bug Fixes

- `closeAllDiffTabs` no longer destroys diffs it does not own. It previously closed every window with `&diff` set and force-deleted any buffer named like `*.diff`/`diff://`/`fugitive://`, so an open diffview.nvim, fugitive, or native `:diffsplit` review was wiped out — and because the Claude CLI calls this tool at the start of every turn, it happened on essentially every prompt. The tool is now scoped to claudecode's own tracked diffs (matching the official VS Code extension, which only closes the tabs it labelled). Relatedly, `openFile`/`openDiff` no longer reuse a window that is in diff mode, which previously `:edit`-ed over one side of an unrelated diff and broke its layout. ([#277](https://github.com/coder/claudecode.nvim/issues/277))
- The Claude terminal now adds the loopback hosts (`localhost`, `127.0.0.1`, `::1`) to `no_proxy`/`NO_PROXY`, so a configured `http_proxy`/`all_proxy` no longer tunnels Claude's `ws://127.0.0.1` IDE connection and causes queued @ mentions to time out. Existing `no_proxy` exclusions are preserved. ([#70](https://github.com/coder/claudecode.nvim/issues/70))
- `focus_after_send = true` no longer fails silently with `terminal.provider = "none"`/`"external"`: those providers run Claude outside Neovim, so focus cannot move there. A one-time warning is now emitted at setup pointing to the new `User ClaudeCodeSendComplete` autocmd, which you can hook to focus your own terminal. (`focus_after_send` still only auto-focuses the in-editor providers.) ([#228](https://github.com/coder/claudecode.nvim/issues/228))
- Rejecting a Claude diff with `:q` (or `:close` / `<C-w>c` / closing the tab) now resolves it as rejected, matching the documented behavior. The proposed buffer is a scratch buffer that `:q` only hides, so the existing `BufDelete`/`BufUnload`/`BufWipeout` autocmds never fired; a `WinClosed` autocmd now handles window-close rejection. ([#238](https://github.com/coder/claudecode.nvim/issues/238))
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ The WebSocket server implements secure authentication using:
- `checkDocumentDirty` - Checks if document has unsaved changes
- `saveDocument` - Saves document with detailed success/failure reporting
- `getWorkspaceFolders` - Gets workspace folder information
- `closeAllDiffTabs` - Closes all diff-related tabs and windows
- `closeAllDiffTabs` - Closes the diffs claudecode itself opened (its tracked diff registry only). It never touches diffs created by other tools such as diffview.nvim, fugitive, or native `:diffsplit`, and `openFile`/`openDiff` never reuse a window that is currently in diff mode (issue #277).
- `getDiagnostics` - Gets language diagnostics (errors, warnings) from the editor

**Internal Tools** (not exposed via MCP):
Expand Down
49 changes: 49 additions & 0 deletions fixtures/issue-277/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Fixture: issue #277 — closeAllDiffTabs destroys foreign diffs

Reproduction environment for
[#277 "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated diffs (diffview.nvim)"](https://github.com/coder/claudecode.nvim/issues/277).

Two defects:

1. `tools/close_all_diff_tabs.lua` closes **every** window with `&diff` set (and
force-deletes `%.diff$` / `diff://` / `fugitive://` buffers) with no check
that claudecode created them. The Claude CLI invokes `closeAllDiffTabs` at
the **start of each user turn** when an IDE is connected (verified against
CLI 2.1.175), so any diffview.nvim / fugitive / native vimdiff layout that is
open when you submit a prompt gets destroyed.
2. `find_main_editor_window()` (in `tools/open_file.lua` and `diff.lua`) does
not exclude `&diff` windows, so `openFile`/`openDiff` can `:edit` into one
half of a foreign diff, corrupting it (the new buffer joins the diff).

## Scripted reproduction

```bash
scripts/repro_issue_277.sh
```

Drives a real Neovim TUI under agent-tty, opens diffview/native diffs, then
sends the same MCP `tools/call` requests the Claude CLI sends. Prints
`REPRODUCED:`/`NOT REPRODUCED:` per phase; exits 0 when all three defects
reproduce.

## Manual reproduction

```bash
source fixtures/nvim-aliases.sh && vv issue-277 # cwd must be a git repo with changes
```

1. `:DiffviewOpen` — side-by-side diff appears (2 windows with `&diff` + file panel).
2. Connect Claude (`:ClaudeCode`, or any client with the lock-file token).
3. Submit any prompt (or send `closeAllDiffTabs` by hand).
4. Both diff windows close; only the Diffview panel survives. `<leader>aw`
shows the diff-window count; `:ReproState` dumps per-window state.

`:ReproNativeDiff <a> <b>` opens a plugin-free native vimdiff for the same
experiment (no diffview involved).

## Notes

- diffview.nvim is cloned into `stdpath("data")/diffview.nvim`
(`~/.local/share/issue-277/`) on first start.
- The fixture exposes `v:lua.Repro277State()`, `v:lua.Repro277DiffWinCount()`
and `v:lua.Repro277Server()` for `--remote-expr` scripting.
136 changes: 136 additions & 0 deletions fixtures/issue-277/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
-- Fixture for issue #277:
-- "[BUG] closeAllDiffTabs closes all diff-mode windows, destroying unrelated
-- diffs (diffview.nvim)"
-- https://github.com/coder/claudecode.nvim/issues/277
--
-- Two defects under test:
-- 1. tools/close_all_diff_tabs.lua closes EVERY `&diff` window (no ownership
-- check), so diffview.nvim / fugitive / native `:diffthis` layouts are
-- destroyed when the Claude CLI fires closeAllDiffTabs (it does so at the
-- start of a user turn whenever an IDE is connected).
-- 2. find_main_editor_window (tools/open_file.lua and diff.lua) does not
-- exclude `&diff` windows, so openFile/openDiff target a diffview window
-- and :edit into it, corrupting the diff layout.
--
-- The fixture pulls in diffview.nvim (cloned on first run) and exposes a
-- window-state probe for scripted verification:
-- nvim --server <sock> --remote-expr 'v:lua.Repro277State()'
--
-- Usage (from repo root):
-- source fixtures/nvim-aliases.sh && vv issue-277
-- or scripted:
-- scripts/repro_issue_277.sh
--
-- Manual repro: open a file in a git repo with uncommitted changes,
-- :DiffviewOpen, connect claude (--ide), submit any prompt -> the side-by-side
-- diff windows close, only the Diffview file panel survives.

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 = "\\"

-- ---------------------------------------------------------------------------
-- diffview.nvim (cloned into stdpath("data") on first run; no plugin manager)
-- ---------------------------------------------------------------------------
local diffview_dir = vim.fn.stdpath("data") .. "/diffview.nvim"
if vim.fn.isdirectory(diffview_dir) == 0 then
vim.notify("issue-277 fixture: cloning diffview.nvim ...")
local out = vim.fn.system({
"git",
"clone",
"--depth=1",
"https://github.com/sindrets/diffview.nvim",
diffview_dir,
})
assert(vim.v.shell_error == 0, "failed to clone diffview.nvim: " .. out)
end
vim.opt.rtp:prepend(diffview_dir)

local ok_dv, diffview = pcall(require, "diffview")
assert(ok_dv, "Failed to load diffview.nvim: " .. tostring(diffview))
diffview.setup({})

-- ---------------------------------------------------------------------------
-- claudecode.nvim (dev version from this repo)
-- ---------------------------------------------------------------------------
local ok, claudecode = pcall(require, "claudecode")
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))

claudecode.setup({
auto_start = true, -- server + lock file immediately, so scripts can connect
-- "warn", not "debug": multi-line debug echoes trip nvim's hit-enter prompt,
-- which blocks --remote-expr probes in the scripted repro.
log_level = "warn",
terminal = {
provider = "native",
auto_close = false,
},
})

vim.o.showtabline = 2
vim.o.laststatus = 2

-- ---------------------------------------------------------------------------
-- Window-state probe (for --remote-expr / on-screen verification)
-- ---------------------------------------------------------------------------

---Compact state of every window across all tabpages.
---@return string JSON: [{win,tab,name,buftype,filetype,diff}...]
function _G.Repro277State()
local out = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
local buf = vim.api.nvim_win_get_buf(win)
local name = vim.api.nvim_buf_get_name(buf)
out[#out + 1] = {
win = win,
tab = vim.api.nvim_tabpage_get_number(vim.api.nvim_win_get_tabpage(win)),
name = vim.fn.fnamemodify(name, ":t") ~= "" and vim.fn.fnamemodify(name, ":~:.") or "[No Name]",
buftype = vim.bo[buf].buftype,
filetype = vim.bo[buf].filetype,
diff = vim.wo[win].diff,
}
end
return vim.json.encode(out)
end

---WebSocket endpoint of the running claudecode server ("port token", or "" if
---not started yet). Lets scripts connect without scanning ~/.claude/ide.
---@return string
function _G.Repro277Server()
local cc = require("claudecode")
if cc.state.port and cc.state.auth_token then
return cc.state.port .. " " .. cc.state.auth_token
end
return ""
end

---Count of windows currently in diff mode (quick assertion helper).
---@return integer
function _G.Repro277DiffWinCount()
local n = 0
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.wo[win].diff then
n = n + 1
end
end
return n
end

vim.api.nvim_create_user_command("ReproState", function()
vim.notify(_G.Repro277State())
end, { desc = "Show issue-277 window state" })

-- Native (plugin-free) diff variant of the same bug: two `:diffsplit` windows.
vim.api.nvim_create_user_command("ReproNativeDiff", function(cmd_opts)
local args = vim.split(cmd_opts.args, "%s+")
assert(#args == 2, "usage: :ReproNativeDiff <file_a> <file_b>")
vim.cmd("edit " .. vim.fn.fnameescape(args[1]))
vim.cmd("vertical diffsplit " .. vim.fn.fnameescape(args[2]))
end, { nargs = "+", complete = "file", desc = "Open a native vimdiff of two files" })

vim.keymap.set("n", "<leader>aw", function()
vim.notify(("diff windows: %d"):format(_G.Repro277DiffWinCount()))
end, { desc = "Show diff window count" })
57 changes: 54 additions & 3 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ local function find_main_editor_window()
is_suitable = false
end

-- Skip windows already in diff mode -- a user vimdiff/diffview.nvim/fugitive
-- pane, or one of claudecode's own diff panes. Opening a file into one
-- clears its window-local 'diff' and destroys that diff layout (issue #277).
if is_suitable and vim.api.nvim_win_get_option(win, "diff") then
is_suitable = false
Comment thread
ThomasK33 marked this conversation as resolved.
end

if
is_suitable
and (
Expand Down Expand Up @@ -170,6 +177,27 @@ end
-- Exposed for testing the sidebar/explorer exclusion logic.
M._find_main_editor_window = find_main_editor_window

---Whether the given tabpage contains any window in diff mode. A Neovim diff is
---scoped to a tabpage -- every &diff window in a tab participates in one shared
---diff set -- so if a tab already hosts a foreign diff (vimdiff/diffview.nvim/
---fugitive), creating Claude's diff in that tab would make its panes join and
---corrupt the user's review. Such cases are routed to a dedicated tab instead
---(issue #277). At diff-setup time claudecode has not created its own diff
---windows yet, so any match here is a foreign diff.
---@param tabpage integer Tabpage handle (0 = current)
---@return boolean
local function tabpage_has_diff_window(tabpage)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
if vim.api.nvim_win_get_option(win, "diff") then
return true
end
end
return false
end

-- Exposed for testing the foreign-diff-tab detection.
M._tabpage_has_diff_window = tabpage_has_diff_window

---Find the Claude Code terminal window to keep focus there.
---Uses the terminal provider to get the active terminal buffer, then finds its window.
---@return number? win_id Window ID of the Claude Code terminal window, or nil if not found
Expand Down Expand Up @@ -1289,7 +1317,10 @@ function M._setup_blocking_diff(params, resolution_callback)

if existing_buffer then
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == existing_buffer then
-- Don't reuse a window that is already in diff mode (e.g. the old
-- file shown inside the user's diffview/vimdiff): diffing into it
-- would join and corrupt that diff (issue #277).
if vim.api.nvim_win_get_buf(win) == existing_buffer and not vim.api.nvim_win_get_option(win, "diff") then
target_window = win
break
end
Expand All @@ -1300,11 +1331,31 @@ function M._setup_blocking_diff(params, resolution_callback)
if not target_window then
target_window = find_main_editor_window()
end

-- A Neovim diff is scoped to its tabpage: all &diff windows in a tab share
-- one diff set. If the tab that would host Claude's diff already contains a
-- foreign diff (vimdiff/diffview.nvim/fugitive), running `diffthis` there
-- makes Claude's panes join and corrupt it -- even when a usable non-diff
-- window exists in that tab. So check the destination tab (the candidate
-- window's tab, or the current tab if none was found) and isolate the diff
-- in a dedicated tab when it already hosts a diff (issue #277). Mirrors the
-- open_in_new_tab path, including the issue #262 hoisting so a failure
-- before registration can still close the new tab.
local dest_tab = (target_window and vim.api.nvim_win_is_valid(target_window))
and vim.api.nvim_win_get_tabpage(target_window)
or vim.api.nvim_get_current_tabpage()
if tabpage_has_diff_window(dest_tab) then
original_tab_handle = vim.api.nvim_get_current_tabpage()
original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle =
display_terminal_in_new_tab()
created_new_tab = true
target_window = nil
end
end
-- If created_new_tab is true, target_window stays nil and will be created in the new tab.
-- Otherwise, if no editor window is suitable (e.g. the Claude terminal is the only window --
-- issue #231), create one by splitting the current window instead of erroring out, mirroring
-- the fallback in lua/claudecode/tools/open_file.lua.
-- issue #231) and the tab has no foreign diff to corrupt, create one by splitting the current
-- window instead of erroring out, mirroring the fallback in lua/claudecode/tools/open_file.lua.
if not target_window and not created_new_tab then
create_split()
local scratch_buf = vim.api.nvim_create_buf(false, true) -- unlisted, scratch
Expand Down
85 changes: 13 additions & 72 deletions lua/claudecode/tools/close_all_diff_tabs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,79 +13,20 @@ local schema = {
---Closes all diff tabs/windows in the editor.
---@return table response MCP-compliant response with content array indicating number of closed tabs.
local function handler(params)
local closed_count = 0

-- Tear down tracked diffs first (resolving their pending coroutines); the
-- window/buffer scan below would otherwise leak that diff state (issue #248).
-- Tear down only the diffs this plugin created, resolving their pending
-- coroutines (issue #248). claudecode.diff.active_diffs is the authoritative
-- record of every diff claudecode opened, so it is the complete and safe scope
-- for this tool.
--
-- We deliberately do NOT scan for "any window with &diff set" or buffers named
-- like *.diff / diff:// / fugitive://: those belong to the user's own diff
-- tools (diffview.nvim, fugitive, native :diffsplit). Claude's CLI invokes
-- closeAllDiffTabs at the START OF EVERY TURN while an IDE is connected, so an
-- unscoped sweep silently destroyed unrelated diffs on each prompt (issue
-- #277). This also matches the official VS Code extension, which closes only
-- tabs it labelled "[Claude Code] ..." -- i.e. its own diffs.
local diff = require("claudecode.diff")
closed_count = closed_count + diff.close_all_diffs("closeAllDiffTabs tool")

-- Get all windows (catches any untracked diff windows, e.g. fugitive)
local windows = vim.api.nvim_list_wins()
local windows_to_close = {} -- Use set to avoid duplicates

for _, win in ipairs(windows) do
local buf = vim.api.nvim_win_get_buf(win)
local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
local diff_mode = vim.api.nvim_win_get_option(win, "diff")
local should_close = false

-- Check if this is a diff window
if diff_mode then
should_close = true
end

-- Also check for diff-related buffer names or types
local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name:match("%.diff$") or buf_name:match("diff://") then
should_close = true
end

-- Check for special diff buffer types
if buftype == "nofile" and buf_name:match("^fugitive://") then
should_close = true
end

-- Add to close set only once (prevents duplicates)
if should_close then
windows_to_close[win] = true
end
end

-- Close the identified diff windows
for win, _ in pairs(windows_to_close) do
if vim.api.nvim_win_is_valid(win) then
local success = pcall(vim.api.nvim_win_close, win, false)
if success then
closed_count = closed_count + 1
end
end
end

-- Also check for buffers that might be diff-related but not currently in windows
local buffers = vim.api.nvim_list_bufs()
for _, buf in ipairs(buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
local buftype = vim.api.nvim_buf_get_option(buf, "buftype")

-- Check for diff-related buffers
if
buf_name:match("%.diff$")
or buf_name:match("diff://")
or (buftype == "nofile" and buf_name:match("^fugitive://"))
then
-- Delete the buffer if it's not in any window
local buf_windows = vim.fn.win_findbuf(buf)
if #buf_windows == 0 then
local success = pcall(vim.api.nvim_buf_delete, buf, { force = true })
if success then
closed_count = closed_count + 1
end
end
end
end
end
local closed_count = diff.close_all_diffs("closeAllDiffTabs tool")

-- Return MCP-compliant format matching VS Code extension
return {
Expand Down
Loading
Loading