Skip to content

Commit f102a0b

Browse files
author
Gabor Kajtar
committed
feat: add external terminal provider
1 parent db91a0a commit f102a0b

File tree

8 files changed

+261
-11
lines changed

8 files changed

+261
-11
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ For most users, the default configuration is sufficient:
368368
- `"auto"`: Try snacks.nvim, fallback to native
369369
- `"snacks"`: Force snacks.nvim (requires folke/snacks.nvim)
370370
- `"native"`: Use built-in Neovim terminal
371+
- `"external"`: Use external terminal (e.g., tmux, separate terminal window)
371372
- **`show_native_term_exit_tip`**: Show help text for exiting native terminal
372373
- **`auto_close`**: Automatically close terminal when commands finish
373374

@@ -449,6 +450,42 @@ For most users, the default configuration is sufficient:
449450
}
450451
```
451452

453+
#### External Terminal Configuration
454+
455+
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:
456+
457+
```lua
458+
{
459+
"coder/claudecode.nvim",
460+
event = "VeryLazy", -- Load on startup for auto-start behavior
461+
opts = {
462+
terminal = {
463+
provider = "external", -- Don't launch internal terminals
464+
},
465+
},
466+
keys = {
467+
{ "<leader>a", nil, desc = "AI/Claude Code" },
468+
-- Add any keymaps you want (but they're not required for loading)
469+
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
470+
{
471+
"<leader>as",
472+
"<cmd>ClaudeCodeTreeAdd<cr>",
473+
desc = "Add file",
474+
ft = { "NvimTree", "neo-tree", "oil" },
475+
},
476+
-- Diff management
477+
{ "<leader>aa", "<cmd>ClaudeCodeDiffAccept<cr>", desc = "Accept diff" },
478+
{ "<leader>ad", "<cmd>ClaudeCodeDiffDeny<cr>", desc = "Deny diff" },
479+
},
480+
}
481+
```
482+
483+
With this configuration:
484+
485+
- The MCP server starts automatically when Neovim loads
486+
- Run `claude --ide` in your external terminal to connect
487+
- Use `:ClaudeCodeStatus` to check connection status and get guidance
488+
452489
#### Custom Claude Installation
453490

454491
```lua
@@ -483,6 +520,7 @@ For most users, the default configuration is sufficient:
483520
- **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/`
484521
- **Need debug logs?** Set `log_level = "debug"` in setup
485522
- **Terminal issues?** Try `provider = "native"` if using snacks.nvim
523+
- **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
486524

487525
## License
488526

lua/claudecode/init.lua

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ M.state = {
8484
---@alias ClaudeCode.TerminalOpts { \
8585
--- split_side?: "left"|"right", \
8686
--- split_width_percentage?: number, \
87-
--- provider?: "auto"|"snacks"|"native", \
87+
--- provider?: "auto"|"snacks"|"native"|"external", \
8888
--- show_native_term_exit_tip?: boolean }
8989
---
9090
---@alias ClaudeCode.SetupOpts { \
@@ -163,9 +163,11 @@ function M._process_queued_mentions()
163163
return
164164
end
165165

166-
-- Ensure terminal is visible when processing queued mentions
166+
-- Ensure terminal is visible when processing queued mentions (unless using external terminal)
167167
local terminal = require("claudecode.terminal")
168-
terminal.ensure_visible()
168+
if not terminal.is_external_provider() then
169+
terminal.ensure_visible()
170+
end
169171

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

257259
-- Check if Claude Code is connected
258260
if M.is_claude_connected() then
259-
-- Claude is connected, send immediately and ensure terminal is visible
261+
-- Claude is connected, send immediately and ensure terminal is visible (unless using external terminal)
260262
local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line)
261263
if success then
262264
local terminal = require("claudecode.terminal")
263-
terminal.ensure_visible()
265+
if not terminal.is_external_provider() then
266+
terminal.ensure_visible()
267+
end
264268
end
265269
return success, error_msg
266270
else
267-
-- Claude not connected, queue the mention and launch terminal
271+
-- Claude not connected, queue the mention and optionally launch terminal
268272
local mention_data = {
269273
file_path = file_path,
270274
start_line = start_line,
@@ -274,11 +278,15 @@ function M.send_at_mention(file_path, start_line, end_line, context)
274278

275279
queue_at_mention(mention_data)
276280

277-
-- Launch terminal with Claude Code
278281
local terminal = require("claudecode.terminal")
279-
terminal.open()
280-
281-
logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path)
282+
if terminal.is_external_provider() then
283+
-- Don't launch internal terminal - assume external Claude Code instance exists
284+
logger.debug(context, "Queued @ mention for external Claude Code instance: " .. file_path)
285+
else
286+
-- Launch terminal with Claude Code
287+
terminal.open()
288+
logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path)
289+
end
282290

283291
return true, nil
284292
end
@@ -449,6 +457,20 @@ function M._create_commands()
449457
vim.api.nvim_create_user_command("ClaudeCodeStatus", function()
450458
if M.state.server and M.state.port then
451459
logger.info("command", "Claude Code integration is running on port " .. tostring(M.state.port))
460+
461+
-- Check if using external terminal provider and provide guidance
462+
local terminal_module_ok, terminal_module = pcall(require, "claudecode.terminal")
463+
if terminal_module_ok and terminal_module then
464+
if terminal_module.is_external_provider() then
465+
local connection_count = M.state.server.get_connection_count and M.state.server.get_connection_count() or 0
466+
if connection_count > 0 then
467+
logger.info("command", "External Claude Code is connected (" .. connection_count .. " connection(s))")
468+
else
469+
logger.info("command", "MCP server ready for external Claude Code connections")
470+
logger.info("command", "Run 'claude --ide' in your terminal to connect to this Neovim instance")
471+
end
472+
end
473+
end
452474
else
453475
logger.info("command", "Claude Code integration is not running")
454476
end

lua/claudecode/terminal.lua

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ local function get_provider()
6767
elseif config.provider == "native" then
6868
-- noop, will use native provider as default below
6969
logger.debug("terminal", "Using native terminal provider")
70+
elseif config.provider == "external" then
71+
local external_provider = load_provider("external")
72+
if external_provider then
73+
logger.debug("terminal", "Using external terminal provider")
74+
return external_provider
75+
else
76+
logger.error("terminal", "Failed to load external terminal provider. Falling back to 'native'.")
77+
end
7078
else
7179
logger.warn("terminal", "Invalid provider configured: " .. tostring(config.provider) .. ". Defaulting to 'native'.")
7280
end
@@ -204,7 +212,7 @@ function M.setup(user_term_config, p_terminal_cmd)
204212
config[k] = v
205213
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
206214
config[k] = v
207-
elseif k == "provider" and (v == "snacks" or v == "native") then
215+
elseif k == "provider" and (v == "snacks" or v == "native" or v == "external") then
208216
config[k] = v
209217
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
210218
config[k] = v
@@ -286,6 +294,12 @@ function M.get_active_terminal_bufnr()
286294
return get_provider().get_active_bufnr()
287295
end
288296

297+
--- Checks if the current terminal provider is external.
298+
-- @return boolean True if using external terminal provider, false otherwise.
299+
function M.is_external_provider()
300+
return config.provider == "external"
301+
end
302+
289303
--- Gets the managed terminal instance for testing purposes.
290304
-- NOTE: This function is intended for use in tests to inspect internal state.
291305
-- The underscore prefix indicates it's not part of the public API for regular use.

lua/claudecode/terminal/external.lua

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
--- External terminal provider for Claude Code.
2+
-- This provider does nothing - it assumes Claude Code is running in an external terminal.
3+
-- @module claudecode.terminal.external
4+
5+
--- @type TerminalProvider
6+
local M = {}
7+
8+
local logger = require("claudecode.logger")
9+
10+
--- Configures the external terminal provider (no-op).
11+
-- @param term_config table The terminal configuration (ignored).
12+
function M.setup(term_config)
13+
logger.info(
14+
"terminal",
15+
"External terminal provider configured - Claude Code commands will not launch internal terminals"
16+
)
17+
logger.debug("terminal", "External provider setup complete - assuming external Claude Code instance will connect")
18+
end
19+
20+
--- Opens the Claude terminal (no-op for external provider).
21+
-- @param cmd_string string The command to run (ignored).
22+
-- @param env_table table Environment variables (ignored).
23+
-- @param effective_config table Terminal configuration (ignored).
24+
-- @param focus boolean|nil Whether to focus the terminal (ignored).
25+
function M.open(cmd_string, env_table, effective_config, focus)
26+
logger.debug(
27+
"terminal",
28+
"External terminal provider: open() called - no action taken (assuming external Claude Code)"
29+
)
30+
end
31+
32+
--- Closes the managed Claude terminal (no-op for external provider).
33+
function M.close()
34+
logger.debug("terminal", "External terminal provider: close() called - no action taken")
35+
end
36+
37+
--- Simple toggle: show/hide the Claude terminal (no-op for external provider).
38+
-- @param cmd_string string The command to run (ignored).
39+
-- @param env_table table Environment variables (ignored).
40+
-- @param effective_config table Terminal configuration (ignored).
41+
function M.simple_toggle(cmd_string, env_table, effective_config)
42+
logger.debug("terminal", "External terminal provider: simple_toggle() called - no action taken")
43+
end
44+
45+
--- Smart focus toggle: switches to terminal if not focused, hides if currently focused (no-op for external provider).
46+
-- @param cmd_string string The command to run (ignored).
47+
-- @param env_table table Environment variables (ignored).
48+
-- @param effective_config table Terminal configuration (ignored).
49+
function M.focus_toggle(cmd_string, env_table, effective_config)
50+
logger.debug("terminal", "External terminal provider: focus_toggle() called - no action taken")
51+
end
52+
53+
--- Toggles the Claude terminal open or closed (no-op for external provider).
54+
-- @param cmd_string string The command to run (ignored).
55+
-- @param env_table table Environment variables (ignored).
56+
-- @param effective_config table Terminal configuration (ignored).
57+
function M.toggle(cmd_string, env_table, effective_config)
58+
logger.debug("terminal", "External terminal provider: toggle() called - no action taken")
59+
end
60+
61+
--- Gets the buffer number of the currently active Claude Code terminal.
62+
-- For external provider, this always returns nil since there's no managed terminal.
63+
-- @return nil Always returns nil for external provider.
64+
function M.get_active_bufnr()
65+
return nil
66+
end
67+
68+
--- Checks if the external terminal provider is available.
69+
-- The external provider is always available.
70+
-- @return boolean Always returns true.
71+
function M.is_available()
72+
return true
73+
end
74+
75+
--- Gets the managed terminal instance for testing purposes (external provider has none).
76+
-- @return nil Always returns nil for external provider.
77+
function M._get_terminal_for_test()
78+
return nil
79+
end
80+
81+
return M

tests/unit/claudecode_add_command_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ describe("ClaudeCodeAdd command", function()
9090
return 1
9191
end,
9292
simple_toggle = spy.new(function() end),
93+
is_external_provider = function()
94+
return false -- Default to false for existing tests
95+
end,
9396
}
9497
elseif mod == "claudecode.visual_commands" then
9598
return {

tests/unit/claudecode_send_command_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ describe("ClaudeCodeSend Command Range Functionality", function()
7272
mock_terminal = {
7373
open = spy.new(function() end),
7474
ensure_visible = spy.new(function() end),
75+
is_external_provider = function()
76+
return false -- Default to false for existing tests
77+
end,
7578
}
7679

7780
-- Mock server
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
-- luacheck: globals expect
2+
require("tests.busted_setup")
3+
4+
describe("External Terminal Provider", function()
5+
local external_provider
6+
local mock_vim
7+
local logger_debug_spy
8+
9+
local function setup_mocks()
10+
-- Mock vim global
11+
mock_vim = {
12+
notify = spy.new(function() end),
13+
log = { levels = { WARN = 2, ERROR = 1, INFO = 3, DEBUG = 4 } },
14+
}
15+
_G.vim = mock_vim
16+
17+
-- Mock logger with spy
18+
logger_debug_spy = spy.new(function() end)
19+
package.loaded["claudecode.logger"] = {
20+
debug = logger_debug_spy,
21+
warn = function() end,
22+
error = function() end,
23+
info = function() end,
24+
}
25+
end
26+
27+
before_each(function()
28+
-- Clear module cache
29+
package.loaded["claudecode.terminal.external"] = nil
30+
31+
setup_mocks()
32+
external_provider = require("claudecode.terminal.external")
33+
end)
34+
35+
describe("basic functionality", function()
36+
it("should be available", function()
37+
expect(external_provider.is_available()).to_be_true()
38+
end)
39+
40+
it("should return nil for active buffer", function()
41+
expect(external_provider.get_active_bufnr()).to_be_nil()
42+
end)
43+
44+
it("should return nil for test terminal", function()
45+
expect(external_provider._get_terminal_for_test()).to_be_nil()
46+
end)
47+
end)
48+
49+
describe("no-op functions", function()
50+
it("should do nothing on setup", function()
51+
external_provider.setup({ some_config = true })
52+
-- Should not error and should log debug message
53+
assert.spy(logger_debug_spy).was_called()
54+
end)
55+
56+
it("should do nothing on open", function()
57+
external_provider.open("claude --ide", { ENV = "test" }, { split_side = "right" }, true)
58+
-- Should not error and should log debug message
59+
assert.spy(logger_debug_spy).was_called()
60+
end)
61+
62+
it("should do nothing on close", function()
63+
external_provider.close()
64+
-- Should not error and should log debug message
65+
assert.spy(logger_debug_spy).was_called()
66+
end)
67+
68+
it("should do nothing on simple_toggle", function()
69+
external_provider.simple_toggle("claude", {}, {})
70+
-- Should not error and should log debug message
71+
assert.spy(logger_debug_spy).was_called()
72+
end)
73+
74+
it("should do nothing on focus_toggle", function()
75+
external_provider.focus_toggle("claude", {}, {})
76+
-- Should not error and should log debug message
77+
assert.spy(logger_debug_spy).was_called()
78+
end)
79+
80+
it("should do nothing on toggle", function()
81+
external_provider.toggle("claude", {}, {})
82+
-- Should not error and should log debug message
83+
assert.spy(logger_debug_spy).was_called()
84+
end)
85+
end)
86+
end)

tests/unit/init_spec.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ describe("claudecode.init", function()
298298
close = spy.new(function() end),
299299
setup = spy.new(function() end),
300300
ensure_visible = spy.new(function() end),
301+
is_external_provider = function()
302+
return false -- Default to false for existing tests
303+
end,
301304
}
302305

303306
local original_require = _G.require

0 commit comments

Comments
 (0)