Skip to content

Commit 6073617

Browse files
author
Gabor Kajtar
committed
feat: add tmux terminal provider
1 parent f102a0b commit 6073617

File tree

8 files changed

+534
-5
lines changed

8 files changed

+534
-5
lines changed

README.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ That's it! For more configuration options, see [Advanced Setup](#advanced-setup)
8686

8787
- `:ClaudeCode [arguments]` - Toggle the Claude Code terminal window (simple show/hide behavior)
8888
- `:ClaudeCodeFocus [arguments]` - Smart focus/toggle Claude terminal (switches to terminal if not focused, hides if focused)
89+
- `:ClaudeCodeTmux [arguments]` - Open Claude Code in a tmux pane (works regardless of terminal provider setting)
8990
- `:ClaudeCode --resume` - Resume a previous Claude conversation
9091
- `:ClaudeCode --continue` - Continue Claude conversation
9192
- `:ClaudeCodeSend` - Send current visual selection to Claude, or add files from tree explorer
@@ -365,10 +366,11 @@ For most users, the default configuration is sufficient:
365366
- **`split_side`**: Which side to open the terminal split (`"left"` or `"right"`)
366367
- **`split_width_percentage`**: Terminal width as a fraction of screen width (0.1 = 10%, 0.5 = 50%)
367368
- **`provider`**: Terminal implementation to use:
368-
- `"auto"`: Try snacks.nvim, fallback to native
369+
- `"auto"`: Try tmux (if in tmux session), then snacks.nvim, fallback to native
369370
- `"snacks"`: Force snacks.nvim (requires folke/snacks.nvim)
370371
- `"native"`: Use built-in Neovim terminal
371-
- `"external"`: Use external terminal (e.g., tmux, separate terminal window)
372+
- `"tmux"`: Use tmux panes (requires tmux session)
373+
- `"external"`: Use external terminal (e.g., separate terminal window)
372374
- **`show_native_term_exit_tip`**: Show help text for exiting native terminal
373375
- **`auto_close`**: Automatically close terminal when commands finish
374376

@@ -486,6 +488,45 @@ With this configuration:
486488
- Run `claude --ide` in your external terminal to connect
487489
- Use `:ClaudeCodeStatus` to check connection status and get guidance
488490

491+
#### Tmux Integration
492+
493+
If you work with tmux sessions, claudecode.nvim can create tmux panes automatically:
494+
495+
```lua
496+
{
497+
"coder/claudecode.nvim",
498+
keys = {
499+
{ "<leader>a", nil, desc = "AI/Claude Code" },
500+
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
501+
{ "<leader>ct", "<cmd>ClaudeCodeTmux<cr>", desc = "Claude in tmux pane" },
502+
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
503+
{
504+
"<leader>as",
505+
"<cmd>ClaudeCodeTreeAdd<cr>",
506+
desc = "Add file",
507+
ft = { "NvimTree", "neo-tree", "oil" },
508+
},
509+
-- Diff management
510+
{ "<leader>aa", "<cmd>ClaudeCodeDiffAccept<cr>", desc = "Accept diff" },
511+
{ "<leader>ad", "<cmd>ClaudeCodeDiffDeny<cr>", desc = "Deny diff" },
512+
},
513+
opts = {
514+
terminal = {
515+
provider = "tmux", -- Use tmux panes when available
516+
split_side = "right", -- Create panes to the right
517+
split_width_percentage = 0.4, -- 40% of terminal width
518+
},
519+
},
520+
}
521+
```
522+
523+
With tmux integration:
524+
525+
- **Auto-detection**: `provider = "auto"` automatically uses tmux when in tmux sessions
526+
- **Manual command**: `:ClaudeCodeTmux` creates tmux panes regardless of provider setting
527+
- **Pane control**: Supports `split_side` ("left"/"right") and `split_width_percentage`
528+
- **Session persistence**: Tmux panes survive across Neovim restarts
529+
489530
#### Custom Claude Installation
490531

491532
```lua

lua/claudecode/init.lua

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ M.state = {
8484
---@alias ClaudeCode.TerminalOpts { \
8585
--- split_side?: "left"|"right", \
8686
--- split_width_percentage?: number, \
87-
--- provider?: "auto"|"snacks"|"native"|"external", \
87+
--- provider?: "auto"|"snacks"|"native"|"external"|"tmux", \
8888
--- show_native_term_exit_tip?: boolean }
8989
---
9090
---@alias ClaudeCode.SetupOpts { \
@@ -896,6 +896,26 @@ function M._create_commands()
896896
end, {
897897
desc = "Close the Claude Code terminal window",
898898
})
899+
900+
vim.api.nvim_create_user_command("ClaudeCodeTmux", function(opts)
901+
local tmux_provider = require("claudecode.terminal.tmux")
902+
if not tmux_provider.is_available() then
903+
logger.error("command", "ClaudeCodeTmux: Not running in tmux session")
904+
return
905+
end
906+
907+
-- Use the normal terminal flow but force tmux provider by calling it directly
908+
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
909+
910+
local effective_config = { split_side = "right", split_width_percentage = 0.5 }
911+
local cmd_string, claude_env_table = terminal.get_claude_command_and_env(cmd_args)
912+
913+
tmux_provider.setup({})
914+
tmux_provider.open(cmd_string, claude_env_table, effective_config, true)
915+
end, {
916+
nargs = "*",
917+
desc = "Open Claude Code in new tmux pane (requires tmux session)",
918+
})
899919
else
900920
logger.error(
901921
"init",

lua/claudecode/terminal.lua

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ local function get_provider()
5151
local logger = require("claudecode.logger")
5252

5353
if config.provider == "auto" then
54-
-- Try snacks first, then fallback to native silently
54+
-- Try tmux first if in tmux session, then snacks, then fallback to native silently
55+
local tmux_provider = load_provider("tmux")
56+
if tmux_provider and tmux_provider.is_available() then
57+
logger.debug("terminal", "Auto-detected tmux session, using tmux provider")
58+
return tmux_provider
59+
end
60+
5561
local snacks_provider = load_provider("snacks")
5662
if snacks_provider and snacks_provider.is_available() then
5763
return snacks_provider
@@ -67,6 +73,14 @@ local function get_provider()
6773
elseif config.provider == "native" then
6874
-- noop, will use native provider as default below
6975
logger.debug("terminal", "Using native terminal provider")
76+
elseif config.provider == "tmux" then
77+
local tmux_provider = load_provider("tmux")
78+
if tmux_provider and tmux_provider.is_available() then
79+
logger.debug("terminal", "Using tmux terminal provider")
80+
return tmux_provider
81+
else
82+
logger.warn("terminal", "'tmux' provider configured, but not in tmux session. Falling back to 'native'.")
83+
end
7084
elseif config.provider == "external" then
7185
local external_provider = load_provider("external")
7286
if external_provider then
@@ -212,7 +226,7 @@ function M.setup(user_term_config, p_terminal_cmd)
212226
config[k] = v
213227
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
214228
config[k] = v
215-
elseif k == "provider" and (v == "snacks" or v == "native" or v == "external") then
229+
elseif k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "tmux") then
216230
config[k] = v
217231
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
218232
config[k] = v
@@ -300,6 +314,20 @@ function M.is_external_provider()
300314
return config.provider == "external"
301315
end
302316

317+
--- Checks if the current terminal provider is tmux.
318+
-- @return boolean True if using tmux terminal provider, false otherwise.
319+
function M.is_tmux_provider()
320+
return config.provider == "tmux"
321+
end
322+
323+
--- Gets the claude command and environment variables for external use.
324+
-- @param cmd_args string|nil Optional arguments to append to the command
325+
-- @return string cmd_string The command string
326+
-- @return table env_table The environment variables table
327+
function M.get_claude_command_and_env(cmd_args)
328+
return get_claude_command_and_env(cmd_args)
329+
end
330+
303331
--- Gets the managed terminal instance for testing purposes.
304332
-- NOTE: This function is intended for use in tests to inspect internal state.
305333
-- The underscore prefix indicates it's not part of the public API for regular use.

lua/claudecode/terminal/tmux.lua

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
--- Tmux terminal provider for Claude Code.
2+
-- This provider creates tmux panes to run Claude Code in external tmux sessions.
3+
-- @module claudecode.terminal.tmux
4+
5+
--- @type TerminalProvider
6+
local M = {}
7+
8+
local logger = require("claudecode.logger")
9+
10+
local active_pane_id = nil
11+
12+
local function is_in_tmux()
13+
return vim and vim.env and vim.env.TMUX ~= nil
14+
end
15+
16+
local function get_tmux_pane_width()
17+
local handle = io.popen("tmux display-message -p '#{window_width}'")
18+
if not handle then
19+
return 80
20+
end
21+
local result = handle:read("*a")
22+
handle:close()
23+
local cleaned = result and result:gsub("%s+", "") or ""
24+
return tonumber(cleaned) or 80
25+
end
26+
27+
local function calculate_split_size(percentage)
28+
if not percentage or percentage <= 0 or percentage >= 1 then
29+
return nil
30+
end
31+
32+
local window_width = get_tmux_pane_width()
33+
return math.floor(window_width * percentage)
34+
end
35+
36+
local function build_split_command(cmd_string, env_table, effective_config)
37+
local split_cmd = "tmux split-window"
38+
39+
if effective_config.split_side == "left" then
40+
split_cmd = split_cmd .. " -bh"
41+
else
42+
split_cmd = split_cmd .. " -h"
43+
end
44+
45+
local split_size = calculate_split_size(effective_config.split_width_percentage)
46+
if split_size then
47+
split_cmd = split_cmd .. " -l " .. split_size
48+
end
49+
50+
-- Add environment variables
51+
if env_table then
52+
for key, value in pairs(env_table) do
53+
split_cmd = split_cmd .. " -e '" .. key .. "=" .. value .. "'"
54+
end
55+
end
56+
57+
split_cmd = split_cmd .. " '" .. cmd_string .. "'"
58+
59+
return split_cmd
60+
end
61+
62+
local function get_active_pane_id()
63+
if not active_pane_id then
64+
return nil
65+
end
66+
67+
local handle = io.popen("tmux list-panes -F '#{pane_id}' | grep '" .. active_pane_id .. "'")
68+
if not handle then
69+
return nil
70+
end
71+
72+
local result = handle:read("*a")
73+
handle:close()
74+
75+
if result and result:gsub("%s+", "") == active_pane_id then
76+
return active_pane_id
77+
end
78+
79+
active_pane_id = nil
80+
return nil
81+
end
82+
83+
local function capture_new_pane_id(split_cmd)
84+
local full_cmd = split_cmd .. " \\; display-message -p '#{pane_id}'"
85+
local handle = io.popen(full_cmd)
86+
if not handle then
87+
return nil
88+
end
89+
90+
local result = handle:read("*a")
91+
handle:close()
92+
93+
local pane_id = result:gsub("%s+", ""):match("%%(%d+)")
94+
return pane_id and ("%" .. pane_id) or nil
95+
end
96+
97+
function M.setup(term_config)
98+
if not is_in_tmux() then
99+
logger.warn("terminal", "Tmux provider configured but not running in tmux session")
100+
return
101+
end
102+
103+
logger.debug("terminal", "Tmux terminal provider configured")
104+
end
105+
106+
function M.open(cmd_string, env_table, effective_config, focus)
107+
if not is_in_tmux() then
108+
logger.error("terminal", "Cannot open tmux pane - not in tmux session")
109+
return
110+
end
111+
112+
if get_active_pane_id() then
113+
logger.debug("terminal", "Claude tmux pane already exists, focusing existing pane")
114+
if focus ~= false then
115+
vim.fn.system("tmux select-pane -t " .. active_pane_id)
116+
end
117+
return
118+
end
119+
120+
local split_cmd = build_split_command(cmd_string, env_table, effective_config)
121+
logger.debug("terminal", "Opening tmux pane with command: " .. split_cmd)
122+
123+
local new_pane_id = capture_new_pane_id(split_cmd)
124+
if new_pane_id then
125+
active_pane_id = new_pane_id
126+
logger.debug("terminal", "Created tmux pane with ID: " .. active_pane_id)
127+
128+
if focus == false then
129+
vim.fn.system("tmux last-pane")
130+
end
131+
else
132+
logger.error("terminal", "Failed to create tmux pane")
133+
end
134+
end
135+
136+
function M.close()
137+
local pane_id = get_active_pane_id()
138+
if not pane_id then
139+
logger.debug("terminal", "No active Claude tmux pane to close")
140+
return
141+
end
142+
143+
vim.fn.system("tmux kill-pane -t " .. pane_id)
144+
active_pane_id = nil
145+
logger.debug("terminal", "Closed tmux pane: " .. pane_id)
146+
end
147+
148+
function M.simple_toggle(cmd_string, env_table, effective_config)
149+
local pane_id = get_active_pane_id()
150+
if pane_id then
151+
M.close()
152+
else
153+
M.open(cmd_string, env_table, effective_config, true)
154+
end
155+
end
156+
157+
function M.focus_toggle(cmd_string, env_table, effective_config)
158+
local pane_id = get_active_pane_id()
159+
if not pane_id then
160+
M.open(cmd_string, env_table, effective_config, true)
161+
return
162+
end
163+
164+
local handle = io.popen("tmux display-message -p '#{pane_active}'")
165+
if not handle then
166+
return
167+
end
168+
169+
local is_active = handle:read("*a"):gsub("%s+", "") == "1"
170+
handle:close()
171+
172+
if is_active then
173+
M.close()
174+
else
175+
vim.fn.system("tmux select-pane -t " .. pane_id)
176+
end
177+
end
178+
179+
function M.toggle(cmd_string, env_table, effective_config)
180+
M.simple_toggle(cmd_string, env_table, effective_config)
181+
end
182+
183+
function M.get_active_bufnr()
184+
return nil
185+
end
186+
187+
function M.is_available()
188+
return is_in_tmux()
189+
end
190+
191+
function M._get_terminal_for_test()
192+
return {
193+
pane_id = active_pane_id,
194+
is_in_tmux = is_in_tmux(),
195+
}
196+
end
197+
198+
return M

tests/unit/claudecode_add_command_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ describe("ClaudeCodeAdd command", function()
9393
is_external_provider = function()
9494
return false -- Default to false for existing tests
9595
end,
96+
is_tmux_provider = function()
97+
return false -- Default to false for existing tests
98+
end,
9699
}
97100
elseif mod == "claudecode.visual_commands" then
98101
return {

tests/unit/claudecode_send_command_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ describe("ClaudeCodeSend Command Range Functionality", function()
7575
is_external_provider = function()
7676
return false -- Default to false for existing tests
7777
end,
78+
is_tmux_provider = function()
79+
return false -- Default to false for existing tests
80+
end,
7881
}
7982

8083
-- Mock server

tests/unit/init_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ describe("claudecode.init", function()
301301
is_external_provider = function()
302302
return false -- Default to false for existing tests
303303
end,
304+
is_tmux_provider = function()
305+
return false -- Default to false for existing tests
306+
end,
304307
}
305308

306309
local original_require = _G.require

0 commit comments

Comments
 (0)