Skip to content

feat: add external provider to run Claude in separate terminal #102

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

Merged
merged 10 commits into from
Aug 8, 2025
Merged
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
24 changes: 23 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ The `fixtures/` directory contains test Neovim configurations for verifying plug
3. **Lock File System** (`lua/claudecode/lockfile.lua`) - Creates discovery files for Claude CLI at `~/.claude/ide/`
4. **Selection Tracking** (`lua/claudecode/selection.lua`) - Monitors text selections and sends updates to Claude
5. **Diff Integration** (`lua/claudecode/diff.lua`) - Native Neovim diff support for Claude's file comparisons
6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions
6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions with support for internal Neovim terminals and external terminal applications

### WebSocket Server Implementation

Expand Down Expand Up @@ -106,6 +106,28 @@ The WebSocket server implements secure authentication using:

**Format Compliance**: All tools return MCP-compliant format: `{content: [{type: "text", text: "JSON-stringified-data"}]}`

### Terminal Integration Options

**Internal Terminals** (within Neovim):

- **Snacks.nvim**: `terminal/snacks.lua` - Advanced terminal with floating windows
- **Native**: `terminal/native.lua` - Built-in Neovim terminal as fallback

**External Terminals** (separate applications):

- **External Provider**: `terminal/external.lua` - Launches Claude in external terminal apps

**Configuration Example**:

```lua
opts = {
terminal = {
provider = "external", -- "auto", "snacks", "native", or "external"
external_terminal_cmd = "alacritty -e %s" -- Required for external provider
}
}
```

### Key File Locations

- `lua/claudecode/init.lua` - Main entry point and setup
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,14 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
terminal = {
split_side = "right", -- "left" or "right"
split_width_percentage = 0.30,
provider = "auto", -- "auto", "snacks", "native", or custom provider table
provider = "auto", -- "auto", "snacks", "native", "external", or custom provider table
auto_close = true,
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below

-- Provider-specific options
provider_opts = {
external_terminal_cmd = nil, -- Command template for external terminal provider (e.g., "alacritty -e %s")
},
},

-- Diff Integration
Expand Down Expand Up @@ -440,7 +445,27 @@ For complete configuration options, see:
- [Snacks.nvim Terminal Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md)
- [Snacks.nvim Window Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/win.md)

## Custom Terminal Providers
## Terminal Providers

### External Terminal Provider

Run Claude Code in a separate terminal application outside of Neovim:

```lua
{
"coder/claudecode.nvim",
opts = {
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command
},
},
},
}
```

### Custom Terminal Providers

You can create custom terminal providers by passing a table with the required functions instead of a string provider name:

Expand Down
22 changes: 22 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ function M.validate(config)

assert(config.terminal_cmd == nil or type(config.terminal_cmd) == "string", "terminal_cmd must be nil or a string")

-- Validate terminal config
assert(type(config.terminal) == "table", "terminal must be a table")

-- Validate provider_opts if present
if config.terminal.provider_opts then
assert(type(config.terminal.provider_opts) == "table", "terminal.provider_opts must be a table")

-- Validate external_terminal_cmd in provider_opts
if config.terminal.provider_opts.external_terminal_cmd then
assert(
type(config.terminal.provider_opts.external_terminal_cmd) == "string",
"terminal.provider_opts.external_terminal_cmd must be a string"
)
if config.terminal.provider_opts.external_terminal_cmd ~= "" then
assert(
config.terminal.provider_opts.external_terminal_cmd:find("%%s"),
"terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command"
)
end
end
end

local valid_log_levels = { "trace", "debug", "info", "warn", "error" }
local is_valid_log_level = false
for _, level in ipairs(valid_log_levels) do
Expand Down
52 changes: 49 additions & 3 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ local defaults = {
provider = "auto",
show_native_term_exit_tip = true,
terminal_cmd = nil,
provider_opts = {
external_terminal_cmd = nil,
},
auto_close = true,
env = {},
snacks_win_opts = {},
Expand Down Expand Up @@ -134,6 +137,22 @@ local function get_provider()
else
logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.")
end
elseif defaults.provider == "external" then
local external_provider = load_provider("external")
if external_provider then
-- Check availability based on our config instead of provider's internal state
local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd

local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s")
if has_external_cmd then
return external_provider
else
logger.warn(
"terminal",
"'external' provider configured, but provider_opts.external_terminal_cmd not properly set. Falling back to 'native'."
)
end
end
elseif defaults.provider == "native" then
-- noop, will use native provider as default below
logger.debug("terminal", "Using native terminal provider")
Expand Down Expand Up @@ -300,12 +319,39 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
end

for k, v in pairs(user_term_config) do
if defaults[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above
if k == "terminal_cmd" then
-- terminal_cmd is handled above, skip
break
elseif k == "provider_opts" then
-- Handle nested provider options
if type(v) == "table" then
defaults[k] = defaults[k] or {}
for opt_k, opt_v in pairs(v) do
if opt_k == "external_terminal_cmd" then
if opt_v == nil or type(opt_v) == "string" then
defaults[k][opt_k] = opt_v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for provider_opts.external_terminal_cmd: " .. tostring(opt_v),
vim.log.levels.WARN
)
end
else
-- For other provider options, just copy them
defaults[k][opt_k] = opt_v
end
end
else
vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN)
end
elseif defaults[k] ~= nil then -- Other known config keys
if k == "split_side" and (v == "left" or v == "right") then
defaults[k] = v
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
defaults[k] = v
elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then
elseif
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
then
defaults[k] = v
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
defaults[k] = v
Expand All @@ -316,7 +362,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
else
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
end
elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config
else
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
end
end
Expand Down
144 changes: 144 additions & 0 deletions lua/claudecode/terminal/external.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
--- External terminal provider for Claude Code.
---Launches Claude Code in an external terminal application using a user-specified command.
---@module 'claudecode.terminal.external'

---@type ClaudeCodeTerminalProvider
local M = {}

local logger = require("claudecode.logger")

local jobid = nil
---@type ClaudeCodeTerminalConfig
local config

local function cleanup_state()
jobid = nil
end

local function is_valid()
-- For external terminals, we only track if we have a running job
-- We don't manage terminal windows since they're external
return jobid and jobid > 0
end

---@param term_config ClaudeCodeTerminalConfig
function M.setup(term_config)
config = term_config or {}
end

---@param cmd_string string
---@param env_table table
function M.open(cmd_string, env_table)
if is_valid() then
-- External terminal is already running, we can't focus it programmatically
-- Just log that it's already running
logger.debug("terminal", "External Claude terminal is already running")
return
end

-- Get external terminal command from provider_opts
local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd

if not external_cmd or external_cmd == "" then
vim.notify(
"external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
vim.log.levels.ERROR
)
return
end

-- Replace %s in the template with the Claude command
if not external_cmd:find("%%s") then
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
return
end

-- Build command by replacing %s with Claude command and splitting
local full_command = string.format(external_cmd, cmd_string)
local cmd_parts = vim.split(full_command, " ")

-- Start the external terminal as a detached process
jobid = vim.fn.jobstart(cmd_parts, {
detach = true,
env = env_table,
on_exit = function(job_id, exit_code, _)
vim.schedule(function()
if job_id == jobid then
cleanup_state()
end
end)
end,
})

if not jobid or jobid <= 0 then
vim.notify("Failed to start external terminal with command: " .. full_command, vim.log.levels.ERROR)
cleanup_state()
return
end
end

function M.close()
if is_valid() then
-- Try to stop the job gracefully
vim.fn.jobstop(jobid)
cleanup_state()
end
end

--- Simple toggle: always start external terminal (can't hide external terminals)
---@param cmd_string string
---@param env_table table
---@param effective_config table
function M.simple_toggle(cmd_string, env_table, effective_config)
if is_valid() then
-- External terminal is running, stop it
M.close()
else
-- Start external terminal
M.open(cmd_string, env_table, effective_config, true)
end
end

--- Smart focus toggle: same as simple toggle for external terminals
---@param cmd_string string
---@param env_table table
---@param effective_config table
function M.focus_toggle(cmd_string, env_table, effective_config)
-- For external terminals, focus toggle behaves the same as simple toggle
-- since we can't detect or control focus of external windows
M.simple_toggle(cmd_string, env_table, effective_config)
end

--- Legacy toggle function for backward compatibility
---@param cmd_string string
---@param env_table table
---@param effective_config table
function M.toggle(cmd_string, env_table, effective_config)
M.simple_toggle(cmd_string, env_table, effective_config)
end

---@return number?
function M.get_active_bufnr()
-- External terminals don't have associated Neovim buffers
return nil
end

--- No-op function for external terminals since we can't ensure visibility of external windows
function M.ensure_visible() end

---@return boolean
function M.is_available()
-- Availability is checked by terminal.lua before this provider is selected
return true
end

---@return table?
function M._get_terminal_for_test()
-- For testing purposes, return job info if available
if is_valid() then
return { jobid = jobid }
end
return nil
end

return M
7 changes: 6 additions & 1 deletion lua/claudecode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@
---@alias ClaudeCodeSplitSide "left"|"right"

-- In-tree terminal provider names
---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"
---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external"

-- Terminal provider-specific options
---@class ClaudeCodeTerminalProviderOptions
---@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s")

-- @ mention queued for Claude Code
---@class ClaudeCodeMention
Expand Down Expand Up @@ -61,6 +65,7 @@
---@field provider ClaudeCodeTerminalProviderName|ClaudeCodeTerminalProvider
---@field show_native_term_exit_tip boolean
---@field terminal_cmd string?
---@field provider_opts ClaudeCodeTerminalProviderOptions?
---@field auto_close boolean
---@field env table<string, string>
---@field snacks_win_opts table
Expand Down