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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
auto_close_on_accept = true,
vertical_split = true,
open_in_current_tab = true,
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens (including floating terminals)
},
},
keys = {
Expand Down
2 changes: 1 addition & 1 deletion lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ M.defaults = {
diff_opts = {
layout = "vertical",
open_in_new_tab = false, -- Open diff in a new tab (false = use current tab)
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens (including floating terminals)
hide_terminal_in_new_tab = false, -- If true and opening in a new tab, do not show Claude terminal there
on_new_file_reject = "keep_empty", -- "keep_empty" leaves an empty buffer; "close_window" closes the placeholder split
},
Expand Down
65 changes: 45 additions & 20 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,24 @@ local function find_claudecode_terminal_window()
return nil
end

-- Find the window containing this buffer
-- Find the window containing this buffer.
-- Prefer a normal split window, but fall back to a floating terminal window (e.g. Snacks position="float").
local floating_fallback = nil

for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == terminal_bufnr then
local win_config = vim.api.nvim_win_get_config(win)
if not (win_config.relative and win_config.relative ~= "") then
local is_floating = win_config.relative and win_config.relative ~= ""

if is_floating then
floating_fallback = floating_fallback or win
else
return win
end
end
end

return nil
return floating_fallback
end

---Create a split based on configured layout
Expand Down Expand Up @@ -619,11 +626,17 @@ local function setup_new_buffer(
term_tab = vim.api.nvim_win_get_tabpage(terminal_win)
end)
if term_tab == current_tab then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
local win_config = vim.api.nvim_win_get_config(terminal_win)
local is_floating = win_config.relative and win_config.relative ~= ""

-- Only resize split terminals. Floating terminals control their own sizing.
if not is_floating then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
end
end
end
end
Expand Down Expand Up @@ -1015,14 +1028,20 @@ function M._cleanup_diff_state(tab_name, reason)
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
if terminal_ok and diff_data.had_terminal_in_original then
pcall(terminal_module.ensure_visible)
-- And restore its configured width if it is visible
-- And restore its configured width if it is visible.
-- (We intentionally do not resize floating terminals.)
local terminal_win = find_claudecode_terminal_window()
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
local win_config = vim.api.nvim_win_get_config(terminal_win)
local is_floating = win_config.relative and win_config.relative ~= ""

if not is_floating then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
end
end
end
else
Expand All @@ -1038,14 +1057,20 @@ function M._cleanup_diff_state(tab_name, reason)
end)
end

-- After closing the diff in the same tab, restore terminal width if visible
-- After closing the diff in the same tab, restore terminal width if visible.
-- (We intentionally do not resize floating terminals.)
local terminal_win = find_claudecode_terminal_window()
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
local win_config = vim.api.nvim_win_get_config(terminal_win)
local is_floating = win_config.relative and win_config.relative ~= ""

if not is_floating then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
end
end
end

Expand Down
112 changes: 112 additions & 0 deletions tests/unit/diff_keep_terminal_focus_float_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
require("tests.busted_setup")

-- Regression test for #150:
-- When diff_opts.keep_terminal_focus = true and the Claude terminal lives in a floating window,
-- opening a diff should return focus to the floating terminal (not the diff split behind it).

describe("Diff keep_terminal_focus with floating terminal", function()
local diff

local test_old_file = "/tmp/claudecode_keep_focus_old.txt"
local test_new_file = "/tmp/claudecode_keep_focus_new.txt"
local tab_name = "keep-focus-float"

local editor_win = 1000
local terminal_win = 1001
local terminal_buf

before_each(function()
-- Fresh vim mock state
if vim and vim._mock and vim._mock.reset then
vim._mock.reset()
end

-- Ensure predictable tab/window state
vim._tabs = { [1] = true }
vim._current_tabpage = 1

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

-- Create a normal, non-floating editor window
local editor_buf = vim.api.nvim_create_buf(true, false)
vim._windows[editor_win] = { buf = editor_buf, width = 80 }
vim._win_tab[editor_win] = 1

-- Create a floating window for the terminal
terminal_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(terminal_buf, "buftype", "terminal")
vim._windows[terminal_win] = {
buf = terminal_buf,
width = 80,
config = { relative = "editor" },
}
vim._win_tab[terminal_win] = 1

vim._tab_windows[1] = { editor_win, terminal_win }
vim._current_window = terminal_win
vim._next_winid = 1002

-- Provide minimal config directly to diff module
diff.setup({
terminal = { split_side = "right", split_width_percentage = 0.30 },
diff_opts = {
layout = "vertical",
open_in_new_tab = false,
keep_terminal_focus = true,
},
})

-- Stub terminal provider with a valid terminal buffer
package.loaded["claudecode.terminal"] = {
get_active_terminal_bufnr = function()
return terminal_buf
end,
ensure_visible = function() end,
}

-- Create a real file so filereadable() returns 1 in mocks
local f = io.open(test_old_file, "w")
f:write("line1\nline2\n")
f:close()

-- Ensure a clean diff state
diff._cleanup_all_active_diffs("test_setup")
end)

after_each(function()
os.remove(test_old_file)
os.remove(test_new_file)

package.loaded["claudecode.terminal"] = nil

if diff then
diff._cleanup_all_active_diffs("test_teardown")
end
end)

it("restores focus to floating terminal window after diff opens", function()
local co = coroutine.create(function()
diff.open_diff_blocking(test_old_file, test_new_file, "updated content\n", tab_name)
end)

local ok, err = coroutine.resume(co)
assert.is_true(ok, tostring(err))
assert.equal("suspended", coroutine.status(co))

-- keep_terminal_focus uses vim.schedule; the vim mock executes scheduled callbacks immediately.

-- Floating terminals (e.g. Snacks) should manage their own sizing.
assert.equal(80, vim.api.nvim_win_get_width(terminal_win))
assert.equal(terminal_win, vim.api.nvim_get_current_win())

-- Resolve to finish the coroutine
vim.schedule(function()
diff._resolve_diff_as_rejected(tab_name)
end)
vim.wait(100, function()
return coroutine.status(co) == "dead"
end)
end)
end)