Skip to content

feat: external and TMUX Terminal Provider Support #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ That's it! For more configuration options, see [Advanced Setup](#advanced-setup)

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

Expand Down Expand Up @@ -449,6 +452,81 @@ For most users, the default configuration is sufficient:
}
```

#### External Terminal Configuration

If you prefer to run Claude Code in an external terminal (e.g., tmux, separate terminal window), configure the plugin to use the external provider and load on startup:

```lua
{
"coder/claudecode.nvim",
event = "VeryLazy", -- Load on startup for auto-start behavior
opts = {
terminal = {
provider = "external", -- Don't launch internal terminals
},
},
keys = {
{ "<leader>a", nil, desc = "AI/Claude Code" },
-- Add any keymaps you want (but they're not required for loading)
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
{
"<leader>as",
"<cmd>ClaudeCodeTreeAdd<cr>",
desc = "Add file",
ft = { "NvimTree", "neo-tree", "oil" },
},
-- Diff management
{ "<leader>aa", "<cmd>ClaudeCodeDiffAccept<cr>", desc = "Accept diff" },
{ "<leader>ad", "<cmd>ClaudeCodeDiffDeny<cr>", desc = "Deny diff" },
},
}
```

With this configuration:

- The MCP server starts automatically when Neovim loads
- Run `claude` in your external terminal to connect
- Use `:ClaudeCodeStatus` to check connection status and get guidance

#### Tmux Integration

If you work with tmux sessions, claudecode.nvim can create tmux panes automatically:

```lua
{
"coder/claudecode.nvim",
keys = {
{ "<leader>a", nil, desc = "AI/Claude Code" },
{ "<leader>ac", "<cmd>ClaudeCode<cr>", desc = "Toggle Claude" },
{ "<leader>ct", "<cmd>ClaudeCodeTmux<cr>", desc = "Claude in tmux pane" },
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
{
"<leader>as",
"<cmd>ClaudeCodeTreeAdd<cr>",
desc = "Add file",
ft = { "NvimTree", "neo-tree", "oil" },
},
-- Diff management
{ "<leader>aa", "<cmd>ClaudeCodeDiffAccept<cr>", desc = "Accept diff" },
{ "<leader>ad", "<cmd>ClaudeCodeDiffDeny<cr>", desc = "Deny diff" },
},
opts = {
terminal = {
provider = "tmux", -- Use tmux panes when available
split_side = "right", -- Create panes to the right
split_width_percentage = 0.4, -- 40% of terminal width
},
},
}
```

With tmux integration:

- **Auto-detection**: `provider = "auto"` automatically uses tmux when in tmux sessions
- **Manual command**: `:ClaudeCodeTmux` creates tmux panes regardless of provider setting
- **Pane control**: Supports `split_side` ("left"/"right") and `split_width_percentage`
- **Session persistence**: Tmux panes survive across Neovim restarts

#### Custom Claude Installation

```lua
Expand Down Expand Up @@ -483,6 +561,7 @@ For most users, the default configuration is sufficient:
- **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/`
- **Need debug logs?** Set `log_level = "debug"` in setup
- **Terminal issues?** Try `provider = "native"` if using snacks.nvim
- **Auto-start not working?** If using external terminal provider, ensure you're using `event = "VeryLazy"` instead of `keys = {...}` only, as lazy loading prevents auto-start from running

## License

Expand Down
62 changes: 52 additions & 10 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ M.state = {
---@alias ClaudeCode.TerminalOpts { \
--- split_side?: "left"|"right", \
--- split_width_percentage?: number, \
--- provider?: "auto"|"snacks"|"native", \
--- provider?: "auto"|"snacks"|"native"|"external"|"tmux", \
--- show_native_term_exit_tip?: boolean }
---
---@alias ClaudeCode.SetupOpts { \
Expand Down Expand Up @@ -163,9 +163,11 @@ function M._process_queued_mentions()
return
end

-- Ensure terminal is visible when processing queued mentions
-- Ensure terminal is visible when processing queued mentions (unless using external terminal)
local terminal = require("claudecode.terminal")
terminal.ensure_visible()
if not terminal.is_external_provider() then
terminal.ensure_visible()
end

local success_count = 0
local total_count = #mentions_to_send
Expand Down Expand Up @@ -256,15 +258,17 @@ function M.send_at_mention(file_path, start_line, end_line, context)

-- Check if Claude Code is connected
if M.is_claude_connected() then
-- Claude is connected, send immediately and ensure terminal is visible
-- Claude is connected, send immediately and ensure terminal is visible (unless using external terminal)
local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line)
if success then
local terminal = require("claudecode.terminal")
terminal.ensure_visible()
if not terminal.is_external_provider() then
terminal.ensure_visible()
end
end
return success, error_msg
else
-- Claude not connected, queue the mention and launch terminal
-- Claude not connected, queue the mention and optionally launch terminal
local mention_data = {
file_path = file_path,
start_line = start_line,
Expand All @@ -274,11 +278,15 @@ function M.send_at_mention(file_path, start_line, end_line, context)

queue_at_mention(mention_data)

-- Launch terminal with Claude Code
local terminal = require("claudecode.terminal")
terminal.open()

logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path)
if terminal.is_external_provider() then
-- Don't launch internal terminal - assume external Claude Code instance exists
logger.debug(context, "Queued @ mention for external Claude Code instance: " .. file_path)
else
-- Launch terminal with Claude Code
terminal.open()
logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path)
end

return true, nil
end
Expand Down Expand Up @@ -449,6 +457,20 @@ function M._create_commands()
vim.api.nvim_create_user_command("ClaudeCodeStatus", function()
if M.state.server and M.state.port then
logger.info("command", "Claude Code integration is running on port " .. tostring(M.state.port))

-- Check if using external terminal provider and provide guidance
local terminal_module_ok, terminal_module = pcall(require, "claudecode.terminal")
if terminal_module_ok and terminal_module then
if terminal_module.is_external_provider() then
local connection_count = M.state.server.get_connection_count and M.state.server.get_connection_count() or 0
if connection_count > 0 then
logger.info("command", "External Claude Code is connected (" .. connection_count .. " connection(s))")
else
logger.info("command", "MCP server ready for external Claude Code connections")
logger.info("command", "Run 'claude --ide' in your terminal to connect to this Neovim instance")
end
end
end
else
logger.info("command", "Claude Code integration is not running")
end
Expand Down Expand Up @@ -874,6 +896,26 @@ function M._create_commands()
end, {
desc = "Close the Claude Code terminal window",
})

vim.api.nvim_create_user_command("ClaudeCodeTmux", function(opts)
local tmux_provider = require("claudecode.terminal.tmux")
if not tmux_provider.is_available() then
logger.error("command", "ClaudeCodeTmux: Not running in tmux session")
return
end

-- Use the normal terminal flow but force tmux provider by calling it directly
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil

local effective_config = { split_side = "right", split_width_percentage = 0.5 }
local cmd_string, claude_env_table = terminal.get_claude_command_and_env(cmd_args)

tmux_provider.setup({})
tmux_provider.open(cmd_string, claude_env_table, effective_config, true)
end, {
nargs = "*",
desc = "Open Claude Code in new tmux pane (requires tmux session)",
})
else
logger.error(
"init",
Expand Down
46 changes: 44 additions & 2 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ local function get_provider()
local logger = require("claudecode.logger")

if config.provider == "auto" then
-- Try snacks first, then fallback to native silently
-- Try tmux first if in tmux session, then snacks, then fallback to native silently
local tmux_provider = load_provider("tmux")
if tmux_provider and tmux_provider.is_available() then
logger.debug("terminal", "Auto-detected tmux session, using tmux provider")
return tmux_provider
end

local snacks_provider = load_provider("snacks")
if snacks_provider and snacks_provider.is_available() then
return snacks_provider
Expand All @@ -67,6 +73,22 @@ local function get_provider()
elseif config.provider == "native" then
-- noop, will use native provider as default below
logger.debug("terminal", "Using native terminal provider")
elseif config.provider == "tmux" then
local tmux_provider = load_provider("tmux")
if tmux_provider and tmux_provider.is_available() then
logger.debug("terminal", "Using tmux terminal provider")
return tmux_provider
else
logger.warn("terminal", "'tmux' provider configured, but not in tmux session. Falling back to 'native'.")
end
elseif config.provider == "external" then
local external_provider = load_provider("external")
if external_provider then
logger.debug("terminal", "Using external terminal provider")
return external_provider
else
logger.error("terminal", "Failed to load external terminal provider. Falling back to 'native'.")
end
else
logger.warn("terminal", "Invalid provider configured: " .. tostring(config.provider) .. ". Defaulting to 'native'.")
end
Expand Down Expand Up @@ -204,7 +226,7 @@ function M.setup(user_term_config, p_terminal_cmd)
config[k] = v
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
config[k] = v
elseif k == "provider" and (v == "snacks" or v == "native") then
elseif k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "tmux") then
config[k] = v
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
config[k] = v
Expand Down Expand Up @@ -286,6 +308,26 @@ function M.get_active_terminal_bufnr()
return get_provider().get_active_bufnr()
end

--- Checks if the current terminal provider is external.
-- @return boolean True if using external terminal provider, false otherwise.
function M.is_external_provider()
return config.provider == "external"
end

--- Checks if the current terminal provider is tmux.
-- @return boolean True if using tmux terminal provider, false otherwise.
function M.is_tmux_provider()
return config.provider == "tmux"
end

--- Gets the claude command and environment variables for external use.
-- @param cmd_args string|nil Optional arguments to append to the command
-- @return string cmd_string The command string
-- @return table env_table The environment variables table
function M.get_claude_command_and_env(cmd_args)
return get_claude_command_and_env(cmd_args)
end

--- Gets the managed terminal instance for testing purposes.
-- NOTE: This function is intended for use in tests to inspect internal state.
-- The underscore prefix indicates it's not part of the public API for regular use.
Expand Down
81 changes: 81 additions & 0 deletions lua/claudecode/terminal/external.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
--- External terminal provider for Claude Code.
-- This provider does nothing - it assumes Claude Code is running in an external terminal.
-- @module claudecode.terminal.external

--- @type TerminalProvider
local M = {}

local logger = require("claudecode.logger")

--- Configures the external terminal provider (no-op).
-- @param term_config table The terminal configuration (ignored).
function M.setup(term_config)
logger.info(
"terminal",
"External terminal provider configured - Claude Code commands will not launch internal terminals"
)
logger.debug("terminal", "External provider setup complete - assuming external Claude Code instance will connect")
end

--- Opens the Claude terminal (no-op for external provider).
-- @param cmd_string string The command to run (ignored).
-- @param env_table table Environment variables (ignored).
-- @param effective_config table Terminal configuration (ignored).
-- @param focus boolean|nil Whether to focus the terminal (ignored).
function M.open(cmd_string, env_table, effective_config, focus)
logger.debug(
"terminal",
"External terminal provider: open() called - no action taken (assuming external Claude Code)"
)
end

--- Closes the managed Claude terminal (no-op for external provider).
function M.close()
logger.debug("terminal", "External terminal provider: close() called - no action taken")
end

--- Simple toggle: show/hide the Claude terminal (no-op for external provider).
-- @param cmd_string string The command to run (ignored).
-- @param env_table table Environment variables (ignored).
-- @param effective_config table Terminal configuration (ignored).
function M.simple_toggle(cmd_string, env_table, effective_config)
logger.debug("terminal", "External terminal provider: simple_toggle() called - no action taken")
end

--- Smart focus toggle: switches to terminal if not focused, hides if currently focused (no-op for external provider).
-- @param cmd_string string The command to run (ignored).
-- @param env_table table Environment variables (ignored).
-- @param effective_config table Terminal configuration (ignored).
function M.focus_toggle(cmd_string, env_table, effective_config)
logger.debug("terminal", "External terminal provider: focus_toggle() called - no action taken")
end

--- Toggles the Claude terminal open or closed (no-op for external provider).
-- @param cmd_string string The command to run (ignored).
-- @param env_table table Environment variables (ignored).
-- @param effective_config table Terminal configuration (ignored).
function M.toggle(cmd_string, env_table, effective_config)
logger.debug("terminal", "External terminal provider: toggle() called - no action taken")
end

--- Gets the buffer number of the currently active Claude Code terminal.
-- For external provider, this always returns nil since there's no managed terminal.
-- @return nil Always returns nil for external provider.
function M.get_active_bufnr()
return nil
end

--- Checks if the external terminal provider is available.
-- The external provider is always available.
-- @return boolean Always returns true.
function M.is_available()
return true
end

--- Gets the managed terminal instance for testing purposes (external provider has none).
-- @return nil Always returns nil for external provider.
function M._get_terminal_for_test()
return nil
end

return M
Loading