diff --git a/README.md b/README.md index b3c31c7..9d31163 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains. { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "am", "ClaudeCodeSelectModel", desc = "Select Claude model" }, { "ab", "ClaudeCodeAdd %", desc = "Add current buffer" }, { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, { @@ -91,6 +92,7 @@ That's it! The plugin will auto-configure everything else. - `:ClaudeCode` - Toggle the Claude Code terminal window - `:ClaudeCodeFocus` - Smart focus/toggle Claude terminal +- `:ClaudeCodeSelectModel` - Select Claude model and open terminal with optional arguments - `:ClaudeCodeSend` - Send current visual selection to Claude - `:ClaudeCodeAdd [start-line] [end-line]` - Add specific file to Claude context with optional line range - `:ClaudeCodeDiffAccept` - Accept diff changes diff --git a/dev-config.lua b/dev-config.lua index ecb3489..dfbd094 100644 --- a/dev-config.lua +++ b/dev-config.lua @@ -15,6 +15,7 @@ return { { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, + { "am", "ClaudeCodeSelectModel", desc = "Select Claude model" }, -- Context sending { "ab", "ClaudeCodeAdd %", desc = "Add current buffer" }, diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index 573fc4c..6ed49ca 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -18,6 +18,10 @@ M.defaults = { vertical_split = true, open_in_current_tab = true, -- Use current tab instead of creating new tab }, + models = { + { name = "Claude Opus 4 (Latest)", value = "opus" }, + { name = "Claude Sonnet 4 (Latest)", value = "sonnet" }, + }, } --- Validates the provided configuration table. @@ -74,6 +78,16 @@ 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 models + assert(type(config.models) == "table", "models must be a table") + assert(#config.models > 0, "models must not be empty") + + for i, model in ipairs(config.models) do + assert(type(model) == "table", "models[" .. i .. "] must be a table") + 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 f673899..b0dde7f 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -942,6 +942,43 @@ function M._create_commands() end, { desc = "Deny/reject the current diff changes", }) + + vim.api.nvim_create_user_command("ClaudeCodeSelectModel", function(opts) + local cmd_args = opts.args and opts.args ~= "" and opts.args or nil + M.open_with_model(cmd_args) + end, { + nargs = "*", + desc = "Select and open Claude terminal with chosen model and optional arguments", + }) +end + +M.open_with_model = function(additional_args) + local models = M.state.config.models + + if not models or #models == 0 then + logger.error("command", "No models configured for selection") + return + end + + vim.ui.select(models, { + prompt = "Select Claude model:", + format_item = function(item) + return item.name + end, + }, function(choice) + if not choice then + return -- User cancelled + end + + if not choice.value or type(choice.value) ~= "string" then + logger.error("command", "Invalid model value selected") + return + end + + local model_arg = "--model " .. choice.value + local final_args = additional_args and (model_arg .. " " .. additional_args) or model_arg + vim.cmd("ClaudeCode " .. final_args) + end) end --- Get version information diff --git a/tests/config_test.lua b/tests/config_test.lua index 9b4aaec..eed7b00 100644 --- a/tests/config_test.lua +++ b/tests/config_test.lua @@ -192,6 +192,10 @@ describe("Config module", function() 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() diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 0bada03..92a5428 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -22,6 +22,7 @@ describe("Configuration", function() expect(config.defaults).to_have_key("auto_start") expect(config.defaults).to_have_key("log_level") expect(config.defaults).to_have_key("track_selection") + expect(config.defaults).to_have_key("models") end) it("should validate valid configuration", function() @@ -41,6 +42,9 @@ describe("Configuration", function() vertical_split = true, open_in_current_tab = true, }, + models = { + { name = "Test Model", value = "test-model" }, + }, } local success = config.validate(valid_config) @@ -77,6 +81,54 @@ describe("Configuration", function() expect(success).to_be_false() end) + it("should reject invalid models configuration", function() + local invalid_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "debug", + track_selection = false, + visual_demotion_delay_ms = 50, + diff_opts = { + auto_close_on_accept = true, + show_diff_stats = true, + vertical_split = true, + open_in_current_tab = true, + }, + models = {}, -- Empty models array should be rejected + } + + local success, _ = pcall(function() + config.validate(invalid_config) + end) + + expect(success).to_be_false() + end) + + it("should reject models with invalid structure", function() + local invalid_config = { + port_range = { min = 10000, max = 65535 }, + auto_start = true, + log_level = "debug", + track_selection = false, + visual_demotion_delay_ms = 50, + diff_opts = { + auto_close_on_accept = true, + show_diff_stats = true, + vertical_split = true, + open_in_current_tab = true, + }, + models = { + { name = "Test Model" }, -- Missing value field + }, + } + + local success, _ = pcall(function() + config.validate(invalid_config) + end) + + expect(success).to_be_false() + end) + it("should merge user config with defaults", function() local user_config = { auto_start = true, @@ -89,6 +141,7 @@ describe("Configuration", function() expect(merged_config.log_level).to_be("debug") expect(merged_config.port_range.min).to_be(config.defaults.port_range.min) expect(merged_config.track_selection).to_be(config.defaults.track_selection) + expect(merged_config.models).to_be_table() end) teardown() diff --git a/tests/unit/init_spec.lua b/tests/unit/init_spec.lua index 2149983..a6233de 100644 --- a/tests/unit/init_spec.lua +++ b/tests/unit/init_spec.lua @@ -462,4 +462,153 @@ describe("claudecode.init", function() assert.is_nil(call_args[2], "Second argument should be nil when no args provided") end) end) + + describe("ClaudeCodeSelectModel command with arguments", function() + local mock_terminal + local mock_ui_select + local mock_vim_cmd + + before_each(function() + mock_terminal = { + toggle = spy.new(function() end), + simple_toggle = spy.new(function() end), + focus_toggle = spy.new(function() end), + open = spy.new(function() end), + close = spy.new(function() end), + } + + -- Mock vim.ui.select to automatically select the first model + mock_ui_select = spy.new(function(models, opts, callback) + -- Simulate user selecting the first model + callback(models[1]) + end) + + -- Mock vim.cmd to capture command execution + mock_vim_cmd = spy.new(function(cmd) end) + + vim.ui = vim.ui or {} + vim.ui.select = mock_ui_select + vim.cmd = mock_vim_cmd + + local original_require = _G.require + _G.require = function(mod) + if mod == "claudecode.terminal" then + return mock_terminal + elseif mod == "claudecode.server.init" then + return mock_server + elseif mod == "claudecode.lockfile" then + return mock_lockfile + elseif mod == "claudecode.selection" then + return mock_selection + else + return original_require(mod) + end + end + end) + + it("should register ClaudeCodeSelectModel command with correct configuration", function() + local claudecode = require("claudecode") + claudecode.setup({ auto_start = false }) + + local command_found = false + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCodeSelectModel" then + command_found = true + local config = call.vals[3] + assert.is_equal("*", config.nargs) + assert.is_true( + string.find(config.desc, "model.*arguments") ~= nil, + "Description should mention model and arguments" + ) + break + end + end + assert.is_true(command_found, "ClaudeCodeSelectModel command was not registered") + end) + + it("should call ClaudeCode command with model arg when no additional args provided", function() + local claudecode = require("claudecode") + claudecode.setup({ auto_start = false }) + + -- Find and call the ClaudeCodeSelectModel command handler + local command_handler + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCodeSelectModel" then + command_handler = call.vals[2] + break + end + end + + assert.is_function(command_handler, "Command handler should be a function") + + command_handler({ args = "" }) + + -- Verify vim.ui.select was called + assert(#mock_ui_select.calls > 0, "vim.ui.select was not called") + + -- Verify vim.cmd was called with the correct ClaudeCode command + assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called") + local cmd_arg = mock_vim_cmd.calls[1].vals[1] + assert.is_equal("ClaudeCode --model opus", cmd_arg, "Should call ClaudeCode with model arg") + end) + + it("should call ClaudeCode command with model and additional args", function() + local claudecode = require("claudecode") + claudecode.setup({ auto_start = false }) + + -- Find and call the ClaudeCodeSelectModel command handler + local command_handler + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCodeSelectModel" then + command_handler = call.vals[2] + break + end + end + + assert.is_function(command_handler, "Command handler should be a function") + + command_handler({ args = "--resume --verbose" }) + + -- Verify vim.ui.select was called + assert(#mock_ui_select.calls > 0, "vim.ui.select was not called") + + -- Verify vim.cmd was called with the correct ClaudeCode command including additional args + assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called") + local cmd_arg = mock_vim_cmd.calls[1].vals[1] + assert.is_equal( + "ClaudeCode --model opus --resume --verbose", + cmd_arg, + "Should call ClaudeCode with model and additional args" + ) + end) + + it("should handle user cancellation gracefully", function() + local claudecode = require("claudecode") + claudecode.setup({ auto_start = false }) + + -- Mock vim.ui.select to simulate user cancellation + vim.ui.select = spy.new(function(models, opts, callback) + callback(nil) -- User cancelled + end) + + -- Find and call the ClaudeCodeSelectModel command handler + local command_handler + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCodeSelectModel" then + command_handler = call.vals[2] + break + end + end + + assert.is_function(command_handler, "Command handler should be a function") + + command_handler({ args = "--resume" }) + + -- Verify vim.ui.select was called + assert(#vim.ui.select.calls > 0, "vim.ui.select was not called") + + -- Verify vim.cmd was NOT called due to user cancellation + assert.is_equal(0, #mock_vim_cmd.calls, "vim.cmd should not be called when user cancels") + end) + end) end)