From 3205b4f6cf4eaf8ac172e0419612b64c99cb0f5d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 17:54:38 +0200 Subject: [PATCH 1/4] fix(diff): open diffs when the Claude terminal is the only window When the Claude Code terminal was the only window (no other splits), openDiff failed with "No suitable editor window found": find_main_editor_window() correctly returns nil (a terminal is not an editor window) and _setup_blocking_diff errored instead of creating a window to host the diff. Create a split with a scratch buffer to host the diff -- mirroring the fallback already in tools/open_file.lua -- and track it as fallback_window so _cleanup_diff_state closes it, avoiding a leaked window per diff. Fixes #231. Change-Id: I27fb3fc9292559d8770641fdd7f1c7f4593b7777 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + lua/claudecode/diff.lua | 28 +++-- scripts/repro_issue_231.lua | 105 ++++++++++++++++++ tests/unit/diff_terminal_only_window_spec.lua | 100 +++++++++++++++++ 4 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 scripts/repro_issue_231.lua create mode 100644 tests/unit/diff_terminal_only_window_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 76786cc9..71acef3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 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)) +- Show diffs when the Claude Code terminal is the only window (no other splits). Previously `openDiff` failed with "No suitable editor window found"; now a split is created to host the diff, matching the behavior of the `openFile` tool. ([#231](https://github.com/coder/claudecode.nvim/issues/231)) - 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 diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index e4f002c0..bdcdefc3 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1063,6 +1063,12 @@ function M._cleanup_diff_state(tab_name, reason) pcall(vim.api.nvim_win_close, diff_data.new_window, true) end + -- Close the fallback window we created when no editor window existed (issue #231); it's reused + -- as the original pane, so target_window_created_by_plugin below doesn't cover it. + if diff_data.fallback_window and vim.api.nvim_win_is_valid(diff_data.fallback_window) then + pcall(vim.api.nvim_win_close, diff_data.fallback_window, false) + end + -- If we created an extra window/split for the diff, close it. Otherwise just disable diff mode. if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then if diff_data.target_window_created_by_plugin then @@ -1197,14 +1203,21 @@ function M._setup_blocking_diff(params, resolution_callback) target_window = find_main_editor_window() end end - -- If created_new_tab is true, target_window stays nil and will be created in the new tab - -- If we still can't find a suitable window AND we're not in a new tab, error out + -- 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. + local fallback_window = nil if not target_window and not created_new_tab then - error({ - code = -32000, - message = "No suitable editor window found", - data = "Could not find a main editor window to display the diff", - }) + create_split() + local scratch_buf = vim.api.nvim_create_buf(false, true) -- unlisted, scratch + if scratch_buf ~= 0 then + vim.api.nvim_win_set_buf(vim.api.nvim_get_current_win(), scratch_buf) + end + target_window = vim.api.nvim_get_current_win() + -- Track it so _cleanup_diff_state closes it; the reused scratch buffer means it won't be + -- flagged target_window_created_by_plugin. + fallback_window = target_window end local new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch @@ -1253,6 +1266,7 @@ function M._setup_blocking_diff(params, resolution_callback) new_window = diff_info.new_window, target_window = diff_info.target_window, target_window_created_by_plugin = diff_info.target_window_created_by_plugin, + fallback_window = fallback_window, original_buffer = diff_info.original_buffer, original_buffer_created_by_plugin = diff_info.original_buffer_created_by_plugin, original_cursor_pos = original_cursor_pos, diff --git a/scripts/repro_issue_231.lua b/scripts/repro_issue_231.lua new file mode 100644 index 00000000..60085b99 --- /dev/null +++ b/scripts/repro_issue_231.lua @@ -0,0 +1,105 @@ +-- Reproduction / verification for issue #231: +-- "When the Claude Code terminal is the only window (no other splits), an +-- error is generated when Claude tries to suggest changes." +-- https://github.com/coder/claudecode.nvim/issues/231 +-- +-- The bug: with a single `buftype=terminal` window, diff.lua's +-- find_main_editor_window() returns nil (it correctly excludes terminals). The +-- fix makes M._setup_blocking_diff create a split to host the diff instead of +-- erroring with "No suitable editor window found". +-- +-- This script drives the REAL diff.lua against a terminal-only layout, with no +-- WebSocket/Claude CLI needed. It exercises the exact code path the openDiff MCP +-- tool uses (M._setup_blocking_diff), so it both reproduces the original bug (on +-- unfixed code) and verifies the fix. +-- +-- Run from the repo root: +-- nvim --headless -u NONE -l scripts/repro_issue_231.lua +-- +-- Exit code: 0 if the diff opens (fixed), 1 if the #231 error is reproduced. +-- The detailed verdict is printed to stdout 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") + +---Make a `buftype=terminal` window the ONLY window (the issue #231 layout). +local function make_terminal_only_window() + vim.cmd("silent! only") + vim.cmd("enew!") + -- jobstart({term=true}) (Neovim 0.11+) / fallback to termopen on older versions. + if vim.fn.has("nvim-0.11") == 1 then + vim.fn.jobstart({ "cat" }, { term = true }) + else + vim.fn.termopen({ "cat" }) + end + vim.cmd("silent! only") + return #vim.api.nvim_list_wins(), vim.api.nvim_buf_get_option(0, "buftype") +end + +---Run M._setup_blocking_diff for a brand-new file and capture the outcome. +---@return boolean ok, string detail +local function try_open_diff() + local new_file = repo_root .. "/__issue_231_repro__.md" + os.remove(new_file) -- ensure is_new_file = true (matches: Claude proposing a new file) + local ok, err = pcall(function() + diff._setup_blocking_diff({ + old_file_path = new_file, + new_file_path = new_file, + new_file_contents = "# Proposed by Claude\n\nhello\n", + tab_name = "✻ [Claude Code] __issue_231_repro__.md (445ca6) ⧉", + }, function() end) + end) + -- Best-effort cleanup of any windows/diff state the setup created. + pcall(function() + diff._cleanup_all_active_diffs("repro cleanup") + end) + if ok then + return true, "setup SUCCEEDED (a window was found or created)" + end + local msg = type(err) == "table" and (tostring(err.message) .. " - " .. tostring(err.data)) or tostring(err) + return false, msg +end + +out("== issue #231 reproduction ==") +out(("Neovim: %s"):format(vim.version and tostring(vim.version()) or vim.fn.execute("version"):match("NVIM[^\n]*"))) + +-- Scenario A: default diff_opts (open_in_new_tab = false) -- the path that regressed in #231. +-- This exercises the actual fix (find_main_editor_window -> nil -> create a split fallback). +diff.setup({ diff_opts = { layout = "vertical", open_in_new_tab = false } }) +local wins, bt = make_terminal_only_window() +out(("\n[A] default config | precondition: windows=%d, only buftype=%q"):format(wins, bt)) +local a_ok, a_detail = try_open_diff() +out(("[A] result: %s -> %s"):format(a_ok and "OK" or "ERROR", a_detail)) + +-- Scenario B: open_in_new_tab = true -- a pre-existing WORKAROUND. NOTE: this does NOT exercise +-- the #231 fix path; the new-tab path creates its own window and never calls +-- find_main_editor_window, so it succeeds even on unfixed code. Included only to confirm the +-- documented workaround still works; scenario A is the real regression signal. +diff.setup({ diff_opts = { layout = "vertical", open_in_new_tab = true } }) +wins, bt = make_terminal_only_window() +out(("\n[B] open_in_new_tab=true | precondition: windows=%d, only buftype=%q"):format(wins, bt)) +local b_ok, b_detail = try_open_diff() +out(("[B] result: %s -> %s"):format(b_ok and "OK" or "ERROR", b_detail)) + +out("\n== verdict ==") +local bug_reproduced = (not a_ok) and a_detail:match("No suitable editor window found") ~= nil +if bug_reproduced then + out("BUG REPRODUCED: default config errors with 'No suitable editor window found' (issue #231).") +else + out("FIXED: default config opens the diff in a terminal-only layout (scenario A).") +end +if b_ok then + out("WORKAROUND OK: diff_opts.open_in_new_tab=true opens the diff in a new tab (does not exercise the fix).") +else + out("NOTE: open_in_new_tab=true did NOT open the diff in this environment: " .. b_detail) +end + +io.stdout:flush() +vim.cmd("cquit " .. (bug_reproduced and 1 or 0)) diff --git a/tests/unit/diff_terminal_only_window_spec.lua b/tests/unit/diff_terminal_only_window_spec.lua new file mode 100644 index 00000000..bb611de4 --- /dev/null +++ b/tests/unit/diff_terminal_only_window_spec.lua @@ -0,0 +1,100 @@ +-- Regression test for issue #231: +-- "When the Claude Code terminal is the only window (no other splits), an +-- error is generated when Claude tries to suggest changes." +-- https://github.com/coder/claudecode.nvim/issues/231 +-- +-- Before the fix, M._setup_blocking_diff errored with "No suitable editor window +-- found" whenever find_main_editor_window() returned nil (e.g. the only window +-- is a terminal). The fix creates a split + fresh buffer to host the diff, tracks +-- that window as `fallback_window`, and closes it on cleanup so it is not leaked. +require("tests.busted_setup") + +-- Build a consistent mock window model where the ONLY window (1000) is a terminal, +-- so find_main_editor_window() returns nil -- the issue #231 layout. (_next_winid is +-- advanced past 1000 so create_split() allocates fresh window ids without colliding.) +local function reset_to_terminal_only() + assert(vim and vim._mock and vim._mock.reset, "expected vim mock with _mock.reset()") + + vim._mock.reset() + vim._tabs = { [1] = true } + vim._current_tabpage = 1 + vim._current_window = 1000 + vim._next_winid = 1001 + + vim._mock.add_buffer(1, "term://fake/claude", "", { buftype = "terminal", modified = false }) + vim._mock.add_window(1000, 1, { 1, 0 }) + vim._win_tab[1000] = 1 + vim._tab_windows[1] = { 1000 } +end + +describe("Diff with the Claude terminal as the only window (issue #231)", function() + local diff + + before_each(function() + reset_to_terminal_only() + + package.loaded["claudecode.logger"] = { + debug = function() end, + error = function() end, + info = function() end, + warn = function() end, + } + + package.loaded["claudecode.diff"] = nil + diff = require("claudecode.diff") + + diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = false, -- default: the path that used to error + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", + }, + terminal = {}, + }) + end) + + after_each(function() + if diff and diff._cleanup_all_active_diffs then + diff._cleanup_all_active_diffs("test teardown") + end + package.loaded["claudecode.diff"] = nil + package.loaded["claudecode.logger"] = nil + end) + + it("has no suitable editor window in this layout (root-cause precondition)", function() + expect(diff._find_main_editor_window()).to_be(nil) + end) + + it("creates a window for the diff instead of erroring, then cleans it up (new file)", function() + local tab_name = "✻ [Claude Code] INSTALL.md (445ca6) ⧉" + local params = { + old_file_path = "/nonexistent/INSTALL.md", -- new file (matches the issue) + new_file_path = "/nonexistent/INSTALL.md", + new_file_contents = "# Install\n\nproposed by Claude\n", + tab_name = tab_name, + } + + -- The regression: this used to raise "No suitable editor window found". + local setup_ok, setup_err = pcall(function() + diff._setup_blocking_diff(params, function() end) + end) + assert.is_true(setup_ok, "diff setup should not error in a terminal-only layout: " .. tostring(setup_err)) + + local state = diff._get_active_diffs()[tab_name] + assert.is_table(state) + assert.is_true(vim.api.nvim_buf_is_valid(state.new_buffer)) + + -- The plugin had to create a window to host the diff (none existed). It must be recorded as + -- `fallback_window` (distinct from the terminal) so cleanup can close it. + assert.is_number(state.fallback_window) + assert.are_not.equal(1000, state.fallback_window) + assert.is_true(vim.api.nvim_win_is_valid(state.fallback_window)) + + -- Cleanup must close the plugin-created fallback window while leaving the terminal (1000). + -- Regression guard for the window leak (the fallback host window was left open on every diff). + diff._cleanup_diff_state(tab_name, "test cleanup") + assert.is_false(vim.api.nvim_win_is_valid(state.fallback_window)) + assert.is_true(vim.api.nvim_win_is_valid(1000)) + end) +end) From b92f195c78a0d61f0e84ae09045f506d226213af Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 18:10:13 +0200 Subject: [PATCH 2/4] fix(diff): wipe the terminal-only fallback scratch buffer Address Codex review on #260: the scratch buffer created for the terminal-only diff fallback was never wiped, leaving a hidden unlisted buffer behind on each diff. Set bufhidden=wipe (matching the existing idiom in this file) so it's cleaned up when the diff reuses it or :edit replaces it. The regression test now also asserts the fallback window's buffer is wiped on cleanup. Change-Id: Ieefdcc7d24ce51605239a9561602c5a41431960b Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/diff.lua | 3 +++ tests/unit/diff_terminal_only_window_spec.lua | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index bdcdefc3..3d886b86 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1212,6 +1212,9 @@ function M._setup_blocking_diff(params, resolution_callback) create_split() local scratch_buf = vim.api.nvim_create_buf(false, true) -- unlisted, scratch if scratch_buf ~= 0 then + -- wipe it once it leaves the window so it isn't leaked when the diff reuses it (new file) + -- or :edit replaces it with the real file (existing file) + vim.api.nvim_buf_set_option(scratch_buf, "bufhidden", "wipe") vim.api.nvim_win_set_buf(vim.api.nvim_get_current_win(), scratch_buf) end target_window = vim.api.nvim_get_current_win() diff --git a/tests/unit/diff_terminal_only_window_spec.lua b/tests/unit/diff_terminal_only_window_spec.lua index bb611de4..18848982 100644 --- a/tests/unit/diff_terminal_only_window_spec.lua +++ b/tests/unit/diff_terminal_only_window_spec.lua @@ -90,11 +90,14 @@ describe("Diff with the Claude terminal as the only window (issue #231)", functi assert.is_number(state.fallback_window) assert.are_not.equal(1000, state.fallback_window) assert.is_true(vim.api.nvim_win_is_valid(state.fallback_window)) + local fallback_buf = vim.api.nvim_win_get_buf(state.fallback_window) - -- Cleanup must close the plugin-created fallback window while leaving the terminal (1000). - -- Regression guard for the window leak (the fallback host window was left open on every diff). + -- Cleanup must close the plugin-created fallback window (leaving the terminal, 1000) and wipe + -- its throwaway scratch buffer. Regression guard for the window + buffer leak (the host window + -- was left open and the scratch buffer left behind on every terminal-only diff). diff._cleanup_diff_state(tab_name, "test cleanup") assert.is_false(vim.api.nvim_win_is_valid(state.fallback_window)) + assert.is_false(vim.api.nvim_buf_is_valid(fallback_buf)) assert.is_true(vim.api.nvim_win_is_valid(1000)) end) end) From 604c4617edff99632dbd5d10eb8cfee45de12a02 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 18:48:31 +0200 Subject: [PATCH 3/4] fix(diff): close terminal-only fallback window if setup errors early Address Claude review on #260: the fallback split is created before the diff state is registered, so an error in between (e.g. a BufRead autocmd throwing on :edit) would strand it -- the post-pcall cleanup is gated on the registered state. Hoist fallback_window above the pcall and close it in the error handler when no state was registered. Covered by a new regression test. Change-Id: I2da6673eb7d3e6cbe5d388ea6fed0873f169f9b0 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/diff.lua | 9 ++++++- tests/unit/diff_terminal_only_window_spec.lua | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 3d886b86..535b8464 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1138,6 +1138,10 @@ function M._setup_blocking_diff(params, resolution_callback) local tab_name = params.tab_name logger.debug("diff", "Setting up diff for:", params.old_file_path) + -- Hoisted so the error handler can close it if setup fails before the diff state is registered, + -- which would otherwise strand the split we create for the terminal-only fallback (issue #231). + local fallback_window = nil + -- Wrap the setup in error handling to ensure cleanup on failure local setup_success, setup_error = pcall(function() local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 @@ -1207,7 +1211,6 @@ function M._setup_blocking_diff(params, resolution_callback) -- 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. - local fallback_window = nil if not target_window and not created_new_tab then create_split() local scratch_buf = vim.api.nvim_create_buf(false, true) -- unlisted, scratch @@ -1305,6 +1308,10 @@ function M._setup_blocking_diff(params, resolution_callback) -- Clean up any partial state that might have been created if active_diffs[tab_name] then M._cleanup_diff_state(tab_name, "setup failed") + elseif fallback_window and vim.api.nvim_win_is_valid(fallback_window) then + -- Errored before the diff state was registered: close the fallback split we created so it + -- (and its bufhidden=wipe scratch) isn't stranded. + pcall(vim.api.nvim_win_close, fallback_window, true) end -- Re-throw the error for MCP compliance diff --git a/tests/unit/diff_terminal_only_window_spec.lua b/tests/unit/diff_terminal_only_window_spec.lua index 18848982..e7ca3f2d 100644 --- a/tests/unit/diff_terminal_only_window_spec.lua +++ b/tests/unit/diff_terminal_only_window_spec.lua @@ -100,4 +100,28 @@ describe("Diff with the Claude terminal as the only window (issue #231)", functi assert.is_false(vim.api.nvim_buf_is_valid(fallback_buf)) assert.is_true(vim.api.nvim_win_is_valid(1000)) end) + + -- If setup errors after the fallback window is created but before the diff state is registered, + -- the error handler must still close that window (it isn't covered by the state-based cleanup). + it("closes the fallback window when setup errors before the diff state is registered", function() + -- Force a failure after the fallback split is created (winid 1001) but before registration. + diff._create_diff_view_from_window = function() + error({ code = -32000, message = "boom" }) + end + + local tab_name = "✻ [Claude Code] err.md ⧉" + local ok = pcall(function() + diff._setup_blocking_diff({ + old_file_path = "/nonexistent/err.md", + new_file_path = "/nonexistent/err.md", + new_file_contents = "x\n", + tab_name = tab_name, + }, function() end) + end) + + assert.is_false(ok) -- setup is expected to fail + assert.is_nil(diff._get_active_diffs()[tab_name]) -- no diff state registered + assert.is_false(vim.api.nvim_win_is_valid(1001)) -- the stranded fallback split was closed + assert.is_true(vim.api.nvim_win_is_valid(1000)) -- the terminal window survives + end) end) From a9df0b076fedaa49418567fae277e62a94342847 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 19:30:42 +0200 Subject: [PATCH 4/4] fix(diff): delete the proposed buffer too if setup errors early Address Claude review on #260: the error handler closed the fallback window but left new_buffer (the proposed-content scratch) loaded if setup throws before the diff state is registered. Hoist new_buffer above the pcall and delete it on the error path, restructured to cover both the terminal-only fallback and the pre-existing non-fallback throw path. The error-path regression test now asserts no buffer is leaked. Change-Id: Ide9dede22bb5bd1284dc5585850ccb8981517554 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/diff.lua | 22 +++++++++++++------ tests/unit/diff_terminal_only_window_spec.lua | 8 ++++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 535b8464..028ed146 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1138,9 +1138,11 @@ function M._setup_blocking_diff(params, resolution_callback) local tab_name = params.tab_name logger.debug("diff", "Setting up diff for:", params.old_file_path) - -- Hoisted so the error handler can close it if setup fails before the diff state is registered, - -- which would otherwise strand the split we create for the terminal-only fallback (issue #231). + -- Hoisted so the error handler can clean them up if setup fails before the diff state is + -- registered: otherwise the terminal-only fallback split and the proposed buffer are stranded + -- (the state-based cleanup is gated on a registered diff). Issue #231. local fallback_window = nil + local new_buffer = nil -- Wrap the setup in error handling to ensure cleanup on failure local setup_success, setup_error = pcall(function() @@ -1226,7 +1228,7 @@ function M._setup_blocking_diff(params, resolution_callback) fallback_window = target_window end - local new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch + new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch (hoisted above the pcall) if new_buffer == 0 then error({ code = -32000, @@ -1308,10 +1310,16 @@ function M._setup_blocking_diff(params, resolution_callback) -- Clean up any partial state that might have been created if active_diffs[tab_name] then M._cleanup_diff_state(tab_name, "setup failed") - elseif fallback_window and vim.api.nvim_win_is_valid(fallback_window) then - -- Errored before the diff state was registered: close the fallback split we created so it - -- (and its bufhidden=wipe scratch) isn't stranded. - pcall(vim.api.nvim_win_close, fallback_window, true) + else + -- Errored before the diff state was registered, so the state-based cleanup can't run. Close + -- the fallback split we may have created (its bufhidden=wipe scratch self-cleans) and delete + -- the proposed buffer; neither is owned by a registered diff. + if fallback_window and vim.api.nvim_win_is_valid(fallback_window) then + pcall(vim.api.nvim_win_close, fallback_window, true) + end + if new_buffer and vim.api.nvim_buf_is_valid(new_buffer) then + pcall(vim.api.nvim_buf_delete, new_buffer, { force = true }) + end end -- Re-throw the error for MCP compliance diff --git a/tests/unit/diff_terminal_only_window_spec.lua b/tests/unit/diff_terminal_only_window_spec.lua index e7ca3f2d..630e199b 100644 --- a/tests/unit/diff_terminal_only_window_spec.lua +++ b/tests/unit/diff_terminal_only_window_spec.lua @@ -101,14 +101,15 @@ describe("Diff with the Claude terminal as the only window (issue #231)", functi assert.is_true(vim.api.nvim_win_is_valid(1000)) end) - -- If setup errors after the fallback window is created but before the diff state is registered, - -- the error handler must still close that window (it isn't covered by the state-based cleanup). - it("closes the fallback window when setup errors before the diff state is registered", function() + -- If setup errors after the fallback window + proposed buffer are created but before the diff + -- state is registered, the error handler must still clean them up (not covered by state cleanup). + it("cleans up the fallback window and proposed buffer when setup errors before registration", function() -- Force a failure after the fallback split is created (winid 1001) but before registration. diff._create_diff_view_from_window = function() error({ code = -32000, message = "boom" }) end + local bufs_before = #vim.api.nvim_list_bufs() local tab_name = "✻ [Claude Code] err.md ⧉" local ok = pcall(function() diff._setup_blocking_diff({ @@ -123,5 +124,6 @@ describe("Diff with the Claude terminal as the only window (issue #231)", functi assert.is_nil(diff._get_active_diffs()[tab_name]) -- no diff state registered assert.is_false(vim.api.nvim_win_is_valid(1001)) -- the stranded fallback split was closed assert.is_true(vim.api.nvim_win_is_valid(1000)) -- the terminal window survives + assert.equals(bufs_before, #vim.api.nvim_list_bufs()) -- proposed buffer + scratch not leaked end) end)