Skip to content

Commit e737c52

Browse files
authored
feat: support function for external_terminal_cmd configuration (#119)
* 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 * fix: fix unit tests
1 parent 985b4b1 commit e737c52

File tree

8 files changed

+494
-17
lines changed

8 files changed

+494
-17
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: 8 additions & 4 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"
@@ -108,7 +110,9 @@ function M.validate(config)
108110
assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean")
109111
assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean")
110112
assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
111-
assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
113+
if config.diff_opts.keep_terminal_focus ~= nil then
114+
assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
115+
end
112116

113117
-- Validate env
114118
assert(type(config.env) == "table", "env must be a table")

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: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,62 @@ 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("external_terminal_cmd must be a string or function, got: " .. type(external_cmd), vim.log.levels.ERROR)
5395
return
5496
end
5597

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

lua/claudecode/types.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
-- Terminal provider-specific options
3838
---@class ClaudeCodeTerminalProviderOptions
39-
---@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s")
39+
---@field external_terminal_cmd string|fun(cmd: string, env: table): string|table|nil Command for external terminal (string template with %s or function)
4040

4141
-- @ mention queued for Claude Code
4242
---@class ClaudeCodeMention

tests/busted_setup.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ _G.expect = function(value)
5353
to_be_truthy = function()
5454
assert.is_truthy(value)
5555
end,
56+
to_match = function(pattern)
57+
assert.is_string(value)
58+
assert.is_true(
59+
string.find(value, pattern, 1, true) ~= nil,
60+
"Expected string '" .. value .. "' to match pattern '" .. pattern .. "'"
61+
)
62+
end,
5663
}
5764
end
5865

tests/unit/config_spec.lua

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe("Configuration", function()
66

77
local function setup()
88
package.loaded["claudecode.config"] = nil
9+
package.loaded["claudecode.terminal"] = nil
910

1011
config = require("claudecode.config")
1112
end
@@ -196,5 +197,113 @@ describe("Configuration", function()
196197
expect(success).to_be_false()
197198
end)
198199

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

0 commit comments

Comments
 (0)