Skip to content

feat: support function for external_terminal_cmd configuration #119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).

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

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

```lua
-- Using a string template (simple)
{
"coder/claudecode.nvim",
opts = {
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command
external_terminal_cmd = "alacritty -e %s", -- %s is replaced with claude command
},
},
},
}

-- Using a function for dynamic command generation (advanced)
{
"coder/claudecode.nvim",
opts = {
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = function(cmd, env)
-- You can build complex commands based on environment or conditions
if vim.fn.has("mac") == 1 then
return { "osascript", "-e", string.format('tell app "Terminal" to do script "%s"', cmd) }
else
return "alacritty -e " .. cmd
end
end,
},
},
},
Expand Down
12 changes: 8 additions & 4 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ function M.validate(config)

-- Validate external_terminal_cmd in provider_opts
if config.terminal.provider_opts.external_terminal_cmd then
local cmd_type = type(config.terminal.provider_opts.external_terminal_cmd)
assert(
type(config.terminal.provider_opts.external_terminal_cmd) == "string",
"terminal.provider_opts.external_terminal_cmd must be a string"
cmd_type == "string" or cmd_type == "function",
"terminal.provider_opts.external_terminal_cmd must be a string or function"
)
if config.terminal.provider_opts.external_terminal_cmd ~= "" then
-- Only validate %s placeholder for strings
if cmd_type == "string" and config.terminal.provider_opts.external_terminal_cmd ~= "" then
assert(
config.terminal.provider_opts.external_terminal_cmd:find("%%s"),
"terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command"
Expand Down Expand Up @@ -108,7 +110,9 @@ function M.validate(config)
assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean")
assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean")
assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
if config.diff_opts.keep_terminal_focus ~= nil then
assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
end

-- Validate env
assert(type(config.env) == "table", "env must be a table")
Expand Down
10 changes: 8 additions & 2 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,13 @@ local function get_provider()
-- Check availability based on our config instead of provider's internal state
local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd

local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s")
local has_external_cmd = false
if type(external_cmd) == "function" then
has_external_cmd = true
elseif type(external_cmd) == "string" and external_cmd ~= "" and external_cmd:find("%%s") then
has_external_cmd = true
end

if has_external_cmd then
return external_provider
else
Expand Down Expand Up @@ -328,7 +334,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
defaults[k] = defaults[k] or {}
for opt_k, opt_v in pairs(v) do
if opt_k == "external_terminal_cmd" then
if opt_v == nil or type(opt_v) == "string" then
if opt_v == nil or type(opt_v) == "string" or type(opt_v) == "function" then
defaults[k][opt_k] = opt_v
else
vim.notify(
Expand Down
54 changes: 46 additions & 8 deletions lua/claudecode/terminal/external.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,62 @@ function M.open(cmd_string, env_table)
-- Get external terminal command from provider_opts
local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd

if not external_cmd or external_cmd == "" then
if not external_cmd then
vim.notify(
"external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
vim.log.levels.ERROR
)
return
end

-- Replace %s in the template with the Claude command
if not external_cmd:find("%%s") then
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
local cmd_parts
local full_command

-- Handle both string and function types
if type(external_cmd) == "function" then
-- Call the function with the Claude command and env table
local result = external_cmd(cmd_string, env_table)
if not result then
vim.notify("external_terminal_cmd function returned nil or false", vim.log.levels.ERROR)
return
end

-- Result can be either a string or a table
if type(result) == "string" then
-- Parse the string into command parts
cmd_parts = vim.split(result, " ")
full_command = result
elseif type(result) == "table" then
-- Use the table directly as command parts
cmd_parts = result
full_command = table.concat(result, " ")
else
vim.notify(
"external_terminal_cmd function must return a string or table, got: " .. type(result),
vim.log.levels.ERROR
)
return
end
elseif type(external_cmd) == "string" then
if external_cmd == "" then
vim.notify("external_terminal_cmd string cannot be empty", vim.log.levels.ERROR)
return
end

-- Replace %s in the template with the Claude command
if not external_cmd:find("%%s") then
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
return
end

-- Build command by replacing %s with Claude command and splitting
full_command = string.format(external_cmd, cmd_string)
cmd_parts = vim.split(full_command, " ")
else
vim.notify("external_terminal_cmd must be a string or function, got: " .. type(external_cmd), vim.log.levels.ERROR)
return
end

-- Build command by replacing %s with Claude command and splitting
local full_command = string.format(external_cmd, cmd_string)
local cmd_parts = vim.split(full_command, " ")

-- Start the external terminal as a detached process
jobid = vim.fn.jobstart(cmd_parts, {
detach = true,
Expand Down
2 changes: 1 addition & 1 deletion lua/claudecode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

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

-- @ mention queued for Claude Code
---@class ClaudeCodeMention
Expand Down
7 changes: 7 additions & 0 deletions tests/busted_setup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ _G.expect = function(value)
to_be_truthy = function()
assert.is_truthy(value)
end,
to_match = function(pattern)
assert.is_string(value)
assert.is_true(
string.find(value, pattern, 1, true) ~= nil,
"Expected string '" .. value .. "' to match pattern '" .. pattern .. "'"
)
end,
}
end

Expand Down
109 changes: 109 additions & 0 deletions tests/unit/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe("Configuration", function()

local function setup()
package.loaded["claudecode.config"] = nil
package.loaded["claudecode.terminal"] = nil

config = require("claudecode.config")
end
Expand Down Expand Up @@ -196,5 +197,113 @@ describe("Configuration", function()
expect(success).to_be_false()
end)

it("should accept function for external_terminal_cmd", function()
local valid_config = {
port_range = { min = 10000, max = 65535 },
auto_start = true,
log_level = "info",
track_selection = true,
visual_demotion_delay_ms = 50,
connection_wait_delay = 200,
connection_timeout = 10000,
queue_timeout = 5000,
diff_opts = {
auto_close_on_accept = true,
show_diff_stats = true,
vertical_split = true,
open_in_current_tab = true,
},
env = {},
models = {
{ name = "Test Model", value = "test" },
},
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = function(cmd, env)
return "terminal " .. cmd
end,
},
},
}

local success, _ = pcall(function()
config.validate(valid_config)
end)

expect(success).to_be_true()
end)

it("should accept string for external_terminal_cmd", function()
local valid_config = {
port_range = { min = 10000, max = 65535 },
auto_start = true,
log_level = "info",
track_selection = true,
visual_demotion_delay_ms = 50,
connection_wait_delay = 200,
connection_timeout = 10000,
queue_timeout = 5000,
diff_opts = {
auto_close_on_accept = true,
show_diff_stats = true,
vertical_split = true,
open_in_current_tab = true,
},
env = {},
models = {
{ name = "Test Model", value = "test" },
},
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = "alacritty -e %s",
},
},
}

local success, _ = pcall(function()
config.validate(valid_config)
end)

expect(success).to_be_true()
end)

it("should reject invalid type for external_terminal_cmd", function()
local invalid_config = {
port_range = { min = 10000, max = 65535 },
auto_start = true,
log_level = "info",
track_selection = true,
visual_demotion_delay_ms = 50,
connection_wait_delay = 200,
connection_timeout = 10000,
queue_timeout = 5000,
diff_opts = {
auto_close_on_accept = true,
show_diff_stats = true,
vertical_split = true,
open_in_current_tab = true,
},
env = {},
models = {
{ name = "Test Model", value = "test" },
},
terminal = {
provider = "external",
provider_opts = {
external_terminal_cmd = 123, -- Invalid: number
},
},
}

local success, err = pcall(function()
config.validate(invalid_config)
end)

expect(success).to_be_false()
expect(tostring(err)).to_match("must be a string or function")
end)

teardown()
end)
Loading