Skip to content

Commit 985b4b1

Browse files
feat: add external provider to run Claude in separate terminal (#102)
* feat: add provider: external to run Claude in separate terminal I think it's pretty convenient to have Claude running in a separate window, separate from Neovim window. I think this is particularly useful on tiling window managers. * Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski <[email protected]> * Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski <[email protected]> * Update lua/claudecode/terminal/external.lua Co-authored-by: Thomas Kosiewski <[email protected]> * Fix annotations * fix formatting * Update README.md * Update lua/claudecode/config.lua Co-authored-by: Thomas Kosiewski <[email protected]> --------- Co-authored-by: Thomas Kosiewski <[email protected]>
1 parent 1489c70 commit 985b4b1

File tree

6 files changed

+271
-7
lines changed

6 files changed

+271
-7
lines changed

CLAUDE.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The `fixtures/` directory contains test Neovim configurations for verifying plug
6464
3. **Lock File System** (`lua/claudecode/lockfile.lua`) - Creates discovery files for Claude CLI at `~/.claude/ide/`
6565
4. **Selection Tracking** (`lua/claudecode/selection.lua`) - Monitors text selections and sends updates to Claude
6666
5. **Diff Integration** (`lua/claudecode/diff.lua`) - Native Neovim diff support for Claude's file comparisons
67-
6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions
67+
6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions with support for internal Neovim terminals and external terminal applications
6868

6969
### WebSocket Server Implementation
7070

@@ -106,6 +106,28 @@ The WebSocket server implements secure authentication using:
106106

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

109+
### Terminal Integration Options
110+
111+
**Internal Terminals** (within Neovim):
112+
113+
- **Snacks.nvim**: `terminal/snacks.lua` - Advanced terminal with floating windows
114+
- **Native**: `terminal/native.lua` - Built-in Neovim terminal as fallback
115+
116+
**External Terminals** (separate applications):
117+
118+
- **External Provider**: `terminal/external.lua` - Launches Claude in external terminal apps
119+
120+
**Configuration Example**:
121+
122+
```lua
123+
opts = {
124+
terminal = {
125+
provider = "external", -- "auto", "snacks", "native", or "external"
126+
external_terminal_cmd = "alacritty -e %s" -- Required for external provider
127+
}
128+
}
129+
```
130+
109131
### Key File Locations
110132

111133
- `lua/claudecode/init.lua` - Main entry point and setup

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,14 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
257257
terminal = {
258258
split_side = "right", -- "left" or "right"
259259
split_width_percentage = 0.30,
260-
provider = "auto", -- "auto", "snacks", "native", or custom provider table
260+
provider = "auto", -- "auto", "snacks", "native", "external", or custom provider table
261261
auto_close = true,
262262
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
263+
264+
-- Provider-specific options
265+
provider_opts = {
266+
external_terminal_cmd = nil, -- Command template for external terminal provider (e.g., "alacritty -e %s")
267+
},
263268
},
264269

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

443-
## Custom Terminal Providers
448+
## Terminal Providers
449+
450+
### External Terminal Provider
451+
452+
Run Claude Code in a separate terminal application outside of Neovim:
453+
454+
```lua
455+
{
456+
"coder/claudecode.nvim",
457+
opts = {
458+
terminal = {
459+
provider = "external",
460+
provider_opts = {
461+
external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command
462+
},
463+
},
464+
},
465+
}
466+
```
467+
468+
### Custom Terminal Providers
444469

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

lua/claudecode/config.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ function M.validate(config)
5252

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

55+
-- Validate terminal config
56+
assert(type(config.terminal) == "table", "terminal must be a table")
57+
58+
-- Validate provider_opts if present
59+
if config.terminal.provider_opts then
60+
assert(type(config.terminal.provider_opts) == "table", "terminal.provider_opts must be a table")
61+
62+
-- Validate external_terminal_cmd in provider_opts
63+
if config.terminal.provider_opts.external_terminal_cmd then
64+
assert(
65+
type(config.terminal.provider_opts.external_terminal_cmd) == "string",
66+
"terminal.provider_opts.external_terminal_cmd must be a string"
67+
)
68+
if config.terminal.provider_opts.external_terminal_cmd ~= "" then
69+
assert(
70+
config.terminal.provider_opts.external_terminal_cmd:find("%%s"),
71+
"terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command"
72+
)
73+
end
74+
end
75+
end
76+
5577
local valid_log_levels = { "trace", "debug", "info", "warn", "error" }
5678
local is_valid_log_level = false
5779
for _, level in ipairs(valid_log_levels) do

lua/claudecode/terminal.lua

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ local defaults = {
1313
provider = "auto",
1414
show_native_term_exit_tip = true,
1515
terminal_cmd = nil,
16+
provider_opts = {
17+
external_terminal_cmd = nil,
18+
},
1619
auto_close = true,
1720
env = {},
1821
snacks_win_opts = {},
@@ -134,6 +137,22 @@ local function get_provider()
134137
else
135138
logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.")
136139
end
140+
elseif defaults.provider == "external" then
141+
local external_provider = load_provider("external")
142+
if external_provider then
143+
-- Check availability based on our config instead of provider's internal state
144+
local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd
145+
146+
local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s")
147+
if has_external_cmd then
148+
return external_provider
149+
else
150+
logger.warn(
151+
"terminal",
152+
"'external' provider configured, but provider_opts.external_terminal_cmd not properly set. Falling back to 'native'."
153+
)
154+
end
155+
end
137156
elseif defaults.provider == "native" then
138157
-- noop, will use native provider as default below
139158
logger.debug("terminal", "Using native terminal provider")
@@ -300,12 +319,39 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
300319
end
301320

302321
for k, v in pairs(user_term_config) do
303-
if defaults[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above
322+
if k == "terminal_cmd" then
323+
-- terminal_cmd is handled above, skip
324+
break
325+
elseif k == "provider_opts" then
326+
-- Handle nested provider options
327+
if type(v) == "table" then
328+
defaults[k] = defaults[k] or {}
329+
for opt_k, opt_v in pairs(v) do
330+
if opt_k == "external_terminal_cmd" then
331+
if opt_v == nil or type(opt_v) == "string" then
332+
defaults[k][opt_k] = opt_v
333+
else
334+
vim.notify(
335+
"claudecode.terminal.setup: Invalid value for provider_opts.external_terminal_cmd: " .. tostring(opt_v),
336+
vim.log.levels.WARN
337+
)
338+
end
339+
else
340+
-- For other provider options, just copy them
341+
defaults[k][opt_k] = opt_v
342+
end
343+
end
344+
else
345+
vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN)
346+
end
347+
elseif defaults[k] ~= nil then -- Other known config keys
304348
if k == "split_side" and (v == "left" or v == "right") then
305349
defaults[k] = v
306350
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
307351
defaults[k] = v
308-
elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then
352+
elseif
353+
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
354+
then
309355
defaults[k] = v
310356
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
311357
defaults[k] = v
@@ -316,7 +362,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
316362
else
317363
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
318364
end
319-
elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config
365+
else
320366
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
321367
end
322368
end

lua/claudecode/terminal/external.lua

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
--- External terminal provider for Claude Code.
2+
---Launches Claude Code in an external terminal application using a user-specified command.
3+
---@module 'claudecode.terminal.external'
4+
5+
---@type ClaudeCodeTerminalProvider
6+
local M = {}
7+
8+
local logger = require("claudecode.logger")
9+
10+
local jobid = nil
11+
---@type ClaudeCodeTerminalConfig
12+
local config
13+
14+
local function cleanup_state()
15+
jobid = nil
16+
end
17+
18+
local function is_valid()
19+
-- For external terminals, we only track if we have a running job
20+
-- We don't manage terminal windows since they're external
21+
return jobid and jobid > 0
22+
end
23+
24+
---@param term_config ClaudeCodeTerminalConfig
25+
function M.setup(term_config)
26+
config = term_config or {}
27+
end
28+
29+
---@param cmd_string string
30+
---@param env_table table
31+
function M.open(cmd_string, env_table)
32+
if is_valid() then
33+
-- External terminal is already running, we can't focus it programmatically
34+
-- Just log that it's already running
35+
logger.debug("terminal", "External Claude terminal is already running")
36+
return
37+
end
38+
39+
-- Get external terminal command from provider_opts
40+
local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd
41+
42+
if not external_cmd or external_cmd == "" then
43+
vim.notify(
44+
"external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
45+
vim.log.levels.ERROR
46+
)
47+
return
48+
end
49+
50+
-- Replace %s in the template with the Claude command
51+
if not external_cmd:find("%%s") then
52+
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
53+
return
54+
end
55+
56+
-- Build command by replacing %s with Claude command and splitting
57+
local full_command = string.format(external_cmd, cmd_string)
58+
local cmd_parts = vim.split(full_command, " ")
59+
60+
-- Start the external terminal as a detached process
61+
jobid = vim.fn.jobstart(cmd_parts, {
62+
detach = true,
63+
env = env_table,
64+
on_exit = function(job_id, exit_code, _)
65+
vim.schedule(function()
66+
if job_id == jobid then
67+
cleanup_state()
68+
end
69+
end)
70+
end,
71+
})
72+
73+
if not jobid or jobid <= 0 then
74+
vim.notify("Failed to start external terminal with command: " .. full_command, vim.log.levels.ERROR)
75+
cleanup_state()
76+
return
77+
end
78+
end
79+
80+
function M.close()
81+
if is_valid() then
82+
-- Try to stop the job gracefully
83+
vim.fn.jobstop(jobid)
84+
cleanup_state()
85+
end
86+
end
87+
88+
--- Simple toggle: always start external terminal (can't hide external terminals)
89+
---@param cmd_string string
90+
---@param env_table table
91+
---@param effective_config table
92+
function M.simple_toggle(cmd_string, env_table, effective_config)
93+
if is_valid() then
94+
-- External terminal is running, stop it
95+
M.close()
96+
else
97+
-- Start external terminal
98+
M.open(cmd_string, env_table, effective_config, true)
99+
end
100+
end
101+
102+
--- Smart focus toggle: same as simple toggle for external terminals
103+
---@param cmd_string string
104+
---@param env_table table
105+
---@param effective_config table
106+
function M.focus_toggle(cmd_string, env_table, effective_config)
107+
-- For external terminals, focus toggle behaves the same as simple toggle
108+
-- since we can't detect or control focus of external windows
109+
M.simple_toggle(cmd_string, env_table, effective_config)
110+
end
111+
112+
--- Legacy toggle function for backward compatibility
113+
---@param cmd_string string
114+
---@param env_table table
115+
---@param effective_config table
116+
function M.toggle(cmd_string, env_table, effective_config)
117+
M.simple_toggle(cmd_string, env_table, effective_config)
118+
end
119+
120+
---@return number?
121+
function M.get_active_bufnr()
122+
-- External terminals don't have associated Neovim buffers
123+
return nil
124+
end
125+
126+
--- No-op function for external terminals since we can't ensure visibility of external windows
127+
function M.ensure_visible() end
128+
129+
---@return boolean
130+
function M.is_available()
131+
-- Availability is checked by terminal.lua before this provider is selected
132+
return true
133+
end
134+
135+
---@return table?
136+
function M._get_terminal_for_test()
137+
-- For testing purposes, return job info if available
138+
if is_valid() then
139+
return { jobid = jobid }
140+
end
141+
return nil
142+
end
143+
144+
return M

lua/claudecode/types.lua

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
---@alias ClaudeCodeSplitSide "left"|"right"
3333

3434
-- In-tree terminal provider names
35-
---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"
35+
---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external"
36+
37+
-- Terminal provider-specific options
38+
---@class ClaudeCodeTerminalProviderOptions
39+
---@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s")
3640

3741
-- @ mention queued for Claude Code
3842
---@class ClaudeCodeMention
@@ -61,6 +65,7 @@
6165
---@field provider ClaudeCodeTerminalProviderName|ClaudeCodeTerminalProvider
6266
---@field show_native_term_exit_tip boolean
6367
---@field terminal_cmd string?
68+
---@field provider_opts ClaudeCodeTerminalProviderOptions?
6469
---@field auto_close boolean
6570
---@field env table<string, string>
6671
---@field snacks_win_opts table

0 commit comments

Comments
 (0)