From e2a6041e15570416f684550995d109d762321c7b Mon Sep 17 00:00:00 2001 From: Noah Pederson Date: Sun, 20 Sep 2026 20:17:15 -0500 Subject: [PATCH] Add model switching command Let users select a model by name or with fzf-lua. Keep the selected model for later prompts in the session. --- README.md | 2 + lua/brokencode/client.lua | 7 +++ lua/brokencode/init.lua | 100 +++++++++++++++++++++++++++++++++++--- lua/brokencode/picker.lua | 36 ++++++++++++++ tests/session_spec.lua | 24 ++++++++- 5 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 lua/brokencode/picker.lua diff --git a/README.md b/README.md index 3e18b86..52ca327 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ the project. Set `model` when the OpenCode server default is not usable. - `:BrokencodeChat` opens the current conversation in a virtual buffer. - `:BrokencodeClear` creates a new session and clears the conversation. - `:BrokencodeNew` clears the project session selection. +- `:BrokencodeSwitch [provider/model@variant]` switches models. Without an + argument, it opens `fzf-lua` or falls back to `vim.ui.select`. The session remains on the OpenCode server. The plugin stores its session ID under `stdpath("state")` so later Neovim processes continue the conversation. diff --git a/lua/brokencode/client.lua b/lua/brokencode/client.lua index 5fb2043..72ab3fe 100644 --- a/lua/brokencode/client.lua +++ b/lua/brokencode/client.lua @@ -89,6 +89,13 @@ function M.switch_model(session_id, model, callback) request("post", path, { model = model }, callback) end +function M.models(directory, callback) + local path = "/api/model?location[directory]=" .. vim.uri_encode(directory) + request("get", path, nil, function(err, response) + callback(err, response and response.data) + end) +end + function M.messages(session_id, callback) local path = "/api/session/" .. session_id .. "/message?order=asc&limit=200" request("get", path, nil, function(err, response) diff --git a/lua/brokencode/init.lua b/lua/brokencode/init.lua index 7605083..c323c74 100644 --- a/lua/brokencode/init.lua +++ b/lua/brokencode/init.lua @@ -6,19 +6,20 @@ local ui = require("brokencode.ui") local M = {} local loaded = false -local models = {} +local selected_models = {} +local ensure_session local function directory() return vim.fs.root(0, { ".git" }) or vim.fn.getcwd() end local function resolve_model(cwd, current_id, callback) - if config.values.model then - callback(config.values.model) + if selected_models[cwd] then + callback(selected_models[cwd]) return end - if models[cwd] then - callback(models[cwd]) + if config.values.model then + callback(config.values.model) return end @@ -31,7 +32,7 @@ local function resolve_model(cwd, current_id, callback) for _, session in ipairs(sessions or {}) do if session.id ~= current_id and session.model then - models[cwd] = session.model + selected_models[cwd] = session.model callback(session.model) return end @@ -40,6 +41,87 @@ local function resolve_model(cwd, current_id, callback) end) end +local function model_ref(model, variant) + return { + providerID = model.providerID, + id = model.modelID or model.id, + variant = variant, + } +end + +local function find_model(available, value) + local name, variant = value:match("^(.-)@([^@]+)$") + name = name or value + local matches = {} + + for _, model in ipairs(available) do + local id = model.modelID or model.id + if name == model.providerID .. "/" .. id or name == id then + table.insert(matches, model) + end + end + + if #matches == 0 then + return nil, nil, "Unknown OpenCode model: " .. name + end + if #matches > 1 then + return nil, nil, "Model ID is ambiguous; use provider/model" + end + if variant then + local found = false + for _, item in ipairs(matches[1].variants or {}) do + found = found or item.id == variant + end + if not found then + return nil, nil, "Unknown model variant: " .. variant + end + end + + return matches[1], variant +end + +local function switch_model(value) + local cwd = directory() + client.models(cwd, function(err, available) + if err then + vim.notify("Brokencode could not list models: " .. err, vim.log.levels.ERROR) + return + end + + local function select(model, variant) + if not model then + return + end + local ref = model_ref(model, variant) + ensure_session(cwd, function(session_id) + client.switch_model(session_id, ref, function(switch_err) + if switch_err then + vim.notify("Brokencode could not switch models: " .. switch_err, vim.log.levels.ERROR) + return + end + + selected_models[cwd] = ref + vim.notify("Brokencode model: " .. ref.providerID .. "/" .. ref.id) + end) + end) + end + + if value ~= "" then + local model, variant, find_err = find_model(available or {}, value) + if find_err then + vim.notify(find_err, vim.log.levels.ERROR) + return + end + select(model, variant) + return + end + + require("brokencode.picker").select(available or {}, function(model) + select(model, nil) + end) + end) +end + local function create_session(cwd, model, callback) client.create(cwd, model, function(err, session) if err then @@ -57,7 +139,7 @@ local function create_session(cwd, model, callback) end) end -local function ensure_session(cwd, callback) +ensure_session = function(cwd, callback) local session_id = state.get(cwd) if session_id then resolve_model(cwd, session_id, function(model) @@ -139,6 +221,7 @@ end function M.chat() ensure_session(directory(), function(session_id) ui.render({}, session_id) + ui.open(true) ui.refresh(session_id) end) end @@ -188,6 +271,9 @@ function M.setup(opts) vim.api.nvim_create_user_command("BrokencodeChat", M.chat, {}) vim.api.nvim_create_user_command("BrokencodeClear", clear, {}) vim.api.nvim_create_user_command("BrokencodeMessage", function() ui.input(M.send) end, {}) + vim.api.nvim_create_user_command("BrokencodeSwitch", function(command_opts) + switch_model(command_opts.args) + end, { nargs = "?" }) vim.api.nvim_create_user_command("BrokencodeNew", function() state.clear(directory()) vim.notify("Brokencode session cleared") diff --git a/lua/brokencode/picker.lua b/lua/brokencode/picker.lua new file mode 100644 index 0000000..579c22f --- /dev/null +++ b/lua/brokencode/picker.lua @@ -0,0 +1,36 @@ +local M = {} + +local function label(model) + local id = model.modelID or model.id + return string.format("%s/%s %s", model.providerID, id, model.name or "") +end + +function M.select(models, callback) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.ui.select(models, { + prompt = "OpenCode model", + format_item = label, + }, callback) + return + end + + local entries = {} + local lookup = {} + for _, model in ipairs(models) do + local entry = label(model) + table.insert(entries, entry) + lookup[entry] = model + end + + fzf.fzf_exec(entries, { + prompt = "OpenCode model> ", + actions = { + ["default"] = function(selected) + callback(selected and lookup[selected[1]] or nil) + end, + }, + }) +end + +return M diff --git a/tests/session_spec.lua b/tests/session_spec.lua index d260fd0..5d1e848 100644 --- a/tests/session_spec.lua +++ b/tests/session_spec.lua @@ -3,6 +3,7 @@ local created = 0 local prompted = 0 local inherited local rendered +local switched package.loaded["brokencode.client"] = { sessions = function(_, callback) @@ -24,7 +25,15 @@ package.loaded["brokencode.client"] = { callback(nil) end, active = function(callback) callback(nil, {}) end, - switch_model = function(_, _, callback) callback(nil) end, + models = function(_, callback) + callback(nil, { + { providerID = "test", id = "new", name = "New", variants = { { id = "fast" } } }, + }) + end, + switch_model = function(_, model, callback) + switched = model + callback(nil) + end, } package.loaded["brokencode.state"] = { get = function() return current end, @@ -38,6 +47,9 @@ package.loaded["brokencode.ui"] = { render = function(messages, session_id) rendered = { messages = messages, session_id = session_id } end, } package.loaded["brokencode.bridge"] = { start = function() end } +package.loaded["brokencode.picker"] = { + select = function(available, callback) callback(available[1]) end, +} local brokencode = require("brokencode") brokencode.send("hello") @@ -54,3 +66,13 @@ assert(created == 2, "BrokencodeClear did not create a session") assert(current == "ses_clear", "BrokencodeClear did not save the new session") assert(rendered and rendered.session_id == "ses_clear", "BrokencodeClear did not select the new session") assert(vim.tbl_isempty(rendered.messages), "BrokencodeClear did not clear the conversation") + +vim.cmd("BrokencodeSwitch test/new@fast") +assert(switched.id == "new", "BrokencodeSwitch did not select the model") +assert(switched.providerID == "test", "BrokencodeSwitch lost the provider") +assert(switched.variant == "fast", "BrokencodeSwitch lost the variant") + +switched = nil +vim.cmd("BrokencodeSwitch") +assert(switched and switched.id == "new", "BrokencodeSwitch did not use the picker") +assert(switched.variant == nil, "the picker retained an old variant") -- 2.54.0