Skip to content

Commit 7b8b709

Browse files
ThomasK33claude
andauthored
feat(diff): per-diff terminal width, auto_resize_terminal opt-out, and lifecycle events (#270)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 254203b commit 7b8b709

11 files changed

Lines changed: 527 additions & 50 deletions

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,13 @@ The `diff_opts` configuration allows you to customize diff behavior:
302302
- `open_in_new_tab` (boolean, default: `false`) - Open diffs in a new tab instead of the current tab.
303303
- `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.
304304
- `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).
305+
- `auto_resize_terminal` (boolean, default: `true`) - Whether the plugin resizes the Claude terminal across the diff lifecycle. Set to `false` to keep the plugin's hands off the terminal width and manage it yourself via the `ClaudeCodeDiffOpened`/`ClaudeCodeDiffClosed` User autocmds.
305306
- Legacy aliases (still supported): `vertical_split` (maps to `layout`) and `open_in_current_tab` (inverse of `open_in_new_tab`).
306307

308+
Related terminal option: `terminal.diff_split_width_percentage` (number, default: `nil`) shrinks/widens the terminal split while a diff is open, falling back to `terminal.split_width_percentage` when unset. It only applies when `auto_resize_terminal` is `true`.
309+
310+
The plugin also emits `User` autocmds `ClaudeCodeDiffOpened` (data: `tab_name`, `file_path`, `new_file_path`, `is_new_file`, `diff_window`, `target_window`, `terminal_window`, `tab_number`) and `ClaudeCodeDiffClosed` (data: `tab_name`, `file_path`, `reason`). These fire regardless of `auto_resize_terminal`, letting user configs react to the diff lifecycle. `reason` is a best-effort human-readable label, not a stable enum; `tab_number` is set only for new-tab diffs and `terminal_window` may be `nil` when no Claude terminal is visible.
311+
307312
**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.
308313

309314
```lua
@@ -314,6 +319,7 @@ require("claudecode").setup({
314319
open_in_new_tab = true, -- Open diff in a separate tab
315320
hide_terminal_in_new_tab = true, -- In the new tab, do not show Claude terminal
316321
on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"
322+
auto_resize_terminal = true, -- false = own terminal width via ClaudeCodeDiffOpened/Closed User autocmds
317323

318324
-- Legacy aliases (still supported):
319325
-- vertical_split = true,

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
348348
terminal = {
349349
split_side = "right", -- "left" or "right"
350350
split_width_percentage = 0.30,
351+
-- Optional: shrink (or widen) the terminal while a diff is open. Defaults to
352+
-- split_width_percentage when unset, preserving today's behavior.
353+
diff_split_width_percentage = nil, -- e.g. 0.20 to give diffs more room
351354
provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table
352355
auto_close = true,
353356
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
@@ -372,6 +375,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
372375
open_in_new_tab = false,
373376
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
374377
hide_terminal_in_new_tab = false,
378+
auto_resize_terminal = true, -- Let the plugin manage the terminal width across the diff lifecycle; set false to own it via the User autocmds below
375379
-- on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"
376380

377381
-- Legacy aliases (still supported):
@@ -385,6 +389,48 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
385389
}
386390
```
387391

392+
### Diff Lifecycle Events
393+
394+
The plugin fires `User` autocmds when a diff opens and closes, so you can react to
395+
the review lifecycle from your own config (resize windows, toggle a colorscheme,
396+
update a statusline, etc.). They are emitted regardless of `auto_resize_terminal`.
397+
398+
| Event pattern | When | `event.data` fields |
399+
| ---------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
400+
| `ClaudeCodeDiffOpened` | A proposed-edit diff has opened | `tab_name`, `file_path`, `new_file_path`, `is_new_file`, `diff_window`, `target_window`, `terminal_window`, `tab_number` |
401+
| `ClaudeCodeDiffClosed` | The diff was accepted/rejected/closed | `tab_name`, `file_path`, `reason` |
402+
403+
`reason` is a best-effort, human-readable label (e.g. `"diff accepted"`, `"diff rejected"`, `"replaced by new diff"`); treat it as diagnostic text, not a stable enum to branch on. `tab_number` is only set when the diff opened in its own tab, and `terminal_window` may be `nil` if no Claude terminal is visible.
404+
405+
To fully own the terminal width during diffs, set `diff_opts.auto_resize_terminal = false`
406+
(so the plugin applies no width policy of its own) and resize from the events yourself.
407+
Note this is "own the width via the events", not "freeze the width": the diff layout still
408+
runs `wincmd =`, which equalizes splits, so set your desired width in the `ClaudeCodeDiffOpened`
409+
handler — it fires after the layout is built, so it wins:
410+
411+
```lua
412+
vim.api.nvim_create_autocmd("User", {
413+
pattern = "ClaudeCodeDiffOpened",
414+
callback = function(ev)
415+
local term = ev.data.terminal_window
416+
if term and vim.api.nvim_win_is_valid(term) then
417+
vim.api.nvim_win_set_width(term, math.floor(vim.o.columns * 0.20))
418+
end
419+
end,
420+
})
421+
422+
vim.api.nvim_create_autocmd("User", {
423+
pattern = "ClaudeCodeDiffClosed",
424+
callback = function(ev)
425+
-- restore your preferred idle layout here
426+
end,
427+
})
428+
```
429+
430+
> For the common "just make the terminal narrower during diffs" case you don't need
431+
> the events at all — set `terminal.diff_split_width_percentage` and leave
432+
> `auto_resize_terminal = true`.
433+
388434
### Working Directory Control
389435

390436
You can fix the Claude terminal's working directory regardless of `autochdir` and buffer-local cwd changes. Options (precedence order):

dev-config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,14 @@ return {
8585
-- open_in_new_tab = true, -- Open diff in a new tab (false = use current tab)
8686
-- keep_terminal_focus = true, -- Keep focus in terminal after opening diff
8787
-- hide_terminal_in_new_tab = true, -- Hide Claude terminal in the new diff tab for more review space
88+
-- auto_resize_terminal = true, -- false = own terminal width via ClaudeCodeDiffOpened/Closed User autocmds
8889
-- },
8990

9091
-- Terminal Configuration
9192
-- terminal = {
9293
-- split_side = "right", -- "left" or "right"
9394
-- split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0)
95+
-- diff_split_width_percentage = 0.20, -- Optional: terminal width while a diff is open (defaults to split_width_percentage)
9496
-- provider = "auto", -- "auto", "snacks", or "native"
9597
-- show_native_term_exit_tip = true, -- Show exit tip for native terminal
9698
-- auto_close = true, -- Auto-close terminal after command completion

lua/claudecode/config.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ M.defaults = {
2626
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
29+
auto_resize_terminal = true, -- Let the plugin manage Claude terminal width across the diff lifecycle; false = own it via ClaudeCodeDiffOpened/Closed
2930
},
3031
-- `value` is passed verbatim to `claude --model`. These short aliases resolve
3132
-- to the latest model on the Anthropic API, so labels stay version-free to
@@ -146,6 +147,9 @@ function M.validate(config)
146147
"diff_opts.on_new_file_reject must be 'keep_empty' or 'close_window'"
147148
)
148149
end
150+
if config.diff_opts.auto_resize_terminal ~= nil then
151+
assert(type(config.diff_opts.auto_resize_terminal) == "boolean", "diff_opts.auto_resize_terminal must be a boolean")
152+
end
149153

150154
-- Legacy diff options (accept if present to avoid breaking old configs)
151155
if config.diff_opts.auto_close_on_accept ~= nil then

lua/claudecode/diff.lua

Lines changed: 111 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,85 @@ local function get_autocmd_group()
3939
return autocmd_group
4040
end
4141

42+
---Resolve the terminal split-width percentage for the current context.
43+
---While a diff is active, an optional `terminal.diff_split_width_percentage`
44+
---(a number in the open interval (0, 1)) takes precedence so the Claude
45+
---terminal can shrink to give the diff more room. It falls back to the idle
46+
---`terminal.split_width_percentage`, then to the 0.30 default.
47+
---@param when "diff"|"idle" Whether a diff is currently active or being torn down
48+
---@return number percentage A width fraction in (0, 1)
49+
local function resolve_split_width_percentage(when)
50+
local terminal_config = (config and config.terminal) or {}
51+
52+
local idle = terminal_config.split_width_percentage
53+
if type(idle) ~= "number" or idle <= 0 or idle >= 1 then
54+
idle = 0.30
55+
end
56+
57+
if when == "diff" then
58+
-- Defensively validate here too: config.apply does not validate terminal
59+
-- sub-keys, so this is the authoritative guard for the value we consume.
60+
-- (terminal.setup additionally warns the user on a bad value at setup time.)
61+
local diff_pct = terminal_config.diff_split_width_percentage
62+
if type(diff_pct) == "number" and diff_pct > 0 and diff_pct < 1 then
63+
return diff_pct
64+
end
65+
end
66+
67+
return idle
68+
end
69+
70+
-- Exposed for testing the diff/idle width resolution logic.
71+
M._resolve_split_width_percentage = resolve_split_width_percentage
72+
73+
---Whether the plugin should manage (resize) the Claude terminal width across the
74+
---diff lifecycle. Controlled by `diff_opts.auto_resize_terminal` (default true).
75+
---When false, the plugin applies no width policy of its own; pair it with the
76+
---`ClaudeCodeDiffOpened`/`ClaudeCodeDiffClosed` User autocmds to size the terminal
77+
---yourself. Note the diff layout still runs `wincmd =` (which equalizes splits),
78+
---so opting out is "own the width via the events" rather than "freeze the width";
79+
---the events fire after the layout is built, so a handler's resize wins.
80+
---@return boolean
81+
local function auto_resize_enabled()
82+
return not (config and config.diff_opts and config.diff_opts.auto_resize_terminal == false)
83+
end
84+
85+
-- Exposed for testing.
86+
M._auto_resize_enabled = auto_resize_enabled
87+
88+
---Resize a Claude terminal split window for the current diff phase.
89+
---No-ops when the user opted out (`auto_resize_terminal = false`), when the
90+
---window is missing/invalid, or when it is a floating window (those manage their
91+
---own sizing). Used for both the during-diff shrink and the on-close restore.
92+
---@param win number? The terminal window id (may be nil)
93+
---@param when "diff"|"idle" The current diff phase
94+
local function resize_terminal_for_diff(win, when)
95+
if not auto_resize_enabled() then
96+
return
97+
end
98+
if not win or not vim.api.nvim_win_is_valid(win) then
99+
return
100+
end
101+
local win_config = vim.api.nvim_win_get_config(win)
102+
if win_config.relative and win_config.relative ~= "" then
103+
return -- floating terminals control their own sizing
104+
end
105+
local split_width = resolve_split_width_percentage(when)
106+
pcall(vim.api.nvim_win_set_width, win, math.floor(vim.o.columns * split_width))
107+
end
108+
109+
-- Exposed for testing the gate + floating-skip + resize behavior.
110+
M._resize_terminal_for_diff = resize_terminal_for_diff
111+
112+
---Fire a plugin User autocmd for the diff lifecycle. Always emitted, regardless
113+
---of `auto_resize_terminal`, so users can react to diffs opening/closing. Wrapped
114+
---in pcall so a faulty user handler can never break diff setup or teardown.
115+
---@param name string The User event pattern (e.g. "ClaudeCodeDiffOpened")
116+
---@param data table Payload exposed to handlers as `args.data`
117+
local function fire_diff_event(name, data)
118+
pcall(vim.api.nvim_exec_autocmds, "User", { pattern = name, data = data, modeline = false })
119+
end
120+
42121
---Find a suitable main editor window to open diffs in.
43122
---Excludes terminals, sidebars, and floating windows.
44123
---@return number? win_id Window ID of the main editor window, or nil if not found
@@ -254,7 +333,6 @@ local function display_terminal_in_new_tab()
254333

255334
local terminal_config = config.terminal or {}
256335
local split_side = terminal_config.split_side or "right"
257-
local split_width = terminal_config.split_width_percentage or 0.30
258336

259337
-- Optionally hide the Claude terminal in the new tab for more review space
260338
local hide_in_new_tab = false
@@ -294,9 +372,8 @@ local function display_terminal_in_new_tab()
294372
desc = "Auto-enter terminal mode when focusing Claude Code terminal",
295373
})
296374

297-
local total_width = vim.o.columns
298-
local terminal_width = math.floor(total_width * split_width)
299-
vim.api.nvim_win_set_width(terminal_win, terminal_width)
375+
-- Size the terminal for the diff (unless the user opted out via auto_resize_terminal).
376+
resize_terminal_for_diff(terminal_win, "diff")
300377

301378
vim.cmd("wincmd " .. (split_side == "right" and "h" or "l"))
302379

@@ -616,11 +693,7 @@ local function setup_new_buffer(
616693
end
617694

618695
if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
619-
local terminal_config = config.terminal or {}
620-
local split_width = terminal_config.split_width_percentage or 0.30
621-
local total_width = vim.o.columns
622-
local terminal_width = math.floor(total_width * split_width)
623-
vim.api.nvim_win_set_width(terminal_win_in_new_tab, terminal_width)
696+
resize_terminal_for_diff(terminal_win_in_new_tab, "diff")
624697
else
625698
local terminal_win = find_claudecode_terminal_window()
626699
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
@@ -630,17 +703,7 @@ local function setup_new_buffer(
630703
term_tab = vim.api.nvim_win_get_tabpage(terminal_win)
631704
end)
632705
if term_tab == current_tab then
633-
local win_config = vim.api.nvim_win_get_config(terminal_win)
634-
local is_floating = win_config.relative and win_config.relative ~= ""
635-
636-
-- Only resize split terminals. Floating terminals control their own sizing.
637-
if not is_floating then
638-
local terminal_config = config.terminal or {}
639-
local split_width = terminal_config.split_width_percentage or 0.30
640-
local total_width = vim.o.columns
641-
local terminal_width = math.floor(total_width * split_width)
642-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
643-
end
706+
resize_terminal_for_diff(terminal_win, "diff")
644707
end
645708
end
646709
end
@@ -1077,21 +1140,9 @@ function M._cleanup_diff_state(tab_name, reason)
10771140
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
10781141
if terminal_ok and diff_data.had_terminal_in_original then
10791142
pcall(terminal_module.ensure_visible)
1080-
-- And restore its configured width if it is visible.
1081-
-- (We intentionally do not resize floating terminals.)
1082-
local terminal_win = find_claudecode_terminal_window()
1083-
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
1084-
local win_config = vim.api.nvim_win_get_config(terminal_win)
1085-
local is_floating = win_config.relative and win_config.relative ~= ""
1086-
1087-
if not is_floating then
1088-
local terminal_config = config.terminal or {}
1089-
local split_width = terminal_config.split_width_percentage or 0.30
1090-
local total_width = vim.o.columns
1091-
local terminal_width = math.floor(total_width * split_width)
1092-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1093-
end
1094-
end
1143+
-- Restore the idle terminal width if it is visible (unless the user opted
1144+
-- out via auto_resize_terminal). Floating terminals are skipped.
1145+
resize_terminal_for_diff(find_claudecode_terminal_window(), "idle")
10951146
end
10961147
else
10971148
-- Close new diff window if still open (only if not in a new tab)
@@ -1120,21 +1171,9 @@ function M._cleanup_diff_state(tab_name, reason)
11201171
end
11211172
end
11221173

1123-
-- After closing the diff in the same tab, restore terminal width if visible.
1124-
-- (We intentionally do not resize floating terminals.)
1125-
local terminal_win = find_claudecode_terminal_window()
1126-
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
1127-
local win_config = vim.api.nvim_win_get_config(terminal_win)
1128-
local is_floating = win_config.relative and win_config.relative ~= ""
1129-
1130-
if not is_floating then
1131-
local terminal_config = config.terminal or {}
1132-
local split_width = terminal_config.split_width_percentage or 0.30
1133-
local total_width = vim.o.columns
1134-
local terminal_width = math.floor(total_width * split_width)
1135-
pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
1136-
end
1137-
end
1174+
-- After closing the diff in the same tab, restore the idle terminal width
1175+
-- (unless the user opted out via auto_resize_terminal). Floating terminals are skipped.
1176+
resize_terminal_for_diff(find_claudecode_terminal_window(), "idle")
11381177
end
11391178

11401179
-- ALWAYS clean up buffers regardless of tab mode (fixes buffer leak)
@@ -1156,6 +1195,14 @@ function M._cleanup_diff_state(tab_name, reason)
11561195
-- Remove from active diffs
11571196
active_diffs[tab_name] = nil
11581197

1198+
-- Notify listeners that the diff has closed. Always emitted; pairs with
1199+
-- ClaudeCodeDiffOpened. `reason` describes why (accepted/rejected/replaced/etc.).
1200+
fire_diff_event("ClaudeCodeDiffClosed", {
1201+
tab_name = tab_name,
1202+
file_path = diff_data.old_file_path,
1203+
reason = reason,
1204+
})
1205+
11591206
logger.debug("diff", "Cleaned up diff for", tab_name)
11601207
end
11611208

@@ -1336,6 +1383,20 @@ function M._setup_blocking_diff(params, resolution_callback)
13361383
is_new_file = is_new_file,
13371384
client_id = params.client_id,
13381385
})
1386+
1387+
-- Notify listeners that a diff is now open. Always emitted (independent of
1388+
-- auto_resize_terminal); pairs with ClaudeCodeDiffClosed. Handlers receive
1389+
-- the payload as `args.data` and may resize/relayout however they like.
1390+
fire_diff_event("ClaudeCodeDiffOpened", {
1391+
tab_name = tab_name,
1392+
file_path = params.old_file_path,
1393+
new_file_path = params.new_file_path,
1394+
is_new_file = is_new_file,
1395+
diff_window = diff_info.new_window,
1396+
target_window = diff_info.target_window,
1397+
terminal_window = terminal_win_in_new_tab or find_claudecode_terminal_window(),
1398+
tab_number = created_new_tab and new_tab_handle or nil,
1399+
})
13391400
end) -- End of pcall
13401401

13411402
-- Handle setup errors

lua/claudecode/terminal.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ local claudecode_server_module = require("claudecode.server.init")
1010
local defaults = {
1111
split_side = "right",
1212
split_width_percentage = 0.30,
13+
diff_split_width_percentage = nil, -- optional terminal width while a diff is active; defaults to split_width_percentage
1314
provider = "auto",
1415
show_native_term_exit_tip = true,
1516
terminal_cmd = nil,
@@ -463,6 +464,15 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
463464
vim.log.levels.WARN
464465
)
465466
end
467+
elseif k == "diff_split_width_percentage" then
468+
if v == nil or (type(v) == "number" and v > 0 and v < 1) then
469+
defaults.diff_split_width_percentage = v
470+
else
471+
vim.notify(
472+
"claudecode.terminal.setup: Invalid value for diff_split_width_percentage: " .. tostring(v),
473+
vim.log.levels.WARN
474+
)
475+
end
466476
elseif k == "provider" then
467477
if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" or v == "none" then
468478
defaults.provider = v

0 commit comments

Comments
 (0)