Skip to content

Commit 8f521fb

Browse files
authored
fix(diff): keep terminal focus for floating terminals (#178)
1 parent 93f8e48 commit 8f521fb

4 files changed

Lines changed: 159 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
280280
auto_close_on_accept = true,
281281
vertical_split = true,
282282
open_in_current_tab = true,
283-
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
283+
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens (including floating terminals)
284284
},
285285
},
286286
keys = {

lua/claudecode/config.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ M.defaults = {
2323
diff_opts = {
2424
layout = "vertical",
2525
open_in_new_tab = false, -- Open diff in a new tab (false = use current tab)
26-
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
26+
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens (including floating terminals)
2727
hide_terminal_in_new_tab = false, -- If true and opening in a new tab, do not show Claude terminal there
2828
on_new_file_reject = "keep_empty", -- "keep_empty" leaves an empty buffer; "close_window" closes the placeholder split
2929
},

lua/claudecode/diff.lua

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,24 @@ local function find_claudecode_terminal_window()
101101
return nil
102102
end
103103

104-
-- Find the window containing this buffer
104+
-- Find the window containing this buffer.
105+
-- Prefer a normal split window, but fall back to a floating terminal window (e.g. Snacks position="float").
106+
local floating_fallback = nil
107+
105108
for _, win in ipairs(vim.api.nvim_list_wins()) do
106109
if vim.api.nvim_win_get_buf(win) == terminal_bufnr then
107110
local win_config = vim.api.nvim_win_get_config(win)
108-
if not (win_config.relative and win_config.relative ~= "") then
111+
local is_floating = win_config.relative and win_config.relative ~= ""
112+
113+
if is_floating then
114+
floating_fallback = floating_fallback or win
115+
else
109116
return win
110117
end
111118
end
112119
end
113120

114-
return nil
121+
return floating_fallback
115122
end
116123

117124
---Create a split based on configured layout
@@ -619,11 +626,17 @@ local function setup_new_buffer(
619626
term_tab = vim.api.nvim_win_get_tabpage(terminal_win)
620627
end)
621628
if term_tab == current_tab then
622-
local terminal_config = config.terminal or {}
623-
local split_width = terminal_config.split_width_percentage or 0.30
624-
local total_width = vim.o.columns
625-
local terminal_width = math.floor(total_width * split_width)
626-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
629+
local win_config = vim.api.nvim_win_get_config(terminal_win)
630+
local is_floating = win_config.relative and win_config.relative ~= ""
631+
632+
-- Only resize split terminals. Floating terminals control their own sizing.
633+
if not is_floating then
634+
local terminal_config = config.terminal or {}
635+
local split_width = terminal_config.split_width_percentage or 0.30
636+
local total_width = vim.o.columns
637+
local terminal_width = math.floor(total_width * split_width)
638+
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
639+
end
627640
end
628641
end
629642
end
@@ -1015,14 +1028,20 @@ function M._cleanup_diff_state(tab_name, reason)
10151028
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
10161029
if terminal_ok and diff_data.had_terminal_in_original then
10171030
pcall(terminal_module.ensure_visible)
1018-
-- And restore its configured width if it is visible
1031+
-- And restore its configured width if it is visible.
1032+
-- (We intentionally do not resize floating terminals.)
10191033
local terminal_win = find_claudecode_terminal_window()
10201034
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
1021-
local terminal_config = config.terminal or {}
1022-
local split_width = terminal_config.split_width_percentage or 0.30
1023-
local total_width = vim.o.columns
1024-
local terminal_width = math.floor(total_width * split_width)
1025-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1035+
local win_config = vim.api.nvim_win_get_config(terminal_win)
1036+
local is_floating = win_config.relative and win_config.relative ~= ""
1037+
1038+
if not is_floating then
1039+
local terminal_config = config.terminal or {}
1040+
local split_width = terminal_config.split_width_percentage or 0.30
1041+
local total_width = vim.o.columns
1042+
local terminal_width = math.floor(total_width * split_width)
1043+
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1044+
end
10261045
end
10271046
end
10281047
else
@@ -1038,14 +1057,20 @@ function M._cleanup_diff_state(tab_name, reason)
10381057
end)
10391058
end
10401059

1041-
-- After closing the diff in the same tab, restore terminal width if visible
1060+
-- After closing the diff in the same tab, restore terminal width if visible.
1061+
-- (We intentionally do not resize floating terminals.)
10421062
local terminal_win = find_claudecode_terminal_window()
10431063
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
1044-
local terminal_config = config.terminal or {}
1045-
local split_width = terminal_config.split_width_percentage or 0.30
1046-
local total_width = vim.o.columns
1047-
local terminal_width = math.floor(total_width * split_width)
1048-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1064+
local win_config = vim.api.nvim_win_get_config(terminal_win)
1065+
local is_floating = win_config.relative and win_config.relative ~= ""
1066+
1067+
if not is_floating then
1068+
local terminal_config = config.terminal or {}
1069+
local split_width = terminal_config.split_width_percentage or 0.30
1070+
local total_width = vim.o.columns
1071+
local terminal_width = math.floor(total_width * split_width)
1072+
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1073+
end
10491074
end
10501075
end
10511076

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
require("tests.busted_setup")
2+
3+
-- Regression test for #150:
4+
-- When diff_opts.keep_terminal_focus = true and the Claude terminal lives in a floating window,
5+
-- opening a diff should return focus to the floating terminal (not the diff split behind it).
6+
7+
describe("Diff keep_terminal_focus with floating terminal", function()
8+
local diff
9+
10+
local test_old_file = "/tmp/claudecode_keep_focus_old.txt"
11+
local test_new_file = "/tmp/claudecode_keep_focus_new.txt"
12+
local tab_name = "keep-focus-float"
13+
14+
local editor_win = 1000
15+
local terminal_win = 1001
16+
local terminal_buf
17+
18+
before_each(function()
19+
-- Fresh vim mock state
20+
if vim and vim._mock and vim._mock.reset then
21+
vim._mock.reset()
22+
end
23+
24+
-- Ensure predictable tab/window state
25+
vim._tabs = { [1] = true }
26+
vim._current_tabpage = 1
27+
28+
-- Reload diff module cleanly
29+
package.loaded["claudecode.diff"] = nil
30+
diff = require("claudecode.diff")
31+
32+
-- Create a normal, non-floating editor window
33+
local editor_buf = vim.api.nvim_create_buf(true, false)
34+
vim._windows[editor_win] = { buf = editor_buf, width = 80 }
35+
vim._win_tab[editor_win] = 1
36+
37+
-- Create a floating window for the terminal
38+
terminal_buf = vim.api.nvim_create_buf(false, true)
39+
vim.api.nvim_buf_set_option(terminal_buf, "buftype", "terminal")
40+
vim._windows[terminal_win] = {
41+
buf = terminal_buf,
42+
width = 80,
43+
config = { relative = "editor" },
44+
}
45+
vim._win_tab[terminal_win] = 1
46+
47+
vim._tab_windows[1] = { editor_win, terminal_win }
48+
vim._current_window = terminal_win
49+
vim._next_winid = 1002
50+
51+
-- Provide minimal config directly to diff module
52+
diff.setup({
53+
terminal = { split_side = "right", split_width_percentage = 0.30 },
54+
diff_opts = {
55+
layout = "vertical",
56+
open_in_new_tab = false,
57+
keep_terminal_focus = true,
58+
},
59+
})
60+
61+
-- Stub terminal provider with a valid terminal buffer
62+
package.loaded["claudecode.terminal"] = {
63+
get_active_terminal_bufnr = function()
64+
return terminal_buf
65+
end,
66+
ensure_visible = function() end,
67+
}
68+
69+
-- Create a real file so filereadable() returns 1 in mocks
70+
local f = io.open(test_old_file, "w")
71+
f:write("line1\nline2\n")
72+
f:close()
73+
74+
-- Ensure a clean diff state
75+
diff._cleanup_all_active_diffs("test_setup")
76+
end)
77+
78+
after_each(function()
79+
os.remove(test_old_file)
80+
os.remove(test_new_file)
81+
82+
package.loaded["claudecode.terminal"] = nil
83+
84+
if diff then
85+
diff._cleanup_all_active_diffs("test_teardown")
86+
end
87+
end)
88+
89+
it("restores focus to floating terminal window after diff opens", function()
90+
local co = coroutine.create(function()
91+
diff.open_diff_blocking(test_old_file, test_new_file, "updated content\n", tab_name)
92+
end)
93+
94+
local ok, err = coroutine.resume(co)
95+
assert.is_true(ok, tostring(err))
96+
assert.equal("suspended", coroutine.status(co))
97+
98+
-- keep_terminal_focus uses vim.schedule; the vim mock executes scheduled callbacks immediately.
99+
100+
-- Floating terminals (e.g. Snacks) should manage their own sizing.
101+
assert.equal(80, vim.api.nvim_win_get_width(terminal_win))
102+
assert.equal(terminal_win, vim.api.nvim_get_current_win())
103+
104+
-- Resolve to finish the coroutine
105+
vim.schedule(function()
106+
diff._resolve_diff_as_rejected(tab_name)
107+
end)
108+
vim.wait(100, function()
109+
return coroutine.status(co) == "dead"
110+
end)
111+
end)
112+
end)

0 commit comments

Comments
 (0)