diff --git a/README.md b/README.md index e9ec2ff..117cc51 100644 --- a/README.md +++ b/README.md @@ -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 = { diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 9e9d0e5..a4fd436 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -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 }, diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 7e1b8f0..e301f8a 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/unit/diff_keep_terminal_focus_float_spec.lua b/tests/unit/diff_keep_terminal_focus_float_spec.lua new file mode 100644 index 0000000..bb6352e --- /dev/null +++ b/tests/unit/diff_keep_terminal_focus_float_spec.lua @@ -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)