Skip to content

Commit fa8d64f

Browse files
feat: ability to pick a model when launching claude (#18)
* feat: ability to pick a model when launching claude * feat: make model configurable * feat: add ClaudeCodeSelectModel command with argument support Change-Id: Ie982b04dc197c1abedc2f99e6d7a84974c9ee6b2 Signed-off-by: Thomas Kosiewski <[email protected]> --------- Signed-off-by: Thomas Kosiewski <[email protected]> Co-authored-by: Thomas Kosiewski <[email protected]>
1 parent 456b686 commit fa8d64f

File tree

7 files changed

+260
-0
lines changed

7 files changed

+260
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ When Anthropic released Claude Code, they only supported VS Code and JetBrains.
3838
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
3939
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
4040
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },
41+
{ "<leader>am", "<cmd>ClaudeCodeSelectModel<cr>", desc = "Select Claude model" },
4142
{ "<leader>ab", "<cmd>ClaudeCodeAdd %<cr>", desc = "Add current buffer" },
4243
{ "<leader>as", "<cmd>ClaudeCodeSend<cr>", mode = "v", desc = "Send to Claude" },
4344
{
@@ -91,6 +92,7 @@ That's it! The plugin will auto-configure everything else.
9192

9293
- `:ClaudeCode` - Toggle the Claude Code terminal window
9394
- `:ClaudeCodeFocus` - Smart focus/toggle Claude terminal
95+
- `:ClaudeCodeSelectModel` - Select Claude model and open terminal with optional arguments
9496
- `:ClaudeCodeSend` - Send current visual selection to Claude
9597
- `:ClaudeCodeAdd <file-path> [start-line] [end-line]` - Add specific file to Claude context with optional line range
9698
- `:ClaudeCodeDiffAccept` - Accept diff changes

dev-config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ return {
1515
{ "<leader>af", "<cmd>ClaudeCodeFocus<cr>", desc = "Focus Claude" },
1616
{ "<leader>ar", "<cmd>ClaudeCode --resume<cr>", desc = "Resume Claude" },
1717
{ "<leader>aC", "<cmd>ClaudeCode --continue<cr>", desc = "Continue Claude" },
18+
{ "<leader>am", "<cmd>ClaudeCodeSelectModel<cr>", desc = "Select Claude model" },
1819

1920
-- Context sending
2021
{ "<leader>ab", "<cmd>ClaudeCodeAdd %<cr>", desc = "Add current buffer" },

lua/claudecode/config.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ M.defaults = {
1818
vertical_split = true,
1919
open_in_current_tab = true, -- Use current tab instead of creating new tab
2020
},
21+
models = {
22+
{ name = "Claude Opus 4 (Latest)", value = "opus" },
23+
{ name = "Claude Sonnet 4 (Latest)", value = "sonnet" },
24+
},
2125
}
2226

2327
--- Validates the provided configuration table.
@@ -74,6 +78,16 @@ function M.validate(config)
7478
assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean")
7579
assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
7680

81+
-- Validate models
82+
assert(type(config.models) == "table", "models must be a table")
83+
assert(#config.models > 0, "models must not be empty")
84+
85+
for i, model in ipairs(config.models) do
86+
assert(type(model) == "table", "models[" .. i .. "] must be a table")
87+
assert(type(model.name) == "string" and model.name ~= "", "models[" .. i .. "].name must be a non-empty string")
88+
assert(type(model.value) == "string" and model.value ~= "", "models[" .. i .. "].value must be a non-empty string")
89+
end
90+
7791
return true
7892
end
7993

lua/claudecode/init.lua

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,43 @@ function M._create_commands()
943943
end, {
944944
desc = "Deny/reject the current diff changes",
945945
})
946+
947+
vim.api.nvim_create_user_command("ClaudeCodeSelectModel", function(opts)
948+
local cmd_args = opts.args and opts.args ~= "" and opts.args or nil
949+
M.open_with_model(cmd_args)
950+
end, {
951+
nargs = "*",
952+
desc = "Select and open Claude terminal with chosen model and optional arguments",
953+
})
954+
end
955+
956+
M.open_with_model = function(additional_args)
957+
local models = M.state.config.models
958+
959+
if not models or #models == 0 then
960+
logger.error("command", "No models configured for selection")
961+
return
962+
end
963+
964+
vim.ui.select(models, {
965+
prompt = "Select Claude model:",
966+
format_item = function(item)
967+
return item.name
968+
end,
969+
}, function(choice)
970+
if not choice then
971+
return -- User cancelled
972+
end
973+
974+
if not choice.value or type(choice.value) ~= "string" then
975+
logger.error("command", "Invalid model value selected")
976+
return
977+
end
978+
979+
local model_arg = "--model " .. choice.value
980+
local final_args = additional_args and (model_arg .. " " .. additional_args) or model_arg
981+
vim.cmd("ClaudeCode " .. final_args)
982+
end)
946983
end
947984

948985
--- Get version information

tests/config_test.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ describe("Config module", function()
192192
vertical_split = true,
193193
open_in_current_tab = true,
194194
},
195+
models = {
196+
{ name = "Claude Opus 4 (Latest)", value = "claude-opus-4-20250514" },
197+
{ name = "Claude Sonnet 4 (Latest)", value = "claude-sonnet-4-20250514" },
198+
},
195199
}
196200

197201
local success, _ = pcall(function()

tests/unit/config_spec.lua

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe("Configuration", function()
2222
expect(config.defaults).to_have_key("auto_start")
2323
expect(config.defaults).to_have_key("log_level")
2424
expect(config.defaults).to_have_key("track_selection")
25+
expect(config.defaults).to_have_key("models")
2526
end)
2627

2728
it("should validate valid configuration", function()
@@ -41,6 +42,9 @@ describe("Configuration", function()
4142
vertical_split = true,
4243
open_in_current_tab = true,
4344
},
45+
models = {
46+
{ name = "Test Model", value = "test-model" },
47+
},
4448
}
4549

4650
local success = config.validate(valid_config)
@@ -77,6 +81,54 @@ describe("Configuration", function()
7781
expect(success).to_be_false()
7882
end)
7983

84+
it("should reject invalid models configuration", function()
85+
local invalid_config = {
86+
port_range = { min = 10000, max = 65535 },
87+
auto_start = true,
88+
log_level = "debug",
89+
track_selection = false,
90+
visual_demotion_delay_ms = 50,
91+
diff_opts = {
92+
auto_close_on_accept = true,
93+
show_diff_stats = true,
94+
vertical_split = true,
95+
open_in_current_tab = true,
96+
},
97+
models = {}, -- Empty models array should be rejected
98+
}
99+
100+
local success, _ = pcall(function()
101+
config.validate(invalid_config)
102+
end)
103+
104+
expect(success).to_be_false()
105+
end)
106+
107+
it("should reject models with invalid structure", function()
108+
local invalid_config = {
109+
port_range = { min = 10000, max = 65535 },
110+
auto_start = true,
111+
log_level = "debug",
112+
track_selection = false,
113+
visual_demotion_delay_ms = 50,
114+
diff_opts = {
115+
auto_close_on_accept = true,
116+
show_diff_stats = true,
117+
vertical_split = true,
118+
open_in_current_tab = true,
119+
},
120+
models = {
121+
{ name = "Test Model" }, -- Missing value field
122+
},
123+
}
124+
125+
local success, _ = pcall(function()
126+
config.validate(invalid_config)
127+
end)
128+
129+
expect(success).to_be_false()
130+
end)
131+
80132
it("should merge user config with defaults", function()
81133
local user_config = {
82134
auto_start = true,
@@ -89,6 +141,7 @@ describe("Configuration", function()
89141
expect(merged_config.log_level).to_be("debug")
90142
expect(merged_config.port_range.min).to_be(config.defaults.port_range.min)
91143
expect(merged_config.track_selection).to_be(config.defaults.track_selection)
144+
expect(merged_config.models).to_be_table()
92145
end)
93146

94147
teardown()

tests/unit/init_spec.lua

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,4 +462,153 @@ describe("claudecode.init", function()
462462
assert.is_nil(call_args[2], "Second argument should be nil when no args provided")
463463
end)
464464
end)
465+
466+
describe("ClaudeCodeSelectModel command with arguments", function()
467+
local mock_terminal
468+
local mock_ui_select
469+
local mock_vim_cmd
470+
471+
before_each(function()
472+
mock_terminal = {
473+
toggle = spy.new(function() end),
474+
simple_toggle = spy.new(function() end),
475+
focus_toggle = spy.new(function() end),
476+
open = spy.new(function() end),
477+
close = spy.new(function() end),
478+
}
479+
480+
-- Mock vim.ui.select to automatically select the first model
481+
mock_ui_select = spy.new(function(models, opts, callback)
482+
-- Simulate user selecting the first model
483+
callback(models[1])
484+
end)
485+
486+
-- Mock vim.cmd to capture command execution
487+
mock_vim_cmd = spy.new(function(cmd) end)
488+
489+
vim.ui = vim.ui or {}
490+
vim.ui.select = mock_ui_select
491+
vim.cmd = mock_vim_cmd
492+
493+
local original_require = _G.require
494+
_G.require = function(mod)
495+
if mod == "claudecode.terminal" then
496+
return mock_terminal
497+
elseif mod == "claudecode.server.init" then
498+
return mock_server
499+
elseif mod == "claudecode.lockfile" then
500+
return mock_lockfile
501+
elseif mod == "claudecode.selection" then
502+
return mock_selection
503+
else
504+
return original_require(mod)
505+
end
506+
end
507+
end)
508+
509+
it("should register ClaudeCodeSelectModel command with correct configuration", function()
510+
local claudecode = require("claudecode")
511+
claudecode.setup({ auto_start = false })
512+
513+
local command_found = false
514+
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
515+
if call.vals[1] == "ClaudeCodeSelectModel" then
516+
command_found = true
517+
local config = call.vals[3]
518+
assert.is_equal("*", config.nargs)
519+
assert.is_true(
520+
string.find(config.desc, "model.*arguments") ~= nil,
521+
"Description should mention model and arguments"
522+
)
523+
break
524+
end
525+
end
526+
assert.is_true(command_found, "ClaudeCodeSelectModel command was not registered")
527+
end)
528+
529+
it("should call ClaudeCode command with model arg when no additional args provided", function()
530+
local claudecode = require("claudecode")
531+
claudecode.setup({ auto_start = false })
532+
533+
-- Find and call the ClaudeCodeSelectModel command handler
534+
local command_handler
535+
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
536+
if call.vals[1] == "ClaudeCodeSelectModel" then
537+
command_handler = call.vals[2]
538+
break
539+
end
540+
end
541+
542+
assert.is_function(command_handler, "Command handler should be a function")
543+
544+
command_handler({ args = "" })
545+
546+
-- Verify vim.ui.select was called
547+
assert(#mock_ui_select.calls > 0, "vim.ui.select was not called")
548+
549+
-- Verify vim.cmd was called with the correct ClaudeCode command
550+
assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called")
551+
local cmd_arg = mock_vim_cmd.calls[1].vals[1]
552+
assert.is_equal("ClaudeCode --model opus", cmd_arg, "Should call ClaudeCode with model arg")
553+
end)
554+
555+
it("should call ClaudeCode command with model and additional args", function()
556+
local claudecode = require("claudecode")
557+
claudecode.setup({ auto_start = false })
558+
559+
-- Find and call the ClaudeCodeSelectModel command handler
560+
local command_handler
561+
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
562+
if call.vals[1] == "ClaudeCodeSelectModel" then
563+
command_handler = call.vals[2]
564+
break
565+
end
566+
end
567+
568+
assert.is_function(command_handler, "Command handler should be a function")
569+
570+
command_handler({ args = "--resume --verbose" })
571+
572+
-- Verify vim.ui.select was called
573+
assert(#mock_ui_select.calls > 0, "vim.ui.select was not called")
574+
575+
-- Verify vim.cmd was called with the correct ClaudeCode command including additional args
576+
assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called")
577+
local cmd_arg = mock_vim_cmd.calls[1].vals[1]
578+
assert.is_equal(
579+
"ClaudeCode --model opus --resume --verbose",
580+
cmd_arg,
581+
"Should call ClaudeCode with model and additional args"
582+
)
583+
end)
584+
585+
it("should handle user cancellation gracefully", function()
586+
local claudecode = require("claudecode")
587+
claudecode.setup({ auto_start = false })
588+
589+
-- Mock vim.ui.select to simulate user cancellation
590+
vim.ui.select = spy.new(function(models, opts, callback)
591+
callback(nil) -- User cancelled
592+
end)
593+
594+
-- Find and call the ClaudeCodeSelectModel command handler
595+
local command_handler
596+
for _, call in ipairs(vim.api.nvim_create_user_command.calls) do
597+
if call.vals[1] == "ClaudeCodeSelectModel" then
598+
command_handler = call.vals[2]
599+
break
600+
end
601+
end
602+
603+
assert.is_function(command_handler, "Command handler should be a function")
604+
605+
command_handler({ args = "--resume" })
606+
607+
-- Verify vim.ui.select was called
608+
assert(#vim.ui.select.calls > 0, "vim.ui.select was not called")
609+
610+
-- Verify vim.cmd was NOT called due to user cancellation
611+
assert.is_equal(0, #mock_vim_cmd.calls, "vim.cmd should not be called when user cancels")
612+
end)
613+
end)
465614
end)

0 commit comments

Comments
 (0)