Skip to content

Commit 3ea8f3b

Browse files
committed
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.
1 parent d0f9748 commit 3ea8f3b

File tree

4 files changed

+230
-7
lines changed

4 files changed

+230
-7
lines changed

CLAUDE.md

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

6868
### WebSocket Server Implementation
6969

@@ -105,6 +105,28 @@ The WebSocket server implements secure authentication using:
105105

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

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

110132
- `lua/claudecode/init.lua` - Main entry point and setup
@@ -314,13 +336,11 @@ When updating the version number for a new release, you must update **ALL** of t
314336
```
315337

316338
2. **`scripts/claude_interactive.sh`** - Multiple client version references:
317-
318339
- Line ~52: `"version": "0.2.0"` (handshake)
319340
- Line ~223: `"version": "0.2.0"` (initialize)
320341
- Line ~309: `"version": "0.2.0"` (reconnect)
321342

322343
3. **`scripts/lib_claude.sh`** - ClaudeCodeNvim version:
323-
324344
- Line ~120: `"version": "0.2.0"` (init message)
325345

326346
4. **`CHANGELOG.md`** - Add new release section with:

lua/claudecode/config.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ M.defaults = {
2323
{ name = "Claude Opus 4 (Latest)", value = "opus" },
2424
{ name = "Claude Sonnet 4 (Latest)", value = "sonnet" },
2525
},
26+
terminal = {
27+
external_terminal_cmd = nil, -- Command template for external terminal (e.g., "alacritty -e %s")
28+
},
2629
}
2730

2831
--- Validates the provided configuration table.
@@ -44,6 +47,19 @@ function M.validate(config)
4447

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

50+
-- Validate terminal config
51+
assert(type(config.terminal) == "table", "terminal must be a table")
52+
assert(
53+
config.terminal.external_terminal_cmd == nil or type(config.terminal.external_terminal_cmd) == "string",
54+
"terminal.external_terminal_cmd must be nil or a string"
55+
)
56+
if config.terminal.external_terminal_cmd and config.terminal.external_terminal_cmd ~= "" then
57+
assert(
58+
config.terminal.external_terminal_cmd:find("%%s"),
59+
"terminal.external_terminal_cmd must contain '%s' placeholder for the Claude command"
60+
)
61+
end
62+
4763
local valid_log_levels = { "trace", "debug", "info", "warn", "error" }
4864
local is_valid_log_level = false
4965
for _, level in ipairs(valid_log_levels) do

lua/claudecode/terminal.lua

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ local function get_provider()
141141
else
142142
logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.")
143143
end
144+
elseif config.provider == "external" then
145+
local external_provider = load_provider("external")
146+
if external_provider then
147+
-- Check availability based on our config instead of provider's internal state
148+
local has_external_cmd = config.external_terminal_cmd
149+
and config.external_terminal_cmd ~= ""
150+
and config.external_terminal_cmd:find("%%s")
151+
if has_external_cmd then
152+
return external_provider
153+
else
154+
logger.warn(
155+
"terminal",
156+
"'external' provider configured, but external_terminal_cmd not properly set. Falling back to 'native'."
157+
)
158+
end
159+
end
144160
elseif config.provider == "native" then
145161
-- noop, will use native provider as default below
146162
logger.debug("terminal", "Using native terminal provider")
@@ -302,12 +318,26 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
302318
end
303319

304320
for k, v in pairs(user_term_config) do
305-
if config[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above
321+
if k == "terminal_cmd" then
322+
-- terminal_cmd is handled above, skip
323+
elseif k == "external_terminal_cmd" then
324+
-- Handle external_terminal_cmd specially
325+
if v == nil or type(v) == "string" then
326+
config[k] = v
327+
else
328+
vim.notify(
329+
"claudecode.terminal.setup: Invalid value for external_terminal_cmd: " .. tostring(v),
330+
vim.log.levels.WARN
331+
)
332+
end
333+
elseif config[k] ~= nil then -- Other known config keys
306334
if k == "split_side" and (v == "left" or v == "right") then
307335
config[k] = v
308336
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
309337
config[k] = v
310-
elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then
338+
elseif
339+
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
340+
then
311341
config[k] = v
312342
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
313343
config[k] = v
@@ -318,13 +348,20 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
318348
else
319349
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
320350
end
321-
elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config
351+
else
322352
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
323353
end
324354
end
325355

326356
-- Setup providers with config
327-
get_provider().setup(config)
357+
-- Convert flat config to nested structure for external provider compatibility
358+
local provider_config = vim.deepcopy(config)
359+
if config.external_terminal_cmd then
360+
provider_config.terminal = provider_config.terminal or {}
361+
provider_config.terminal.external_terminal_cmd = config.external_terminal_cmd
362+
end
363+
364+
get_provider().setup(provider_config)
328365
end
329366

330367
--- Opens or focuses the Claude terminal.

lua/claudecode/terminal/external.lua

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

0 commit comments

Comments
 (0)