Skip to content

feat: ability to pick a model when launching claude #18

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 3 commits into from
Jul 28, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains.
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },
{ "<leader>am", "<cmd>ClaudeCodeSelectModel<cr>", desc = "Select Claude model" },
{ "<leader>ab", "<cmd>ClaudeCodeAdd %<cr>", desc = "Add current buffer" },
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
{
Expand Down Expand Up @@ -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 <file-path> [start-line] [end-line]` - Add specific file to Claude context with optional line range
- `:ClaudeCodeDiffAccept` - Accept diff changes
Expand Down
1 change: 1 addition & 0 deletions dev-config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ return {
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },
{ "<leader>am", "<cmd>ClaudeCodeSelectModel<cr>", desc = "Select Claude model" },

-- Context sending
{ "<leader>ab", "<cmd>ClaudeCodeAdd %<cr>", desc = "Add current buffer" },
Expand Down
14 changes: 14 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/config_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/init_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)