diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 6ed49ca..88bdee8 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -6,6 +6,7 @@ M.defaults = { port_range = { min = 10000, max = 65535 }, auto_start = true, terminal_cmd = nil, + env = {}, -- Custom environment variables for Claude terminal log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection @@ -78,6 +79,13 @@ function M.validate(config) 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") + -- Validate env + assert(type(config.env) == "table", "env must be a table") + for key, value in pairs(config.env) do + assert(type(key) == "string", "env keys must be strings") + assert(type(value) == "string", "env values must be strings") + end + -- Validate models assert(type(config.models) == "table", "models must be a table") assert(#config.models > 0, "models must not be empty") @@ -87,7 +95,6 @@ function M.validate(config) assert(type(model.name) == "string" and model.name ~= "", "models[" .. i .. "].name must be a non-empty string") assert(type(model.value) == "string" and model.value ~= "", "models[" .. i .. "].value must be a non-empty string") end - return true end diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 939e51d..dcaf16f 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -36,6 +36,7 @@ M.version = { --- @field port_range {min: integer, max: integer} Port range for WebSocket server. --- @field auto_start boolean Auto-start WebSocket server on Neovim startup. --- @field terminal_cmd string|nil Custom terminal command to use when launching Claude. +--- @field env table Custom environment variables for Claude terminal. --- @field log_level "trace"|"debug"|"info"|"warn"|"error" Log level. --- @field track_selection boolean Enable sending selection updates to Claude. --- @field visual_demotion_delay_ms number Milliseconds to wait before demoting a visual selection. @@ -49,6 +50,7 @@ local default_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, terminal_cmd = nil, + env = {}, log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, -- Reduced from 200ms for better responsiveness in tree navigation @@ -306,14 +308,14 @@ function M.setup(opts) logger.setup(M.state.config) - -- Setup terminal module: always try to call setup to pass terminal_cmd, + -- Setup terminal module: always try to call setup to pass terminal_cmd and env, -- even if terminal_opts (for split_side etc.) are not provided. local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal") if terminal_setup_ok then -- Guard in case tests or user replace the module with a minimal stub without `setup`. if type(terminal_module.setup) == "function" then -- terminal_opts might be nil, which the setup function should handle gracefully. - terminal_module.setup(terminal_opts, M.state.config.terminal_cmd) + terminal_module.setup(terminal_opts, M.state.config.terminal_cmd, M.state.config.env) end else logger.error("init", "Failed to load claudecode.terminal module for setup.") diff --git a/lua/claudecode/logger.lua b/lua/claudecode/logger.lua index 1a8969d..8b0056d 100644 --- a/lua/claudecode/logger.lua +++ b/lua/claudecode/logger.lua @@ -68,22 +68,19 @@ local function log(level, component, message_parts) end end - if level == M.levels.ERROR then - vim.schedule(function() + -- Wrap all vim.notify and nvim_echo calls in vim.schedule to avoid + -- "nvim_echo must not be called in a fast event context" errors + vim.schedule(function() + if level == M.levels.ERROR then vim.notify(prefix .. " " .. message, vim.log.levels.ERROR, { title = "ClaudeCode Error" }) - end) - elseif level == M.levels.WARN then - vim.schedule(function() + elseif level == M.levels.WARN then vim.notify(prefix .. " " .. message, vim.log.levels.WARN, { title = "ClaudeCode Warning" }) - end) - else - -- For INFO, DEBUG, TRACE, use nvim_echo to avoid flooding notifications, - -- to make them appear in :messages, and wrap in vim.schedule - -- to avoid "nvim_echo must not be called in a fast event context". - vim.schedule(function() + else + -- For INFO, DEBUG, TRACE, use nvim_echo to avoid flooding notifications, + -- to make them appear in :messages vim.api.nvim_echo({ { prefix .. " " .. message, "Normal" } }, true, {}) - end) - end + end + end) end --- @param component string|nil Optional component/module name. diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index b919180..e0db5ac 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -24,6 +24,7 @@ local config = { show_native_term_exit_tip = true, terminal_cmd = nil, auto_close = true, + env = {}, -- Custom environment variables for Claude terminal snacks_win_opts = {}, } @@ -153,6 +154,11 @@ local function get_claude_command_and_env(cmd_args) env_table["CLAUDE_CODE_SSE_PORT"] = tostring(sse_port_value) end + -- Merge custom environment variables from config + for key, value in pairs(config.env) do + env_table[key] = value + end + return cmd_string, env_table end @@ -186,7 +192,8 @@ end -- @field user_term_config.show_native_term_exit_tip boolean Show tip for exiting native terminal (default: true). -- @field user_term_config.snacks_win_opts table Opts to pass to `Snacks.terminal.open()` (default: {}). -- @param p_terminal_cmd string|nil The command to run in the terminal (from main config). -function M.setup(user_term_config, p_terminal_cmd) +-- @param p_env table|nil Custom environment variables to pass to the terminal (from main config). +function M.setup(user_term_config, p_terminal_cmd, p_env) if user_term_config == nil then -- Allow nil, default to empty table silently user_term_config = {} elseif type(user_term_config) ~= "table" then -- Warn if it's not nil AND not a table @@ -204,6 +211,16 @@ function M.setup(user_term_config, p_terminal_cmd) config.terminal_cmd = nil -- Fallback to default behavior end + if p_env == nil or type(p_env) == "table" then + config.env = p_env or {} + else + vim.notify( + "claudecode.terminal.setup: Invalid env provided: " .. tostring(p_env) .. ". Using empty table.", + vim.log.levels.WARN + ) + config.env = {} + end + for k, v in pairs(user_term_config) do if config[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above if k == "split_side" and (v == "left" or v == "right") then diff --git a/tests/config_test.lua b/tests/config_test.lua index eed7b00..5457452 100644 --- a/tests/config_test.lua +++ b/tests/config_test.lua @@ -175,34 +175,24 @@ describe("Config module", function() assert(type(config.defaults.track_selection) == "boolean") end) - it("should validate valid configuration", function() - local valid_config = { - port_range = { min = 10000, max = 65535 }, - auto_start = true, + it("should apply and validate user configuration", function() + local user_config = { terminal_cmd = "toggleterm", log_level = "debug", track_selection = false, - 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, - }, models = { { name = "Claude Opus 4 (Latest)", value = "claude-opus-4-20250514" }, { name = "Claude Sonnet 4 (Latest)", value = "claude-sonnet-4-20250514" }, }, } - local success, _ = pcall(function() - return config.validate(valid_config) + local success, final_config = pcall(function() + return config.apply(user_config) end) assert(success == true) + assert(final_config.env ~= nil) -- Should inherit default empty table + assert(type(final_config.env) == "table") end) it("should merge user config with defaults", function() diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 92a5428..82f801c 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -25,30 +25,22 @@ describe("Configuration", function() expect(config.defaults).to_have_key("models") end) - it("should validate valid configuration", function() - local valid_config = { - port_range = { min = 10000, max = 65535 }, - auto_start = true, + it("should apply and validate user configuration", function() + local user_config = { terminal_cmd = "toggleterm", log_level = "debug", track_selection = false, - 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, - }, models = { { name = "Test Model", value = "test-model" }, }, } - local success = config.validate(valid_config) - expect(success).to_be_true() + local final_config = config.apply(user_config) + expect(final_config).to_be_table() + expect(final_config.terminal_cmd).to_be("toggleterm") + expect(final_config.log_level).to_be("debug") + expect(final_config.track_selection).to_be_false() + expect(final_config.env).to_be_table() -- Should inherit default empty table end) it("should reject invalid port range", function()