From 21d76d5aa556c5d67074dfd729b08ca8e1c8b553 Mon Sep 17 00:00:00 2001 From: giuxtaposition Date: Fri, 14 Aug 2026 14:57:14 +0200 Subject: [PATCH] feat(terminal): support top and bottom split_side --- README.md | 4 +- lua/claudecode/diff.lua | 40 +++++++++------- lua/claudecode/terminal.lua | 4 +- lua/claudecode/terminal/native.lua | 49 +++++++++---------- lua/claudecode/types.lua | 2 +- tests/unit/diff_split_width_spec.lua | 2 +- tests/unit/native_terminal_toggle_spec.lua | 56 +++++++++++++++++++++- tests/unit/terminal_spec.lua | 10 ++++ 8 files changed, 116 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 4e3f10f2..bafd20c1 100644 --- a/README.md +++ b/README.md @@ -346,8 +346,8 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). -- Terminal Configuration terminal = { - split_side = "right", -- "left" or "right" - split_width_percentage = 0.30, + split_side = "right", -- "left", "right", "top", or "bottom" (top/bottom = horizontal split) + split_width_percentage = 0.30, -- fraction of columns for vertical splits, of lines for horizontal -- 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 diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 25b9cdad..f0950232 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -39,14 +39,16 @@ local function get_autocmd_group() return autocmd_group end ----Resolve the terminal split-width percentage for the current context. +---Resolve the terminal split-size fraction for the current context. The value +---is applied to `vim.o.columns` for left/right splits and to `vim.o.lines` for +---top/bottom splits (the config keys keep the historical "width" label). ---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) +---@return number fraction A size fraction in (0, 1) +local function resolve_terminal_split_fraction(when) local terminal_config = (config and config.terminal) or {} local idle = terminal_config.split_width_percentage @@ -58,17 +60,17 @@ local function resolve_split_width_percentage(when) -- 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 + local diff_fraction = terminal_config.diff_split_width_percentage + if type(diff_fraction) == "number" and diff_fraction > 0 and diff_fraction < 1 then + return diff_fraction end end return idle end --- Exposed for testing the diff/idle width resolution logic. -M._resolve_split_width_percentage = resolve_split_width_percentage +-- Exposed for testing the diff/idle size fraction resolution logic. +M._resolve_terminal_split_fraction = resolve_terminal_split_fraction ---Whether the plugin should manage (resize) the Claude terminal width across the ---diff lifecycle. Controlled by `diff_opts.auto_resize_terminal` (default true). @@ -102,8 +104,13 @@ local function resize_terminal_for_diff(win, when) 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)) + local fraction = resolve_terminal_split_fraction(when) + local split_side = (config and config.terminal and config.terminal.split_side) or "right" + if split_side == "top" or split_side == "bottom" then + pcall(vim.api.nvim_win_set_height, win, math.floor(vim.o.lines * fraction)) + else + pcall(vim.api.nvim_win_set_width, win, math.floor(vim.o.columns * fraction)) + end end -- Exposed for testing the gate + floating-skip + resize behavior. @@ -377,15 +384,13 @@ local function display_terminal_in_new_tab() return original_tab, nil, had_terminal_in_original, new_tab end - vim.cmd("vsplit") + local horizontal = split_side == "top" or split_side == "bottom" + vim.cmd(horizontal and "split" or "vsplit") local terminal_win = vim.api.nvim_get_current_win() - if split_side == "left" then - vim.cmd("wincmd H") - else - vim.cmd("wincmd L") - end + local move_cmd = ({ left = "H", right = "L", top = "K", bottom = "J" })[split_side] or "L" + vim.cmd("wincmd " .. move_cmd) vim.api.nvim_win_set_buf(terminal_win, terminal_bufnr) @@ -410,7 +415,8 @@ local function display_terminal_in_new_tab() -- 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")) + local jump_cmd = ({ left = "l", right = "h", top = "j", bottom = "k" })[split_side] or "h" + vim.cmd("wincmd " .. jump_cmd) return original_tab, terminal_win, had_terminal_in_original, new_tab end diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 15be75a9..239a9415 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -204,7 +204,7 @@ local function build_config(opts_override) if type(opts_override) == "table" then local validators = { split_side = function(val) - return val == "left" or val == "right" + return val == "left" or val == "right" or val == "top" or val == "bottom" end, split_width_percentage = function(val) return type(val) == "number" and val > 0 and val < 1 @@ -452,7 +452,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) for k, v in pairs(user_term_config) do if k == "split_side" then - if v == "left" or v == "right" then + if v == "left" or v == "right" or v == "top" or v == "bottom" then defaults.split_side = v else vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 1382bca2..ed20a866 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -47,6 +47,27 @@ local function is_valid() return true end +--- Create the terminal split (vertical for left/right, horizontal for top/bottom) +--- and return the new window id. split_width_percentage is reused as the size +--- fraction on the split axis (columns for vertical, lines for horizontal). +local function create_split(effective_config) + local side = effective_config.split_side + local horizontal = side == "top" or side == "bottom" + local lead = (side == "left" or side == "top") and "topleft " or "botright " + + if horizontal then + local height = math.floor(vim.o.lines * effective_config.split_width_percentage) + vim.cmd(lead .. height .. "split") + return vim.api.nvim_get_current_win() + end + + local width = math.floor(vim.o.columns * effective_config.split_width_percentage) + vim.cmd(lead .. width .. "vsplit") + local new_winid = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_height(new_winid, vim.o.lines) -- keep full-height vertical splits + return new_winid +end + local function open_terminal(cmd_string, env_table, effective_config, focus) focus = utils.normalize_focus(focus) @@ -63,19 +84,7 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) end local original_win = vim.api.nvim_get_current_win() - local width = math.floor(vim.o.columns * effective_config.split_width_percentage) - local full_height = vim.o.lines - local placement_modifier - - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " - end - - vim.cmd(placement_modifier .. width .. "vsplit") - local new_winid = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_height(new_winid, full_height) + local new_winid = create_split(effective_config) vim.api.nvim_win_call(new_winid, function() vim.cmd("enew") @@ -220,19 +229,7 @@ local function show_hidden_terminal(effective_config, focus) local original_win = vim.api.nvim_get_current_win() -- Create a new window for the existing buffer - local width = math.floor(vim.o.columns * effective_config.split_width_percentage) - local full_height = vim.o.lines - local placement_modifier - - if effective_config.split_side == "left" then - placement_modifier = "topleft " - else - placement_modifier = "botright " - end - - vim.cmd(placement_modifier .. width .. "vsplit") - local new_winid = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_height(new_winid, full_height) + local new_winid = create_split(effective_config) -- Set the existing buffer in the new window vim.api.nvim_win_set_buf(new_winid, bufnr) diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 26e0a6a0..9945a871 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -37,7 +37,7 @@ ---@alias ClaudeCodeNewFileRejectBehavior "keep_empty"|"close_window" -- Terminal split side positioning ----@alias ClaudeCodeSplitSide "left"|"right" +---@alias ClaudeCodeSplitSide "left"|"right"|"top"|"bottom" -- In-tree terminal provider names ---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external"|"none" diff --git a/tests/unit/diff_split_width_spec.lua b/tests/unit/diff_split_width_spec.lua index 973989a4..3ddc1f35 100644 --- a/tests/unit/diff_split_width_spec.lua +++ b/tests/unit/diff_split_width_spec.lua @@ -4,7 +4,7 @@ describe("Diff/idle terminal split width resolution", function() local diff = require("claudecode.diff") local function resolve(when) - return diff._resolve_split_width_percentage(when) + return diff._resolve_terminal_split_fraction(when) end it("uses split_width_percentage for both states when diff width is unset", function() diff --git a/tests/unit/native_terminal_toggle_spec.lua b/tests/unit/native_terminal_toggle_spec.lua index ddf4d700..dfa0bd32 100644 --- a/tests/unit/native_terminal_toggle_spec.lua +++ b/tests/unit/native_terminal_toggle_spec.lua @@ -107,8 +107,13 @@ describe("claudecode.terminal.native toggle behavior", function() end, }, cmd = function(command) - -- Handle vsplit and other commands - if command:match("^topleft %d+vsplit") or command:match("^botright %d+vsplit") then + -- Handle vsplit/split and other commands + if + command:match("^topleft %d+vsplit") + or command:match("^botright %d+vsplit") + or command:match("^topleft %d+split") + or command:match("^botright %d+split") + then -- Create new window local winid = mock_state.next_winid mock_state.next_winid = mock_state.next_winid + 1 @@ -499,6 +504,53 @@ describe("claudecode.terminal.native toggle behavior", function() assert.are.equal(terminal_winid, focused_winid) end) + it("should open a horizontal split for bottom split_side", function() + local cmd_string = "claude" + local env_table = { TEST = "value" } + local config = { split_side = "bottom", split_width_percentage = 0.3 } + + local captured = {} + local original_cmd = mock_vim.cmd + mock_vim.cmd = function(command) + table.insert(captured, command) + return original_cmd(command) + end + + native_provider.open(cmd_string, env_table, config) + + local saw_horizontal = false + for _, c in ipairs(captured) do + if c:match("^botright %d+split$") then + saw_horizontal = true + end + assert.is_nil(c:match("vsplit"), "bottom split_side must not use vsplit") + end + assert.is_true(saw_horizontal, "expected a botright split command") + end) + + it("should open a horizontal split for top split_side", function() + local cmd_string = "claude" + local env_table = { TEST = "value" } + local config = { split_side = "top", split_width_percentage = 0.3 } + + local captured = {} + local original_cmd = mock_vim.cmd + mock_vim.cmd = function(command) + table.insert(captured, command) + return original_cmd(command) + end + + native_provider.open(cmd_string, env_table, config) + + local saw_horizontal = false + for _, c in ipairs(captured) do + if c:match("^topleft %d+split$") then + saw_horizontal = true + end + end + assert.is_true(saw_horizontal, "expected a topleft split command") + end) + it("should hide terminal when focused and toggle called", function() local cmd_string = "claude" local env_table = { TEST = "value" } diff --git a/tests/unit/terminal_spec.lua b/tests/unit/terminal_spec.lua index 0c771cc0..561da4a5 100644 --- a/tests/unit/terminal_spec.lua +++ b/tests/unit/terminal_spec.lua @@ -398,6 +398,16 @@ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() vim.notify:was_called_with(spy.matching.string.match("Invalid value for split_side"), vim.log.levels.WARN) end) + it("should accept top and bottom split_side", function() + terminal_wrapper.setup({ split_side = "bottom" }) + terminal_wrapper.open() + assert.are.equal("bottom", mock_snacks_provider.open:get_call(1).refs[3].split_side) + + terminal_wrapper.setup({ split_side = "top" }) + terminal_wrapper.open() + assert.are.equal("top", mock_snacks_provider.open:get_call(2).refs[3].split_side) + end) + it("should ignore invalid split_width_percentage and use default", function() terminal_wrapper.setup({ split_side = "left", split_width_percentage = 2.0 }) terminal_wrapper.open()