From d4bfab34027d8f155cb5afc38f25f98e2a61c364 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 10:45:17 +0200 Subject: [PATCH 1/2] feat(diff): per-diff terminal width, auto_resize opt-out, and lifecycle events Implements issue #242. Adds three layered, backward-compatible controls for the Claude terminal during diff review: - terminal.diff_split_width_percentage: optional terminal width while a diff is open (falls back to split_width_percentage; default nil keeps current behavior). - diff_opts.auto_resize_terminal (default true): when false, the plugin leaves the terminal width untouched so users own it themselves. - User autocmds ClaudeCodeDiffOpened / ClaudeCodeDiffClosed (always emitted, pcall-guarded) carrying a data payload, so configs can react to the diff lifecycle without monkey-patching private functions. The five existing terminal-resize sites are consolidated behind one gated helper; events fire exactly once per open/close. Change-Id: I4a712a576cd1a3bd203e3f3659784bb8122f31c0 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CLAUDE.md | 6 + README.md | 43 ++++ dev-config.lua | 2 + lua/claudecode/config.lua | 4 + lua/claudecode/diff.lua | 158 +++++++++----- lua/claudecode/terminal.lua | 10 + lua/claudecode/types.lua | 2 + tests/unit/config_spec.lua | 60 +++++ tests/unit/diff_auto_resize_events_spec.lua | 229 ++++++++++++++++++++ tests/unit/diff_split_width_spec.lua | 43 ++++ tests/unit/terminal_spec.lua | 14 ++ 11 files changed, 521 insertions(+), 50 deletions(-) create mode 100644 tests/unit/diff_auto_resize_events_spec.lua create mode 100644 tests/unit/diff_split_width_spec.lua diff --git a/CLAUDE.md b/CLAUDE.md index e1845a86..e43f13c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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 `` might trigger unintended actions. ```lua @@ -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, diff --git a/README.md b/README.md index 1574f183..32766227 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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): @@ -372,6 +376,45 @@ 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 keeps its hands off) and resize from the events yourself: + +```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): diff --git a/dev-config.lua b/dev-config.lua index e3d62f4f..da953f87 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -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 diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 2860bdd6..c77577be 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -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 @@ -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 diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 51d91bcd..9704bb13 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -39,6 +39,82 @@ 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 leaves the terminal width untouched so users can own it +---themselves via the `ClaudeCodeDiffOpened`/`ClaudeCodeDiffClosed` User autocmds. +---@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 + 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 @@ -254,7 +330,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 @@ -294,9 +369,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")) @@ -616,11 +690,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 @@ -630,17 +700,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 @@ -1077,21 +1137,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) @@ -1120,21 +1168,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) @@ -1156,6 +1192,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 @@ -1336,6 +1380,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 diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 5ed370a6..61fc95a6 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -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, @@ -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 diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index c90ed8f7..ee26db0f 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -20,6 +20,7 @@ ---@field keep_terminal_focus boolean Keep focus in terminal after opening diff ---@field hide_terminal_in_new_tab boolean Hide Claude terminal in newly created diff tab ---@field on_new_file_reject ClaudeCodeNewFileRejectBehavior Behavior when rejecting a new-file diff +---@field auto_resize_terminal boolean Let the plugin resize the Claude terminal across the diff lifecycle (default true); set false to own width via the ClaudeCodeDiffOpened/Closed User autocmds -- Model selection option ---@class ClaudeCodeModelOption @@ -88,6 +89,7 @@ ---@class ClaudeCodeTerminalConfig ---@field split_side ClaudeCodeSplitSide ---@field split_width_percentage number +---@field diff_split_width_percentage number? -- optional terminal width while a diff is active; defaults to split_width_percentage ---@field provider ClaudeCodeTerminalProviderName|ClaudeCodeTerminalProvider ---@field show_native_term_exit_tip boolean ---@field terminal_cmd string? diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index dafc925a..4d3c642f 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -191,6 +191,66 @@ describe("Configuration", function() expect(success).to_be_false() end) + it("should accept valid auto_resize_terminal configuration", function() + local user_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "info", + track_selection = true, + visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 5000, + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + auto_resize_terminal = false, + }, + env = {}, + models = { + { name = "Test Model", value = "test" }, + }, + } + + local final_config = config.apply(user_config) + expect(final_config.diff_opts.auto_resize_terminal).to_be_false() + end) + + it("should default auto_resize_terminal to true", function() + local final_config = config.apply({ auto_start = true, log_level = "info" }) + expect(final_config.diff_opts.auto_resize_terminal).to_be_true() + end) + + it("should reject invalid auto_resize_terminal configuration", function() + local invalid_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "info", + track_selection = true, + visual_demotion_delay_ms = 50, + connection_wait_delay = 200, + connection_timeout = 10000, + queue_timeout = 5000, + diff_opts = { + layout = "vertical", + open_in_new_tab = false, + keep_terminal_focus = false, + auto_resize_terminal = "yes", -- Should be boolean + }, + env = {}, + models = { + { name = "Test Model", value = "test" }, + }, + } + + local success, _ = pcall(function() + config.validate(invalid_config) + end) + + expect(success).to_be_false() + end) + it("should accept function for external_terminal_cmd", function() local valid_config = { port_range = { min = 10000, max = 65535 }, diff --git a/tests/unit/diff_auto_resize_events_spec.lua b/tests/unit/diff_auto_resize_events_spec.lua new file mode 100644 index 00000000..b65dff68 --- /dev/null +++ b/tests/unit/diff_auto_resize_events_spec.lua @@ -0,0 +1,229 @@ +require("tests.busted_setup") + +describe("Diff auto_resize_terminal flag", function() + local diff = require("claudecode.diff") + + it("defaults to enabled when diff_opts is empty", function() + diff.setup({ terminal = {}, diff_opts = {} }) + assert.is_true(diff._auto_resize_enabled()) + end) + + it("defaults to enabled when diff_opts is absent", function() + diff.setup({ terminal = {} }) + assert.is_true(diff._auto_resize_enabled()) + end) + + it("is enabled when explicitly true", function() + diff.setup({ terminal = {}, diff_opts = { auto_resize_terminal = true } }) + assert.is_true(diff._auto_resize_enabled()) + end) + + it("is disabled only when explicitly false", function() + diff.setup({ terminal = {}, diff_opts = { auto_resize_terminal = false } }) + assert.is_false(diff._auto_resize_enabled()) + end) +end) + +describe("resize_terminal_for_diff gating", function() + local diff = require("claudecode.diff") + + -- Drive the resize helper with controlled window APIs and capture set_width calls. + local function capture_resize(opts) + local saved = { + valid = vim.api.nvim_win_is_valid, + cfg = vim.api.nvim_win_get_config, + setw = vim.api.nvim_win_set_width, + cols = vim.o.columns, + } + local calls = {} + vim.o.columns = 200 + vim.api.nvim_win_is_valid = function() + return opts.valid ~= false + end + vim.api.nvim_win_get_config = function() + return { relative = opts.floating and "editor" or "" } + end + vim.api.nvim_win_set_width = function(w, width) + calls[#calls + 1] = { win = w, width = width } + end + + diff.setup({ terminal = opts.terminal or {}, diff_opts = opts.diff_opts or {} }) + diff._resize_terminal_for_diff(opts.win or 4242, opts.when or "diff") + + vim.api.nvim_win_is_valid = saved.valid + vim.api.nvim_win_get_config = saved.cfg + vim.api.nvim_win_set_width = saved.setw + vim.o.columns = saved.cols + return calls + end + + it("resizes a split terminal to the diff width when enabled", function() + local calls = capture_resize({ + terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 0.2 }, + diff_opts = { auto_resize_terminal = true }, + when = "diff", + }) + assert.are.equal(1, #calls) + assert.are.equal(math.floor(200 * 0.2), calls[1].width) -- 40 + end) + + it("restores to the idle width when phase is idle", function() + local calls = capture_resize({ + terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 0.2 }, + diff_opts = { auto_resize_terminal = true }, + when = "idle", + }) + assert.are.equal(1, #calls) + assert.are.equal(math.floor(200 * 0.5), calls[1].width) -- 100 + end) + + it("does NOT resize when auto_resize_terminal is false", function() + local calls = capture_resize({ + terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 0.2 }, + diff_opts = { auto_resize_terminal = false }, + when = "diff", + }) + assert.are.equal(0, #calls) + end) + + it("does NOT resize a floating terminal window", function() + local calls = capture_resize({ + terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 0.2 }, + diff_opts = { auto_resize_terminal = true }, + floating = true, + when = "diff", + }) + assert.are.equal(0, #calls) + end) + + it("does NOT resize an invalid window", function() + local calls = capture_resize({ + diff_opts = { auto_resize_terminal = true }, + valid = false, + when = "diff", + }) + assert.are.equal(0, #calls) + end) + + it("no-ops for a nil window", function() + diff.setup({ terminal = {}, diff_opts = { auto_resize_terminal = true } }) + assert.has_no.errors(function() + diff._resize_terminal_for_diff(nil, "diff") + end) + end) +end) + +describe("Diff lifecycle User events", function() + local diff = require("claudecode.diff") + local open_diff_tool = require("claudecode.tools.open_diff") + + local test_old_file = "/tmp/claudecode_events_old.lua" + + -- Capture ONLY the nvim_exec_autocmds calls made during `action`. This is + -- hermetic: it does not depend on the shared mock recorder or on spec ordering + -- (other diff specs in the same busted process also fire ClaudeCodeDiff* events). + local function capture_events(action) + local captured = {} + local orig = vim.api.nvim_exec_autocmds + vim.api.nvim_exec_autocmds = function(event, opts) + captured[#captured + 1] = { event = event, opts = opts } + if orig then + return orig(event, opts) + end + end + local ok, err = pcall(action) + vim.api.nvim_exec_autocmds = orig + assert(ok, tostring(err)) + return captured + end + + local function find_event(captured, pattern) + for i = #captured, 1, -1 do + local e = captured[i] + if e.event == "User" and e.opts and e.opts.pattern == pattern then + return e + end + end + return nil + end + + before_each(function() + local f = io.open(test_old_file, "w") + f:write("local a = 1\nlocal b = 2\n") + f:close() + + diff.setup({ terminal = {}, diff_opts = {} }) + diff._cleanup_all_active_diffs("test_reset") + package.loaded["claudecode.terminal"] = { + get_active_terminal_bufnr = function() + return nil + end, + ensure_visible = function() end, + } + end) + + after_each(function() + package.loaded["claudecode.terminal"] = nil + os.remove(test_old_file) + end) + + it("emits ClaudeCodeDiffOpened with the full payload when a diff opens", function() + local co = coroutine.create(function() + open_diff_tool.handler({ + old_file_path = test_old_file, + new_file_path = test_old_file, + new_file_contents = "local a = 1\nlocal b = 99\nlocal c = 3\n", + tab_name = "opened-tab", + }) + end) + local captured = capture_events(function() + local ok, err = coroutine.resume(co) + assert(ok, tostring(err)) + end) + + local ev = find_event(captured, "ClaudeCodeDiffOpened") + assert.is_not_nil(ev) + local data = ev.opts.data + assert.are.equal("opened-tab", data.tab_name) + assert.are.equal(test_old_file, data.file_path) + assert.are.equal(test_old_file, data.new_file_path) + assert.is_false(data.is_new_file) + assert.is_not_nil(data.diff_window) + assert.is_not_nil(data.target_window) + assert.is_false(ev.opts.modeline) -- never re-process the current buffer's modeline + + vim.schedule(function() + diff._resolve_diff_as_rejected("opened-tab") + end) + vim.wait(100, function() + return coroutine.status(co) == "dead" + end) + end) + + it("emits ClaudeCodeDiffClosed with tab_name/file_path/reason on cleanup", function() + diff._register_diff_state("events-tab", { + old_file_path = test_old_file, + status = "pending", + }) + local captured = capture_events(function() + diff._cleanup_diff_state("events-tab", "diff rejected") + end) + + local ev = find_event(captured, "ClaudeCodeDiffClosed") + assert.is_not_nil(ev) + assert.are.equal("events-tab", ev.opts.data.tab_name) + assert.are.equal(test_old_file, ev.opts.data.file_path) + assert.are.equal("diff rejected", ev.opts.data.reason) + end) + + it("forwards the cleanup reason verbatim (not a hardcoded value)", function() + diff._register_diff_state("events-tab-2", { old_file_path = test_old_file, status = "pending" }) + local captured = capture_events(function() + diff._cleanup_diff_state("events-tab-2", "replaced by new diff") + end) + + local ev = find_event(captured, "ClaudeCodeDiffClosed") + assert.is_not_nil(ev) + assert.are.equal("replaced by new diff", ev.opts.data.reason) + end) +end) diff --git a/tests/unit/diff_split_width_spec.lua b/tests/unit/diff_split_width_spec.lua new file mode 100644 index 00000000..973989a4 --- /dev/null +++ b/tests/unit/diff_split_width_spec.lua @@ -0,0 +1,43 @@ +require("tests.busted_setup") + +describe("Diff/idle terminal split width resolution", function() + local diff = require("claudecode.diff") + + local function resolve(when) + return diff._resolve_split_width_percentage(when) + end + + it("uses split_width_percentage for both states when diff width is unset", function() + diff.setup({ terminal = { split_width_percentage = 0.5 } }) + assert.are.equal(0.5, resolve("idle")) + assert.are.equal(0.5, resolve("diff")) + end) + + it("shrinks to diff_split_width_percentage while a diff is active, idle unchanged", function() + diff.setup({ terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 0.3 } }) + assert.are.equal(0.5, resolve("idle")) + assert.are.equal(0.3, resolve("diff")) + end) + + it("falls back to the 0.30 default when no widths are configured", function() + diff.setup({ terminal = {} }) + assert.are.equal(0.30, resolve("idle")) + assert.are.equal(0.30, resolve("diff")) + end) + + it("ignores an out-of-range diff width and falls back to the idle width", function() + diff.setup({ terminal = { split_width_percentage = 0.5, diff_split_width_percentage = 2.0 } }) + assert.are.equal(0.5, resolve("diff")) + end) + + it("ignores a non-number diff width and falls back to the idle width", function() + diff.setup({ terminal = { split_width_percentage = 0.4, diff_split_width_percentage = "wide" } }) + assert.are.equal(0.4, resolve("diff")) + end) + + it("allows a diff width wider than the idle width", function() + diff.setup({ terminal = { split_width_percentage = 0.3, diff_split_width_percentage = 0.6 } }) + assert.are.equal(0.3, resolve("idle")) + assert.are.equal(0.6, resolve("diff")) + end) +end) diff --git a/tests/unit/terminal_spec.lua b/tests/unit/terminal_spec.lua index f15b13e6..0028366f 100644 --- a/tests/unit/terminal_spec.lua +++ b/tests/unit/terminal_spec.lua @@ -409,6 +409,20 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() ) end) + it("should store a valid diff_split_width_percentage", function() + terminal_wrapper.setup({ diff_split_width_percentage = 0.2 }) + assert.are.equal(0.2, terminal_wrapper.defaults.diff_split_width_percentage) + end) + + it("should ignore an invalid diff_split_width_percentage and warn", function() + terminal_wrapper.setup({ diff_split_width_percentage = 2.0 }) + assert.is_nil(terminal_wrapper.defaults.diff_split_width_percentage) + vim.notify:was_called_with( + spy.matching.string.match("Invalid value for diff_split_width_percentage"), + vim.log.levels.WARN + ) + end) + it("should ignore unknown keys", function() terminal_wrapper.setup({ unknown_key = "some_value", split_side = "left" }) terminal_wrapper.open() From c88184edf6398d55b5383cf6354b5bfd9629ac60 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 9 Jun 2026 11:21:51 +0200 Subject: [PATCH 2/2] docs(diff): clarify auto_resize_terminal opt-out is hands-off, not freeze-width Per review feedback (#270): when auto_resize_terminal=false the plugin applies no width policy, but the diff layout still runs `wincmd =` (equalizing splits), so opt-out means "own the width via the ClaudeCodeDiffOpened/Closed events" (which fire after layout, so a handler's resize wins) rather than "freeze the width". Tighten the helper comment and README wording to match. Change-Id: I2ab94caf160ae7e1fe57c3917ba00ce628aaf52b Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- README.md | 5 ++++- lua/claudecode/diff.lua | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 32766227..884b298b 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,10 @@ update a statusline, etc.). They are emitted regardless of `auto_resize_terminal `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 keeps its hands off) and resize from the events yourself: +(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", { diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 9704bb13..f4f81227 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -72,8 +72,11 @@ 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 leaves the terminal width untouched so users can own it ----themselves via the `ClaudeCodeDiffOpened`/`ClaudeCodeDiffClosed` User autocmds. +---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)