Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 23 additions & 17 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 23 additions & 26 deletions lua/claudecode/terminal/native.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lua/claudecode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/diff_split_width_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
56 changes: 54 additions & 2 deletions tests/unit/native_terminal_toggle_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <n>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 <n>split command")
end)

it("should hide terminal when focused and toggle called", function()
local cmd_string = "claude"
local env_table = { TEST = "value" }
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/terminal_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down