Skip to content
Open
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
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,22 +288,27 @@ require("claudecode").setup({

The `diff_opts` configuration allows you to customize diff behavior:

- `layout` ("vertical"|"horizontal", default: `"vertical"`) - Whether the diff panes open in a vertical or horizontal split.
- `keep_terminal_focus` (boolean, default: `false`) - When enabled, keeps focus in the Claude Code terminal when a diff opens instead of moving focus to the diff buffer. This allows you to continue using terminal keybindings like `<CR>` for accepting/rejecting diffs without accidentally triggering other mappings.
- `open_in_new_tab` (boolean, default: `false`) - Open diffs in a new tab instead of the current tab.
- `hide_terminal_in_new_tab` (boolean, default: `false`) - When opening diffs in a new tab, do not show the Claude terminal split in that new tab. The terminal remains in the original tab, giving maximum screen estate for reviewing the diff.
- `on_new_file_reject` ("keep_empty"|"close_window", default: `"keep_empty"`) - Behavior when rejecting a diff for a new file (where the old file did not exist).
- Legacy aliases (still supported): `vertical_split` (maps to `layout`) and `open_in_current_tab` (inverse of `open_in_new_tab`).

**Example use case**: If you frequently use `<CR>` or arrow keys in the Claude Code terminal to accept/reject diffs, enable this option to prevent focus from moving to the diff buffer where `<CR>` might trigger unintended actions.

```lua
require("claudecode").setup({
diff_opts = {
keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens
open_in_new_tab = true, -- Open diff in a separate tab
layout = "vertical", -- "vertical" or "horizontal"
keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens
open_in_new_tab = true, -- Open diff in a separate tab
hide_terminal_in_new_tab = true, -- In the new tab, do not show Claude terminal
auto_close_on_accept = true,
show_diff_stats = true,
vertical_split = true,
open_in_current_tab = true,
on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"

-- Legacy aliases (still supported):
-- vertical_split = true,
-- open_in_current_tab = true,
},
})
```
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,15 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).

-- Diff Integration
diff_opts = {
auto_close_on_accept = true,
vertical_split = true,
open_in_current_tab = true,
layout = "vertical", -- "vertical" or "horizontal"
open_in_new_tab = false,
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
hide_terminal_in_new_tab = false,
-- on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"

-- Legacy aliases (still supported):
-- vertical_split = true,
-- open_in_current_tab = true,
},
},
keys = {
Expand Down
31 changes: 25 additions & 6 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ function M._create_diff_view_from_window(
existing_buffer
)
local original_buffer_created_by_plugin = false
local target_window_created_by_plugin = false

-- If no target window provided, create a new window in suitable location
if not target_window then
Expand Down Expand Up @@ -937,6 +938,7 @@ function M._create_diff_view_from_window(
vim.api.nvim_set_current_win(target_window)
create_split()
original_window = vim.api.nvim_get_current_win()
target_window_created_by_plugin = true
else
original_window = choice.original_win
end
Expand Down Expand Up @@ -968,6 +970,7 @@ function M._create_diff_view_from_window(
return {
new_window = new_win,
target_window = original_window,
target_window_created_by_plugin = target_window_created_by_plugin,
original_buffer = original_buffer,
original_buffer_created_by_plugin = original_buffer_created_by_plugin,
}
Expand Down Expand Up @@ -1031,11 +1034,19 @@ function M._cleanup_diff_state(tab_name, reason)
pcall(vim.api.nvim_win_close, diff_data.new_window, true)
end

-- Turn off diff mode in target window if it still exists
-- 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
vim.api.nvim_win_call(diff_data.target_window, function()
vim.cmd("diffoff")
end)
if diff_data.target_window_created_by_plugin then
-- Try a non-forced close first to avoid dropping any user edits in that window.
pcall(vim.api.nvim_win_close, diff_data.target_window, false)
end

-- If the target window is still around, ensure diff mode is off.
if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then
vim.api.nvim_win_call(diff_data.target_window, function()
vim.cmd("diffoff")
end)
end
end

-- After closing the diff in the same tab, restore terminal width if visible
Expand Down Expand Up @@ -1206,6 +1217,7 @@ function M._setup_blocking_diff(params, resolution_callback)
new_buffer = new_buffer,
new_window = diff_info.new_window,
target_window = diff_info.target_window,
target_window_created_by_plugin = diff_info.target_window_created_by_plugin,
original_buffer = diff_info.original_buffer,
original_buffer_created_by_plugin = diff_info.original_buffer_created_by_plugin,
original_cursor_pos = original_cursor_pos,
Expand Down Expand Up @@ -1260,8 +1272,13 @@ end
function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name)
-- Check for existing diff with same tab_name
if active_diffs[tab_name] then
-- Resolve the existing diff as rejected before replacing
M._resolve_diff_as_rejected(tab_name)
local existing_diff = active_diffs[tab_name]
-- Resolve the existing diff as rejected before replacing, but only if it was still pending.
if existing_diff.status == "pending" then
M._resolve_diff_as_rejected(tab_name)
end
-- Always clean up any leftover UI/state so we don't leak windows when reusing tab_names.
M._cleanup_diff_state(tab_name, "replaced by new diff")
end

-- Set up blocking diff operation
Expand Down Expand Up @@ -1438,7 +1455,9 @@ return M
---@class DiffLayoutInfo
---@field new_window NvimWin
---@field target_window NvimWin
---@field target_window_created_by_plugin boolean
---@field original_buffer NvimBuf
---@field original_buffer_created_by_plugin boolean

---@class DiffWindowChoice
---@field decision DiffWindowDecision
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/diff_split_window_cleanup_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
require("tests.busted_setup")

local function reset_vim_state_for_splits()
assert(vim and vim._mock and vim._mock.reset, "Expected vim mock with _mock.reset()")

vim._mock.reset()

-- Recreate a minimal tab/window state suitable for split operations.
vim._tabs = { [1] = true }
vim._current_tabpage = 1
vim._current_window = 1000
vim._next_winid = 1001

vim._mock.add_buffer(1, "/home/user/project/test.lua", "local test = {}\nreturn test", { modified = false })
vim._mock.add_window(1000, 1, { 1, 0 })
vim._win_tab[1000] = 1
vim._tab_windows[1] = { 1000 }
end

describe("Diff split window cleanup", function()
local diff
local test_old_file = "/tmp/test_split_window_cleanup_old.txt"
local tab_name = "test_split_window_cleanup_tab"

before_each(function()
reset_vim_state_for_splits()

-- Prepare a dummy file
local f = assert(io.open(test_old_file, "w"))
f:write("line1\nline2\n")
f:close()

-- Minimal logger stub
package.loaded["claudecode.logger"] = {
debug = function() end,
error = function() end,
info = function() end,
warn = function() end,
}

-- Reload diff module cleanly
package.loaded["claudecode.diff"] = nil
diff = require("claudecode.diff")

diff.setup({
diff_opts = {
layout = "vertical",
open_in_new_tab = false,
keep_terminal_focus = false,
},
terminal = {},
})
end)

after_each(function()
os.remove(test_old_file)
if diff and diff._cleanup_all_active_diffs then
diff._cleanup_all_active_diffs("test teardown")
end
package.loaded["claudecode.diff"] = nil
end)

it("closes the plugin-created original split after accept when close_tab is invoked", function()
local params = {
old_file_path = test_old_file,
new_file_path = test_old_file,
new_file_contents = "new1\nnew2\n",
tab_name = tab_name,
}

diff._setup_blocking_diff(params, function() end)

local state = diff._get_active_diffs()[tab_name]
assert.is_table(state)

local new_win = state.new_window
local target_win = state.target_window

-- Should have created an extra split for the original side (target_win != 1000)
assert.are_not.equal(1000, target_win)
assert.is_true(vim.api.nvim_win_is_valid(target_win))
assert.is_true(vim.api.nvim_win_is_valid(new_win))

diff._resolve_diff_as_saved(tab_name, state.new_buffer)

-- Accept should not close windows yet
assert.is_true(vim.api.nvim_win_is_valid(target_win))

local closed = diff.close_diff_by_tab_name(tab_name)
assert.is_true(closed)

assert.is_false(vim.api.nvim_win_is_valid(new_win))
assert.is_false(vim.api.nvim_win_is_valid(target_win))
assert.is_true(vim.api.nvim_win_is_valid(1000))
end)

it("does not close the reused target window when the old file is already open", function()
-- Open the old file in the main window so choose_original_window reuses it
vim.cmd("edit " .. vim.fn.fnameescape(test_old_file))

local params = {
old_file_path = test_old_file,
new_file_path = test_old_file,
new_file_contents = "new content\n",
tab_name = tab_name,
}

diff._setup_blocking_diff(params, function() end)

local state = diff._get_active_diffs()[tab_name]
assert.is_table(state)

local new_win = state.new_window
local target_win = state.target_window

assert.are.equal(1000, target_win)
assert.is_true(vim.api.nvim_win_is_valid(new_win))

diff._resolve_diff_as_saved(tab_name, state.new_buffer)

local closed = diff.close_diff_by_tab_name(tab_name)
assert.is_true(closed)

assert.is_false(vim.api.nvim_win_is_valid(new_win))
assert.is_true(vim.api.nvim_win_is_valid(1000))

-- In reuse scenario, diff mode should have been disabled.
assert.are.equal("diffoff", vim._last_command)
end)
end)