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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

## [Unreleased]

### Features

- `:ClaudeCodeCloseAllDiffs` command to close pending Claude diffs at once (e.g. proposals orphaned by resolving them via Claude remote control). Diffs you have already accepted but whose file has not been written yet are left intact so saved edits are never discarded. ([#248](https://github.com/coder/claudecode.nvim/issues/248))

### Bug Fixes

- Diffs opened via `openDiff` no longer linger forever when they are resolved outside this Neovim or their Claude session goes away. Pending diffs are now automatically closed when the client that opened them disconnects or the integration is stopped, and `closeAllDiffTabs` now also resolves/cleans the diff module's tracked state instead of only closing windows. ([#248](https://github.com/coder/claudecode.nvim/issues/248))
- Work around a Neovim core bug (< 0.12.2) that fragmented large bracketed pastes into the terminal across `vim.paste` phases, making Cmd+V appear to truncate content. Added a scoped, version-gated `vim.paste` shim controlled by `terminal.fix_streamed_paste` (`"auto"` by default; no-op on Neovim >= 0.12.2). ([#161](https://github.com/coder/claudecode.nvim/issues/161))

## [0.3.0] - 2025-09-15
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ Configure the plugin with the detected path:
- `:ClaudeCodeAdd <file-path> [start-line] [end-line]` - Add specific file to Claude context with optional line range
- `:ClaudeCodeDiffAccept` - Accept diff changes
- `:ClaudeCodeDiffDeny` - Reject diff changes
- `:ClaudeCodeCloseAllDiffs` - Close pending Claude diffs (leaves accepted/saved diffs intact)

## Working with Diffs

Expand All @@ -208,6 +209,8 @@ When Claude proposes changes, the plugin opens a native Neovim diff view:

You can edit Claude's suggestions before accepting them.

If a diff is resolved outside this Neovim (for example via Claude remote control on another device) the diff windows would otherwise stay open. They are now closed automatically when the Claude session that opened them disconnects. If you resolve diffs remotely while the session is still connected, run `:ClaudeCodeCloseAllDiffs` to clear the leftover pending proposals — it leaves any diff you have already accepted (`:w`) but whose file has not been written yet untouched, so your saved edits are never discarded.

## How It Works

This plugin creates a WebSocket server that Claude Code CLI connects to, implementing the same protocol as the official VS Code extension. When you launch Claude, it automatically detects Neovim and gains full access to your editor.
Expand Down
82 changes: 82 additions & 0 deletions fixtures/remote-diff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# `remote-diff` fixture — repro for issue #248

Reproduces the behaviour behind
[#248 "Close diff handled by remote control"](https://github.com/coder/claudecode.nvim/issues/248):
diffs Claude opens in Neovim (via the `openDiff` MCP tool) **stay open forever**
when they are accepted/rejected somewhere other than this Neovim instance
(e.g. Claude "remote control" on a phone), or when the Claude session that
opened them goes away without closing them.

This fixture is like the generic [`repro`](../repro) fixture but:

- keeps logging at `warn` (so the diff UI is clean for screenshots / automation —
the `repro` fixture's `debug` level spams the message area and triggers
hit-enter prompts), and
- adds a `:DiffState` / `:DiffStateFile` inspector that prints how many windows
are open and how many diffs the diff module still considers **active/pending**.

## Files

- `init.lua` — minimal claudecode.nvim config + `:DiffState` inspector.
- `example/{a.txt,b.txt,c.lua}` — sample files to diff against.

## Quick start

```sh
# Terminal 1 — the editor under test:
source fixtures/nvim-aliases.sh
vv remote-diff
# (equivalently: NVIM_APPNAME=remote-diff XDG_CONFIG_HOME=fixtures nvim a.txt)
# The server auto-starts; check the lock file exists:
# ls ~/.claude/ide/*.lock

# Terminal 2 — play the role of Claude over the MCP socket:
scripts/repro_issue_248.sh # open 3 diffs, then DISCONNECT (no close_tab)
```

Now back in Neovim run `:DiffState`. With the #248 fix you will see:

```
windows=1 active_diffs=0
```

The client went away, and `on_disconnect` automatically closed the diffs it had
opened. **Before the fix** the diff windows lingered (`windows=6 active_diffs=3`,
all `[pending]`) because teardown depended entirely on a `close_tab` the departed
client never sent — that was the bug.

`scripts/repro_issue_248.sh --cleanup` instead sends `closeAllDiffTabs`, which now
drains the diff registry (resolving pending diffs), so `:DiffState` likewise shows
`active_diffs=0` — before the fix it closed the windows but left `active_diffs > 0`.

## Verifying with the _real_ Claude CLI

The synthetic script is convenient, but you can confirm the fix with the real CLI too:

```sh
# Point a real Claude at this Neovim's MCP server (use the port from the lock file):
PORT=$(basename "$(ls ~/.claude/ide/*.lock | head -1)" .lock)
cd "$(jq -r .workspaceFolders[0] ~/.claude/ide/$PORT.lock)"
ENABLE_IDE_INTEGRATION=true CLAUDE_CODE_SSE_PORT=$PORT claude --ide
```

In Claude, switch **off** auto-accept (Shift+Tab until the mode line is blank —
in auto/accept-edits mode Claude edits files directly and never uses the IDE
diff), then ask it to edit a file. The diff opens in Neovim (`:DiffState` shows
`active_diffs=1`).

- Accept it (in Neovim **or** in Claude's prompt) → Claude sends `close_tab` →
the diff closes. This is the normal local flow.
- Instead, **kill the Claude process** before it sends `close_tab` (mimicking a
phone/remote-control resolution). With the #248 fix the pending diff is now
auto-closed by `on_disconnect` — `:DiffState` shows `windows=1 active_diffs=0`
(before the fix the window leaked and stayed open). A diff you had already
accepted with `:w` is instead left open, so its not-yet-written edits survive.

## Inspector commands (added by this fixture)

- `:DiffState` — notify window count + active diff tab names/status.
- `:DiffStateFile [path]` — write the same info to a file (for automation;
defaults to `stdpath('cache')/diff_state.txt`).
- `<leader>as` — run `:DiffState`.
- `<leader>aa` / `<leader>ad` — accept / deny the focused diff.
4 changes: 4 additions & 0 deletions fixtures/remote-diff/example/a.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This is file A.

Keep this window focused.
This simulates "you are looking at nvim".
2 changes: 2 additions & 0 deletions fixtures/remote-diff/example/b.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
line1
line2
5 changes: 5 additions & 0 deletions fixtures/remote-diff/example/c.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
local M = {}
function M.hello()
return "hello"
end
return M
98 changes: 98 additions & 0 deletions fixtures/remote-diff/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
-- Repro fixture for issue #248: "Close diff handled by remote control".
--
-- Scenario this fixture is built to demonstrate:
-- 1. Claude opens one or more diffs in Neovim via the `openDiff` MCP tool.
-- 2. The user resolves those diffs from *somewhere other than this Neovim*
-- (e.g. Claude "remote control" on a phone), so the diff is never
-- accepted/rejected inside Neovim and no `close_tab` arrives.
-- 3. The diff windows stay open in Neovim forever.
--
-- Unlike the generic `repro` fixture this one keeps logging quiet (so the diff
-- UI is clean for screenshots / automation) and exposes a `:DiffState` command
-- that prints how many windows and how many *active* claudecode diffs exist.
--
-- Usage (from repo root):
-- source fixtures/nvim-aliases.sh
-- vv remote-diff # or: NVIM_APPNAME=remote-diff XDG_CONFIG_HOME=fixtures nvim a.txt
--
-- Then drive the MCP side with scripts/repro_issue_248.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))

claudecode.setup({
auto_start = false,
-- Keep logging quiet so the diff UI is clean for screenshots / automation.
-- (The generic `repro` fixture uses "debug", which spams the message area and
-- triggers hit-enter prompts that interfere with TUI automation.)
log_level = "warn",
terminal = {
provider = "native",
auto_close = false,
},
diff_opts = {
layout = "vertical",
open_in_new_tab = false,
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()

-- Inspection command: how many windows, and how many *active* diffs does the
-- diff module still think are open? This is the heart of the repro: after a
-- remote resolution the windows linger and active_diffs never drains.
local function diff_state()
local wins = #vim.api.nvim_list_wins()
local active = require("claudecode.diff")._get_active_diffs()
local names = {}
for tab_name, data in pairs(active) do
names[#names + 1] = (" [%s] %s"):format(data.status or "?", tab_name)
end
table.sort(names)
local lines = {
("windows=%d active_diffs=%d"):format(wins, #names),
}
vim.list_extend(lines, names)
return lines, wins, #names
end

vim.api.nvim_create_user_command("DiffState", function()
local lines = diff_state()
vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO)
end, { desc = "Show window count + active claudecode diffs" })

-- Scriptable variant: writes the state to a file so external automation can
-- assert on it without scraping the message area.
vim.api.nvim_create_user_command("DiffStateFile", function(opts)
-- stdpath("cache") (not "run") so this works on the plugin's Neovim 0.8 floor.
local path = opts.args ~= "" and opts.args or (vim.fn.stdpath("cache") .. "/diff_state.txt")
local lines = diff_state()
vim.fn.writefile(lines, path)
end, { nargs = "?", desc = "Write window/diff state to a file" })
Comment thread
ThomasK33 marked this conversation as resolved.

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" })
76 changes: 75 additions & 1 deletion lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@ function M._setup_blocking_diff(params, resolution_callback)
resolution_callback = resolution_callback,
result_content = nil,
is_new_file = is_new_file,
client_id = params.client_id,
})
end) -- End of pcall

Expand Down Expand Up @@ -1293,8 +1294,9 @@ end
---@param new_file_path string Path to the new file (used for naming)
---@param new_file_contents string Contents of the new file
---@param tab_name string Name for the diff tab/view
---@param client_id string|nil Id of the MCP client opening the diff (so it can be cleaned up if that client disconnects)
---@return table response MCP-compliant response with content array
function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name)
function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name, client_id)
-- Check for existing diff with same tab_name
if active_diffs[tab_name] then
local existing_diff = active_diffs[tab_name]
Expand Down Expand Up @@ -1324,6 +1326,7 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t
new_file_path = new_file_path,
new_file_contents = new_file_contents,
tab_name = tab_name,
client_id = client_id,
}, function(result)
-- Resume the coroutine with the result
local resume_success, resume_result = coroutine.resume(co, result)
Expand Down Expand Up @@ -1428,6 +1431,77 @@ function M.close_diff_by_tab_name(tab_name)
return false
end

---Close every active diff matching an optional filter.
---Reuses close_diff_by_tab_name, which resolves still-pending diffs as rejected
---(resuming their coroutine) before tearing down the UI.
---@param filter_fn (fun(diff_data: table): boolean)|nil Only close diffs for which this returns true (nil = all)
---@param reason string Human-readable reason (for logging)
---@return number count Number of diffs closed
local function close_active_diffs(filter_fn, reason)
local count = 0
-- Snapshot the tab names first: close_diff_by_tab_name nils out entries as it
-- goes, and mutating a table while iterating it with pairs() is undefined.
local tab_names = {}
for tab_name, diff_data in pairs(active_diffs) do
if not filter_fn or filter_fn(diff_data) then
tab_names[#tab_names + 1] = tab_name
end
end
for _, tab_name in ipairs(tab_names) do
if M.close_diff_by_tab_name(tab_name) then
count = count + 1
end
end
if count > 0 then
logger.debug("diff", "Closed", count, "active diff(s):", reason)
end
return count
end

---Close all active diffs, resolving any still pending as rejected.
---Closes diffs in ANY state (including saved), so its only caller is the
---closeAllDiffTabs tool, where Claude is the connected client and has written
---accepted files. Automatic cleanup and the :ClaudeCodeCloseAllDiffs command
---deliberately use close_pending_diffs / close_diffs_for_client instead, to
---leave already-saved diffs alone -- see close_pending_diffs for why.
---@param reason string Human-readable reason (for logging)
---@return number count Number of diffs closed
function M.close_all_diffs(reason)
return close_active_diffs(nil, reason or "close all diffs")
end

-- Automatic teardown (client disconnect, server stop) must only touch *pending*
-- diffs. A diff with status == "saved" has been :w'd by the user -- its edits
-- live only in the proposed buffer until Claude writes them to disk -- so closing
-- it would run close_diff_by_tab_name's saved-branch, wiping the proposed buffer
-- and reloading the file from unchanged disk, silently destroying the edits if
-- Claude died before writing. Pending diffs carry no such accepted content.

---Close every still-pending diff (e.g. on server stop, which bypasses
---on_disconnect). Leaves saved/rejected diffs for client-driven finalization.
---@param reason string Human-readable reason (for logging)
---@return number count Number of diffs closed
function M.close_pending_diffs(reason)
return close_active_diffs(function(diff_data)
return diff_data.status == "pending"
end, reason or "close pending diffs")
end

---Close the still-pending diffs opened by a specific MCP client, used when that
---client disconnects so its orphaned diff windows don't linger (e.g. the Claude
---session that opened them exited or moved to remote control).
---@param client_id string The id of the client whose diffs should be closed
---@param reason string Human-readable reason (for logging)
---@return number count Number of diffs closed
function M.close_diffs_for_client(client_id, reason)
if not client_id then
return 0
end
return close_active_diffs(function(diff_data)
return diff_data.client_id == client_id and diff_data.status == "pending"
end, reason or ("client " .. tostring(client_id)))
end
Comment thread
claude[bot] marked this conversation as resolved.

---Test helper function (only for testing)
---@return table active_diffs The active diffs table
function M._get_active_diffs()
Expand Down
16 changes: 16 additions & 0 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,22 @@ function M._create_commands()
desc = "Deny/reject the current diff changes",
})

vim.api.nvim_create_user_command("ClaudeCodeCloseAllDiffs", function()
-- Pending only: a status="saved" diff holds the user's :w'd edits in its
-- proposed buffer until Claude writes the file, and closing it would discard
-- them (same data-loss branch the auto-cleanup avoids). So this clears
-- orphaned proposals but leaves accepted diffs for the user to handle.
local diff = require("claudecode.diff")
local count = diff.close_pending_diffs("user command")
if count > 0 then
vim.notify(("Closed %d pending Claude diff(s)"):format(count), vim.log.levels.INFO)
else
vim.notify("No pending Claude diffs to close", vim.log.levels.WARN)
end
end, {
desc = "Close pending Claude Code diffs (leaves accepted/saved diffs intact)",
})

vim.api.nvim_create_user_command("ClaudeCodeSelectModel", function(opts)
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
M.open_with_model(cmd_args)
Expand Down
19 changes: 19 additions & 0 deletions lua/claudecode/server/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ function M.start(config, auth_token)
", reason:",
(reason or "N/A") .. ")"
)

-- Close diffs this client opened but never resolved (issue #248) -- only if
-- the diff module is in use. Scheduled: diff cleanup touches window APIs.
local diff = package.loaded["claudecode.diff"]
if diff then
local client_id = client.id
vim.schedule(function()
diff.close_diffs_for_client(client_id, "client disconnected")
end)
end
end,
on_error = function(error_msg)
logger.error("server", "WebSocket server error:", error_msg)
Expand Down Expand Up @@ -109,6 +119,15 @@ function M.stop()
M.state.ping_timer = nil
end

-- Reject any still-pending diffs before teardown -- stop_server bypasses
-- on_disconnect (#248). Pending only, so saved-but-unflushed edits survive;
-- only if the diff module is in use, and while clients can still receive
-- DIFF_REJECTED.
local diff = package.loaded["claudecode.diff"]
if diff then
diff.close_pending_diffs("server stopping")
end

tcp_server.stop_server(M.state.server)

-- CRITICAL: Clear global deferred responses to prevent memory leaks and hanging
Expand Down
Loading
Loading