Skip to content

Commit 5b21405

Browse files
committed
feat: support function for external_terminal_cmd configuration
Allow external_terminal_cmd to be either a string template with %s placeholder or a function that receives (cmd, env) and returns the command to execute. This enables more dynamic terminal command generation based on environment or runtime conditions. Examples: - String: "alacritty -e %s" - Function: function(cmd, env) return { "osascript", "-e", ... } end
1 parent 985b4b1 commit 5b21405

File tree

6 files changed

+488
-15
lines changed

6 files changed

+488
-15
lines changed

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
263263

264264
-- Provider-specific options
265265
provider_opts = {
266-
external_terminal_cmd = nil, -- Command template for external terminal provider (e.g., "alacritty -e %s")
266+
-- Command for external terminal provider. Can be:
267+
-- 1. String with %s placeholder: "alacritty -e %s"
268+
-- 2. Function returning command: function(cmd, env) return "alacritty -e " .. cmd end
269+
external_terminal_cmd = nil,
267270
},
268271
},
269272

@@ -452,13 +455,34 @@ For complete configuration options, see:
452455
Run Claude Code in a separate terminal application outside of Neovim:
453456

454457
```lua
458+
-- Using a string template (simple)
455459
{
456460
"coder/claudecode.nvim",
457461
opts = {
458462
terminal = {
459463
provider = "external",
460464
provider_opts = {
461-
external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command
465+
external_terminal_cmd = "alacritty -e %s", -- %s is replaced with claude command
466+
},
467+
},
468+
},
469+
}
470+
471+
-- Using a function for dynamic command generation (advanced)
472+
{
473+
"coder/claudecode.nvim",
474+
opts = {
475+
terminal = {
476+
provider = "external",
477+
provider_opts = {
478+
external_terminal_cmd = function(cmd, env)
479+
-- You can build complex commands based on environment or conditions
480+
if vim.fn.has("mac") == 1 then
481+
return { "osascript", "-e", string.format('tell app "Terminal" to do script "%s"', cmd) }
482+
else
483+
return "alacritty -e " .. cmd
484+
end
485+
end,
462486
},
463487
},
464488
},

lua/claudecode/config.lua

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,13 @@ function M.validate(config)
6161

6262
-- Validate external_terminal_cmd in provider_opts
6363
if config.terminal.provider_opts.external_terminal_cmd then
64+
local cmd_type = type(config.terminal.provider_opts.external_terminal_cmd)
6465
assert(
65-
type(config.terminal.provider_opts.external_terminal_cmd) == "string",
66-
"terminal.provider_opts.external_terminal_cmd must be a string"
66+
cmd_type == "string" or cmd_type == "function",
67+
"terminal.provider_opts.external_terminal_cmd must be a string or function"
6768
)
68-
if config.terminal.provider_opts.external_terminal_cmd ~= "" then
69+
-- Only validate %s placeholder for strings
70+
if cmd_type == "string" and config.terminal.provider_opts.external_terminal_cmd ~= "" then
6971
assert(
7072
config.terminal.provider_opts.external_terminal_cmd:find("%%s"),
7173
"terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command"

lua/claudecode/terminal.lua

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ local function get_provider()
143143
-- Check availability based on our config instead of provider's internal state
144144
local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd
145145

146-
local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s")
146+
local has_external_cmd = false
147+
if type(external_cmd) == "function" then
148+
has_external_cmd = true
149+
elseif type(external_cmd) == "string" and external_cmd ~= "" and external_cmd:find("%%s") then
150+
has_external_cmd = true
151+
end
152+
147153
if has_external_cmd then
148154
return external_provider
149155
else
@@ -328,7 +334,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
328334
defaults[k] = defaults[k] or {}
329335
for opt_k, opt_v in pairs(v) do
330336
if opt_k == "external_terminal_cmd" then
331-
if opt_v == nil or type(opt_v) == "string" then
337+
if opt_v == nil or type(opt_v) == "string" or type(opt_v) == "function" then
332338
defaults[k][opt_k] = opt_v
333339
else
334340
vim.notify(

lua/claudecode/terminal/external.lua

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,65 @@ function M.open(cmd_string, env_table)
3939
-- Get external terminal command from provider_opts
4040
local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd
4141

42-
if not external_cmd or external_cmd == "" then
42+
if not external_cmd then
4343
vim.notify(
4444
"external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
4545
vim.log.levels.ERROR
4646
)
4747
return
4848
end
4949

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)
50+
local cmd_parts
51+
local full_command
52+
53+
-- Handle both string and function types
54+
if type(external_cmd) == "function" then
55+
-- Call the function with the Claude command and env table
56+
local result = external_cmd(cmd_string, env_table)
57+
if not result then
58+
vim.notify("external_terminal_cmd function returned nil or false", vim.log.levels.ERROR)
59+
return
60+
end
61+
62+
-- Result can be either a string or a table
63+
if type(result) == "string" then
64+
-- Parse the string into command parts
65+
cmd_parts = vim.split(result, " ")
66+
full_command = result
67+
elseif type(result) == "table" then
68+
-- Use the table directly as command parts
69+
cmd_parts = result
70+
full_command = table.concat(result, " ")
71+
else
72+
vim.notify(
73+
"external_terminal_cmd function must return a string or table, got: " .. type(result),
74+
vim.log.levels.ERROR
75+
)
76+
return
77+
end
78+
elseif type(external_cmd) == "string" then
79+
if external_cmd == "" then
80+
vim.notify("external_terminal_cmd string cannot be empty", vim.log.levels.ERROR)
81+
return
82+
end
83+
84+
-- Replace %s in the template with the Claude command
85+
if not external_cmd:find("%%s") then
86+
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
87+
return
88+
end
89+
90+
-- Build command by replacing %s with Claude command and splitting
91+
full_command = string.format(external_cmd, cmd_string)
92+
cmd_parts = vim.split(full_command, " ")
93+
else
94+
vim.notify(
95+
"external_terminal_cmd must be a string or function, got: " .. type(external_cmd),
96+
vim.log.levels.ERROR
97+
)
5398
return
5499
end
55100

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-
60101
-- Start the external terminal as a detached process
61102
jobid = vim.fn.jobstart(cmd_parts, {
62103
detach = true,

tests/unit/config_spec.lua

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,5 +196,113 @@ describe("Configuration", function()
196196
expect(success).to_be_false()
197197
end)
198198

199+
it("should accept function for external_terminal_cmd", function()
200+
local valid_config = {
201+
port_range = { min = 10000, max = 65535 },
202+
auto_start = true,
203+
log_level = "info",
204+
track_selection = true,
205+
visual_demotion_delay_ms = 50,
206+
connection_wait_delay = 200,
207+
connection_timeout = 10000,
208+
queue_timeout = 5000,
209+
diff_opts = {
210+
auto_close_on_accept = true,
211+
show_diff_stats = true,
212+
vertical_split = true,
213+
open_in_current_tab = true,
214+
},
215+
env = {},
216+
models = {
217+
{ name = "Test Model", value = "test" },
218+
},
219+
terminal = {
220+
provider = "external",
221+
provider_opts = {
222+
external_terminal_cmd = function(cmd, env)
223+
return "terminal " .. cmd
224+
end,
225+
},
226+
},
227+
}
228+
229+
local success, _ = pcall(function()
230+
config.validate(valid_config)
231+
end)
232+
233+
expect(success).to_be_true()
234+
end)
235+
236+
it("should accept string for external_terminal_cmd", function()
237+
local valid_config = {
238+
port_range = { min = 10000, max = 65535 },
239+
auto_start = true,
240+
log_level = "info",
241+
track_selection = true,
242+
visual_demotion_delay_ms = 50,
243+
connection_wait_delay = 200,
244+
connection_timeout = 10000,
245+
queue_timeout = 5000,
246+
diff_opts = {
247+
auto_close_on_accept = true,
248+
show_diff_stats = true,
249+
vertical_split = true,
250+
open_in_current_tab = true,
251+
},
252+
env = {},
253+
models = {
254+
{ name = "Test Model", value = "test" },
255+
},
256+
terminal = {
257+
provider = "external",
258+
provider_opts = {
259+
external_terminal_cmd = "alacritty -e %s",
260+
},
261+
},
262+
}
263+
264+
local success, _ = pcall(function()
265+
config.validate(valid_config)
266+
end)
267+
268+
expect(success).to_be_true()
269+
end)
270+
271+
it("should reject invalid type for external_terminal_cmd", function()
272+
local invalid_config = {
273+
port_range = { min = 10000, max = 65535 },
274+
auto_start = true,
275+
log_level = "info",
276+
track_selection = true,
277+
visual_demotion_delay_ms = 50,
278+
connection_wait_delay = 200,
279+
connection_timeout = 10000,
280+
queue_timeout = 5000,
281+
diff_opts = {
282+
auto_close_on_accept = true,
283+
show_diff_stats = true,
284+
vertical_split = true,
285+
open_in_current_tab = true,
286+
},
287+
env = {},
288+
models = {
289+
{ name = "Test Model", value = "test" },
290+
},
291+
terminal = {
292+
provider = "external",
293+
provider_opts = {
294+
external_terminal_cmd = 123, -- Invalid: number
295+
},
296+
},
297+
}
298+
299+
local success, err = pcall(function()
300+
config.validate(invalid_config)
301+
end)
302+
303+
expect(success).to_be_false()
304+
expect(err).to_match("must be a string or function")
305+
end)
306+
199307
teardown()
200308
end)

0 commit comments

Comments
 (0)