Skip to content

feat: add env configuration option and fix vim.notify scheduling #21

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

Merged
merged 5 commits into from
Jul 29, 2025
Merged
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
9 changes: 8 additions & 1 deletion lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string> 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.
Expand All @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
23 changes: 10 additions & 13 deletions lua/claudecode/logger.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 6 additions & 16 deletions tests/config_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 8 additions & 16 deletions tests/unit/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down