diff --git a/README.md b/README.md index 0774826..fff8d5f 100644 --- a/README.md +++ b/README.md @@ -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, }, }, @@ -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, }, }, }, diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 5676781..3886d2b 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -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" @@ -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") diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 65273db..61c9358 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -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 @@ -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( diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index 8c960e5..4521746 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -39,7 +39,7 @@ 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 @@ -47,16 +47,54 @@ function M.open(cmd_string, env_table) 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, diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 2dad779..c45b188 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -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 diff --git a/tests/busted_setup.lua b/tests/busted_setup.lua index a6d6795..244b54a 100644 --- a/tests/busted_setup.lua +++ b/tests/busted_setup.lua @@ -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 diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 3e0d63c..94886b3 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -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 @@ -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) diff --git a/tests/unit/terminal/external_spec.lua b/tests/unit/terminal/external_spec.lua new file mode 100644 index 0000000..97f9f0b --- /dev/null +++ b/tests/unit/terminal/external_spec.lua @@ -0,0 +1,289 @@ +describe("claudecode.terminal.external", function() + local external_provider + local mock_vim + local original_vim + local spy + + before_each(function() + -- Store original vim global + original_vim = vim + + -- Create spy module + spy = require("luassert.spy") + + -- Create mock vim + mock_vim = { + fn = { + jobstart = spy.new(function() + return 123 + end), -- Return valid job id + jobstop = spy.new(function() end), + }, + notify = spy.new(function() end), + log = { + levels = { + ERROR = 3, + WARN = 2, + INFO = 1, + DEBUG = 0, + }, + }, + split = function(str, sep) + local result = {} + for part in string.gmatch(str, "[^" .. sep .. "]+") do + table.insert(result, part) + end + return result + end, + schedule = function(fn) + fn() + end, + } + + -- Set global vim to mock + _G.vim = mock_vim + + -- Clear package cache and reload module + package.loaded["claudecode.terminal.external"] = nil + package.loaded["claudecode.logger"] = nil + + -- Mock logger + package.loaded["claudecode.logger"] = { + debug = spy.new(function() end), + info = spy.new(function() end), + warn = spy.new(function() end), + error = spy.new(function() end), + } + + external_provider = require("claudecode.terminal.external") + end) + + after_each(function() + -- Restore original vim + _G.vim = original_vim + end) + + describe("setup", function() + it("should store config", function() + local config = { + provider_opts = { + external_terminal_cmd = "alacritty -e %s", + }, + } + external_provider.setup(config) + -- Setup doesn't return anything, just verify it doesn't error + assert(true) + end) + end) + + describe("open with string command", function() + it("should handle string command with %s placeholder", function() + local config = { + provider_opts = { + external_terminal_cmd = "alacritty -e %s", + }, + } + external_provider.setup(config) + + external_provider.open("claude --help", { ENABLE_IDE_INTEGRATION = "true" }) + + assert.spy(mock_vim.fn.jobstart).was_called(1) + local call_args = mock_vim.fn.jobstart.calls[1].vals + assert.are.same({ "alacritty", "-e", "claude", "--help" }, call_args[1]) + assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env) + end) + + it("should error if string command missing %s placeholder", function() + local config = { + provider_opts = { + external_terminal_cmd = "alacritty -e claude", + }, + } + external_provider.setup(config) + + external_provider.open("claude --help", {}) + + assert + .spy(mock_vim.notify) + .was_called_with("external_terminal_cmd must contain '%s' placeholder for the Claude command.", mock_vim.log.levels.ERROR) + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + + it("should error if string command is empty", function() + local config = { + provider_opts = { + external_terminal_cmd = "", + }, + } + external_provider.setup(config) + + external_provider.open("claude", {}) + + assert.spy(mock_vim.notify).was_called() + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + end) + + describe("open with function command", function() + it("should handle function returning string", function() + local config = { + provider_opts = { + external_terminal_cmd = function(cmd, env) + return "kitty " .. cmd + end, + }, + } + external_provider.setup(config) + + external_provider.open("claude --help", { ENABLE_IDE_INTEGRATION = "true" }) + + assert.spy(mock_vim.fn.jobstart).was_called(1) + local call_args = mock_vim.fn.jobstart.calls[1].vals + assert.are.same({ "kitty", "claude", "--help" }, call_args[1]) + assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env) + end) + + it("should handle function returning table", function() + local config = { + provider_opts = { + external_terminal_cmd = function(cmd, env) + return { "osascript", "-e", 'tell app "Terminal" to do script "' .. cmd .. '"' } + end, + }, + } + external_provider.setup(config) + + external_provider.open("claude", { ENABLE_IDE_INTEGRATION = "true" }) + + assert.spy(mock_vim.fn.jobstart).was_called(1) + local call_args = mock_vim.fn.jobstart.calls[1].vals + assert.are.same({ "osascript", "-e", 'tell app "Terminal" to do script "claude"' }, call_args[1]) + end) + + it("should pass cmd and env to function", function() + local received_cmd, received_env + local config = { + provider_opts = { + external_terminal_cmd = function(cmd, env) + received_cmd = cmd + received_env = env + return "terminal " .. cmd + end, + }, + } + external_provider.setup(config) + + local test_env = { ENABLE_IDE_INTEGRATION = "true", CLAUDE_CODE_SSE_PORT = "12345" } + external_provider.open("claude --resume", test_env) + + assert.are.equal("claude --resume", received_cmd) + assert.are.same(test_env, received_env) + end) + + it("should error if function returns nil", function() + local config = { + provider_opts = { + external_terminal_cmd = function(cmd, env) + return nil + end, + }, + } + external_provider.setup(config) + + external_provider.open("claude", {}) + + assert + .spy(mock_vim.notify) + .was_called_with("external_terminal_cmd function returned nil or false", mock_vim.log.levels.ERROR) + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + + it("should error if function returns invalid type", function() + local config = { + provider_opts = { + external_terminal_cmd = function(cmd, env) + return 123 -- Invalid: number + end, + }, + } + external_provider.setup(config) + + external_provider.open("claude", {}) + + assert + .spy(mock_vim.notify) + .was_called_with("external_terminal_cmd function must return a string or table, got: number", mock_vim.log.levels.ERROR) + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + end) + + describe("open with invalid config", function() + it("should error if external_terminal_cmd not configured", function() + external_provider.setup({}) + + external_provider.open("claude", {}) + + assert.spy(mock_vim.notify).was_called_with( + "external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.", + mock_vim.log.levels.ERROR + ) + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + + it("should error if external_terminal_cmd is invalid type", function() + local config = { + provider_opts = { + external_terminal_cmd = 123, -- Invalid: number + }, + } + external_provider.setup(config) + + external_provider.open("claude", {}) + + assert + .spy(mock_vim.notify) + .was_called_with("external_terminal_cmd must be a string or function, got: number", mock_vim.log.levels.ERROR) + assert.spy(mock_vim.fn.jobstart).was_not_called() + end) + end) + + describe("close", function() + it("should stop job if valid", function() + local config = { + provider_opts = { + external_terminal_cmd = "alacritty -e %s", + }, + } + external_provider.setup(config) + + -- Start a terminal + external_provider.open("claude", {}) + + -- Close it + external_provider.close() + + assert.spy(mock_vim.fn.jobstop).was_called_with(123) + end) + + it("should not error if no job running", function() + external_provider.close() + assert.spy(mock_vim.fn.jobstop).was_not_called() + end) + end) + + describe("other methods", function() + it("get_active_bufnr should return nil for external terminals", function() + assert.is_nil(external_provider.get_active_bufnr()) + end) + + it("is_available should return true", function() + assert.is_true(external_provider.is_available()) + end) + + it("ensure_visible should be a no-op", function() + -- Should not error + external_provider.ensure_visible() + assert(true) + end) + end) +end)