Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,13 @@ The `diff_opts` configuration allows you to customize diff behavior:
- `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).
- `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.
- Legacy aliases (still supported): `vertical_split` (maps to `layout`) and `open_in_current_tab` (inverse of `open_in_new_tab`).

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`.

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.

**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
Expand All @@ -314,6 +319,7 @@ require("claudecode").setup({
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
on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"
auto_resize_terminal = true, -- false = own terminal width via ClaudeCodeDiffOpened/Closed User autocmds

-- Legacy aliases (still supported):
-- vertical_split = true,
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
terminal = {
split_side = "right", -- "left" or "right"
split_width_percentage = 0.30,
-- Optional: shrink (or widen) the terminal while a diff is open. Defaults to
-- split_width_percentage when unset, preserving today's behavior.
diff_split_width_percentage = nil, -- e.g. 0.20 to give diffs more room
provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table
auto_close = true,
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
Expand All @@ -359,6 +362,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
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,
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
-- on_new_file_reject = "keep_empty", -- "keep_empty" or "close_window"

-- Legacy aliases (still supported):
Expand All @@ -372,6 +376,48 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
}
```

### Diff Lifecycle Events

The plugin fires `User` autocmds when a diff opens and closes, so you can react to
the review lifecycle from your own config (resize windows, toggle a colorscheme,
update a statusline, etc.). They are emitted regardless of `auto_resize_terminal`.

| Event pattern | When | `event.data` fields |
| ---------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `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` |
| `ClaudeCodeDiffClosed` | The diff was accepted/rejected/closed | `tab_name`, `file_path`, `reason` |

`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.

To fully own the terminal width during diffs, set `diff_opts.auto_resize_terminal = false`
(so the plugin applies no width policy of its own) and resize from the events yourself.
Note this is "own the width via the events", not "freeze the width": the diff layout still
runs `wincmd =`, which equalizes splits, so set your desired width in the `ClaudeCodeDiffOpened`
handler — it fires after the layout is built, so it wins:

```lua
vim.api.nvim_create_autocmd("User", {
pattern = "ClaudeCodeDiffOpened",
callback = function(ev)
local term = ev.data.terminal_window
if term and vim.api.nvim_win_is_valid(term) then
vim.api.nvim_win_set_width(term, math.floor(vim.o.columns * 0.20))
end
end,
})

vim.api.nvim_create_autocmd("User", {
pattern = "ClaudeCodeDiffClosed",
callback = function(ev)
-- restore your preferred idle layout here
end,
})
```

> For the common "just make the terminal narrower during diffs" case you don't need
> the events at all — set `terminal.diff_split_width_percentage` and leave
> `auto_resize_terminal = true`.

### Working Directory Control

You can fix the Claude terminal's working directory regardless of `autochdir` and buffer-local cwd changes. Options (precedence order):
Expand Down
2 changes: 2 additions & 0 deletions dev-config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ return {
-- open_in_new_tab = true, -- Open diff in a new tab (false = use current tab)
-- keep_terminal_focus = true, -- Keep focus in terminal after opening diff
-- hide_terminal_in_new_tab = true, -- Hide Claude terminal in the new diff tab for more review space
-- auto_resize_terminal = true, -- false = own terminal width via ClaudeCodeDiffOpened/Closed User autocmds
-- },

-- Terminal Configuration
-- terminal = {
-- split_side = "right", -- "left" or "right"
-- split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0)
-- diff_split_width_percentage = 0.20, -- Optional: terminal width while a diff is open (defaults to split_width_percentage)
-- provider = "auto", -- "auto", "snacks", or "native"
-- show_native_term_exit_tip = true, -- Show exit tip for native terminal
-- auto_close = true, -- Auto-close terminal after command completion
Expand Down
4 changes: 4 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ M.defaults = {
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
auto_resize_terminal = true, -- Let the plugin manage Claude terminal width across the diff lifecycle; false = own it via ClaudeCodeDiffOpened/Closed
},
-- `value` is passed verbatim to `claude --model`. These short aliases resolve
-- to the latest model on the Anthropic API, so labels stay version-free to
Expand Down Expand Up @@ -146,6 +147,9 @@ function M.validate(config)
"diff_opts.on_new_file_reject must be 'keep_empty' or 'close_window'"
)
end
if config.diff_opts.auto_resize_terminal ~= nil then
assert(type(config.diff_opts.auto_resize_terminal) == "boolean", "diff_opts.auto_resize_terminal must be a boolean")
end

-- Legacy diff options (accept if present to avoid breaking old configs)
if config.diff_opts.auto_close_on_accept ~= nil then
Expand Down
161 changes: 111 additions & 50 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,85 @@ local function get_autocmd_group()
return autocmd_group
end

---Resolve the terminal split-width percentage for the current context.
---While a diff is active, an optional `terminal.diff_split_width_percentage`
---(a number in the open interval (0, 1)) takes precedence so the Claude
---terminal can shrink to give the diff more room. It falls back to the idle
---`terminal.split_width_percentage`, then to the 0.30 default.
---@param when "diff"|"idle" Whether a diff is currently active or being torn down
---@return number percentage A width fraction in (0, 1)
local function resolve_split_width_percentage(when)
local terminal_config = (config and config.terminal) or {}

local idle = terminal_config.split_width_percentage
if type(idle) ~= "number" or idle <= 0 or idle >= 1 then
idle = 0.30
end

if when == "diff" then
-- Defensively validate here too: config.apply does not validate terminal
-- sub-keys, so this is the authoritative guard for the value we consume.
-- (terminal.setup additionally warns the user on a bad value at setup time.)
local diff_pct = terminal_config.diff_split_width_percentage
if type(diff_pct) == "number" and diff_pct > 0 and diff_pct < 1 then
return diff_pct
end
end

return idle
end

-- Exposed for testing the diff/idle width resolution logic.
M._resolve_split_width_percentage = resolve_split_width_percentage

---Whether the plugin should manage (resize) the Claude terminal width across the
---diff lifecycle. Controlled by `diff_opts.auto_resize_terminal` (default true).
---When false, the plugin applies no width policy of its own; pair it with the
---`ClaudeCodeDiffOpened`/`ClaudeCodeDiffClosed` User autocmds to size the terminal
---yourself. Note the diff layout still runs `wincmd =` (which equalizes splits),
---so opting out is "own the width via the events" rather than "freeze the width";
---the events fire after the layout is built, so a handler's resize wins.
---@return boolean
local function auto_resize_enabled()
return not (config and config.diff_opts and config.diff_opts.auto_resize_terminal == false)
end

-- Exposed for testing.
M._auto_resize_enabled = auto_resize_enabled

---Resize a Claude terminal split window for the current diff phase.
---No-ops when the user opted out (`auto_resize_terminal = false`), when the
---window is missing/invalid, or when it is a floating window (those manage their
---own sizing). Used for both the during-diff shrink and the on-close restore.
---@param win number? The terminal window id (may be nil)
---@param when "diff"|"idle" The current diff phase
local function resize_terminal_for_diff(win, when)
if not auto_resize_enabled() then
return
end
Comment thread
ThomasK33 marked this conversation as resolved.
if not win or not vim.api.nvim_win_is_valid(win) then
return
end
local win_config = vim.api.nvim_win_get_config(win)
if win_config.relative and win_config.relative ~= "" then
return -- floating terminals control their own sizing
end
local split_width = resolve_split_width_percentage(when)
pcall(vim.api.nvim_win_set_width, win, math.floor(vim.o.columns * split_width))
end

-- Exposed for testing the gate + floating-skip + resize behavior.
M._resize_terminal_for_diff = resize_terminal_for_diff

---Fire a plugin User autocmd for the diff lifecycle. Always emitted, regardless
---of `auto_resize_terminal`, so users can react to diffs opening/closing. Wrapped
---in pcall so a faulty user handler can never break diff setup or teardown.
---@param name string The User event pattern (e.g. "ClaudeCodeDiffOpened")
---@param data table Payload exposed to handlers as `args.data`
local function fire_diff_event(name, data)
pcall(vim.api.nvim_exec_autocmds, "User", { pattern = name, data = data, modeline = false })
end

---Find a suitable main editor window to open diffs in.
---Excludes terminals, sidebars, and floating windows.
---@return number? win_id Window ID of the main editor window, or nil if not found
Expand Down Expand Up @@ -254,7 +333,6 @@ local function display_terminal_in_new_tab()

local terminal_config = config.terminal or {}
local split_side = terminal_config.split_side or "right"
local split_width = terminal_config.split_width_percentage or 0.30

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

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

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

Expand Down Expand Up @@ -616,11 +693,7 @@ local function setup_new_buffer(
end

if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_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)
vim.api.nvim_win_set_width(terminal_win_in_new_tab, terminal_width)
resize_terminal_for_diff(terminal_win_in_new_tab, "diff")
else
local terminal_win = find_claudecode_terminal_window()
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
Expand All @@ -630,17 +703,7 @@ local function setup_new_buffer(
term_tab = vim.api.nvim_win_get_tabpage(terminal_win)
end)
if term_tab == current_tab then
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
resize_terminal_for_diff(terminal_win, "diff")
end
end
end
Expand Down Expand Up @@ -1077,21 +1140,9 @@ 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.
-- (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 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
-- Restore the idle terminal width if it is visible (unless the user opted
-- out via auto_resize_terminal). Floating terminals are skipped.
resize_terminal_for_diff(find_claudecode_terminal_window(), "idle")
end
else
-- Close new diff window if still open (only if not in a new tab)
Expand Down Expand Up @@ -1120,21 +1171,9 @@ function M._cleanup_diff_state(tab_name, reason)
end
end

-- 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 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
-- After closing the diff in the same tab, restore the idle terminal width
-- (unless the user opted out via auto_resize_terminal). Floating terminals are skipped.
resize_terminal_for_diff(find_claudecode_terminal_window(), "idle")
end

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

-- Notify listeners that the diff has closed. Always emitted; pairs with
-- ClaudeCodeDiffOpened. `reason` describes why (accepted/rejected/replaced/etc.).
fire_diff_event("ClaudeCodeDiffClosed", {
tab_name = tab_name,
file_path = diff_data.old_file_path,
reason = reason,
})

logger.debug("diff", "Cleaned up diff for", tab_name)
end

Expand Down Expand Up @@ -1336,6 +1383,20 @@ function M._setup_blocking_diff(params, resolution_callback)
is_new_file = is_new_file,
client_id = params.client_id,
})

-- Notify listeners that a diff is now open. Always emitted (independent of
-- auto_resize_terminal); pairs with ClaudeCodeDiffClosed. Handlers receive
-- the payload as `args.data` and may resize/relayout however they like.
fire_diff_event("ClaudeCodeDiffOpened", {
tab_name = tab_name,
file_path = params.old_file_path,
new_file_path = params.new_file_path,
is_new_file = is_new_file,
diff_window = diff_info.new_window,
target_window = diff_info.target_window,
terminal_window = terminal_win_in_new_tab or find_claudecode_terminal_window(),
tab_number = created_new_tab and new_tab_handle or nil,
})
end) -- End of pcall

-- Handle setup errors
Expand Down
10 changes: 10 additions & 0 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ local claudecode_server_module = require("claudecode.server.init")
local defaults = {
split_side = "right",
split_width_percentage = 0.30,
diff_split_width_percentage = nil, -- optional terminal width while a diff is active; defaults to split_width_percentage
provider = "auto",
show_native_term_exit_tip = true,
terminal_cmd = nil,
Expand Down Expand Up @@ -449,6 +450,15 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
vim.log.levels.WARN
)
end
elseif k == "diff_split_width_percentage" then
if v == nil or (type(v) == "number" and v > 0 and v < 1) then
defaults.diff_split_width_percentage = v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for diff_split_width_percentage: " .. tostring(v),
vim.log.levels.WARN
)
end
elseif k == "provider" then
if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" or v == "none" then
defaults.provider = v
Expand Down
Loading
Loading