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 1 commit
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
26 changes: 23 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,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 @@ -105,6 +105,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 Expand Up @@ -314,13 +336,11 @@ When updating the version number for a new release, you must update **ALL** of t
```

2. **`scripts/claude_interactive.sh`** - Multiple client version references:

- Line ~52: `"version": "0.2.0"` (handshake)
- Line ~223: `"version": "0.2.0"` (initialize)
- Line ~309: `"version": "0.2.0"` (reconnect)

3. **`scripts/lib_claude.sh`** - ClaudeCodeNvim version:

- Line ~120: `"version": "0.2.0"` (init message)

4. **`CHANGELOG.md`** - Add new release section with:
Expand Down
16 changes: 16 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ M.defaults = {
{ name = "Claude Opus 4 (Latest)", value = "opus" },
{ name = "Claude Sonnet 4 (Latest)", value = "sonnet" },
},
terminal = {
external_terminal_cmd = nil, -- Command template for external terminal (e.g., "alacritty -e %s")
},
}

--- Validates the provided configuration table.
Expand All @@ -44,6 +47,19 @@ 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")
assert(
config.terminal.external_terminal_cmd == nil or type(config.terminal.external_terminal_cmd) == "string",
"terminal.external_terminal_cmd must be nil or a string"
)
if config.terminal.external_terminal_cmd and config.terminal.external_terminal_cmd ~= "" then
assert(
config.terminal.external_terminal_cmd:find("%%s"),
"terminal.external_terminal_cmd must contain '%s' placeholder for the Claude command"
)
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
45 changes: 41 additions & 4 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ local function get_provider()
else
logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.")
end
elseif config.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 has_external_cmd = config.external_terminal_cmd
and config.external_terminal_cmd ~= ""
and config.external_terminal_cmd:find("%%s")
if has_external_cmd then
return external_provider
else
logger.warn(
"terminal",
"'external' provider configured, but external_terminal_cmd not properly set. Falling back to 'native'."
)
end
end
elseif config.provider == "native" then
-- noop, will use native provider as default below
logger.debug("terminal", "Using native terminal provider")
Expand Down Expand Up @@ -302,12 +318,26 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
end

for k, v in pairs(user_term_config) do
if config[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above
if k == "terminal_cmd" then
-- terminal_cmd is handled above, skip
elseif k == "external_terminal_cmd" then
-- Handle external_terminal_cmd specially
if v == nil or type(v) == "string" then
config[k] = v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for external_terminal_cmd: " .. tostring(v),
vim.log.levels.WARN
)
end
elseif config[k] ~= nil then -- Other known config keys
if k == "split_side" and (v == "left" or v == "right") then
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" 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
config[k] = v
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
config[k] = v
Expand All @@ -318,13 +348,20 @@ 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

-- Setup providers with config
get_provider().setup(config)
-- Convert flat config to nested structure for external provider compatibility
local provider_config = vim.deepcopy(config)
if config.external_terminal_cmd then
provider_config.terminal = provider_config.terminal or {}
provider_config.terminal.external_terminal_cmd = config.external_terminal_cmd
end

get_provider().setup(provider_config)
end

--- Opens or focuses the Claude terminal.
Expand Down
150 changes: 150 additions & 0 deletions lua/claudecode/terminal/external.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
--- External terminal provider for Claude Code.
-- Launches Claude Code in an external terminal application using a user-specified command.
-- @module claudecode.terminal.external

--- @type TerminalProvider
local M = {}

local logger = require("claudecode.logger")
local utils = require("claudecode.utils")

local jobid = nil
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 table
function M.setup(term_config)
config = term_config or {}
end

--- @param cmd_string string
--- @param env_table table
--- @param effective_config table
--- @param focus boolean|nil
function M.open(cmd_string, env_table, effective_config, focus)
focus = utils.normalize_focus(focus)

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

-- Build the external command using the configured template
if
not config.terminal
or not config.terminal.external_terminal_cmd
or config.terminal.external_terminal_cmd == ""
then
vim.notify(
"terminal.external_terminal_cmd not configured. Please set terminal.external_terminal_cmd in your config.",
vim.log.levels.ERROR
)
return
end

-- Replace %s in the template with the Claude command
if not config.terminal.external_terminal_cmd:find("%%s") then
vim.notify(
"terminal.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(config.terminal.external_terminal_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|nil
function M.get_active_bufnr()
-- External terminals don't have associated Neovim buffers
return nil
end

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

--- @return table|nil
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
Loading