diff --git a/README.md b/README.md index b62a7b33..24216c4a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ require('opencode').setup({ ['os'] = { 'select_session' }, -- Select and load a opencode session ['oR'] = { 'rename_session' }, -- Rename current session ['op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list + ['oV'] = { 'configure_variant' }, -- Switch model variant for the current model ['oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows ['ov'] = { 'paste_image'}, -- Paste image from clipboard into current session ['od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt @@ -161,6 +162,7 @@ require('opencode').setup({ [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history [''] = { 'next_prompt_history', mode = { 'n', 'i' } }, -- Navigate to next prompt in history [''] = { 'switch_mode' }, -- Switch between modes (build/plan) + [''] = { 'cycle_variant', mode = { 'n', 'i' } }, -- Cycle through available model variants }, output_window = { [''] = { 'close' }, -- Close UI windows @@ -169,6 +171,7 @@ require('opencode').setup({ ['[['] = { 'prev_message' }, -- Navigate to previous message in the conversation [''] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes ['i'] = { 'focus_input', 'n' }, -- Focus on input window and enter insert mode at the end of the input from the output window + [''] = { 'cycle_variant', mode = { 'n' } }, -- Cycle through available model variants ['oS'] = { 'select_child_session' }, -- Select and load a child session ['oD'] = { 'debug_message' }, -- Open raw message in new buffer for debugging ['oO'] = { 'debug_output' }, -- Open raw output in new buffer for debugging @@ -377,6 +380,18 @@ In the model picker, press **``** to toggle the currently selected model as No configuration is needed - the plugin respects and updates the OpenCode CLI format automatically. +### Model Variants + +Some models support multiple variants (e.g., different context window sizes or optimization modes). The plugin provides convenient ways to switch between available variants for the currently active model. + +#### Switching Variants + +- **Via picker**: Press `oV` to open the variant picker showing all available variants for the current model +- **Via cycling**: Press `` (Alt+R) in the input or output window to cycle through available variants +- **Via slash command**: Type `/variant` in the input window + +When you switch variants, the plugin remembers your selection per model, so the next time you use that model, it will automatically use the last selected variant. + ### UI icons (disable emojis or customize) By default, opencode.nvim uses emojis for icons in the UI. If you prefer a plain, emoji-free interface, you can switch to the `text` preset or override icons individually. @@ -552,6 +567,8 @@ The plugin provides the following actions that can be triggered via keymaps, com | Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` | | Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` | | Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | +| Configure model variant | `oV` | `:Opencode variant` / `/variant` | `require('opencode.api').configure_variant()` | +| Cycle through model variants | `` (window) | - | `require('opencode.api').cycle_variant()` | | Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | | Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | | Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | @@ -720,6 +737,7 @@ You can run predefined user commands and built-in slash commands from the input - `/help` — Show help - `/mcp` — Show MCP servers - `/models` — Switch provider/model +- `/variant` — Switch model variant - `/sessions` — Switch session - `/child-sessions` — Switch to a child session - `/agent` — Switch agent/mode diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 60f2cbde..b8c57abc 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -83,6 +83,14 @@ function M.configure_provider() core.configure_provider() end +function M.configure_variant() + core.configure_variant() +end + +function M.cycle_variant() + core.cycle_variant() +end + function M.cancel() core.cancel() end @@ -1205,6 +1213,11 @@ M.commands = { fn = M.configure_provider, }, + variant = { + desc = 'Switch model variant', + fn = M.configure_variant, + }, + run = { desc = 'Run prompt in current session', fn = function(args) @@ -1334,6 +1347,7 @@ M.slash_commands_map = { ['/history'] = { fn = M.select_history, desc = 'Select from history' }, ['/mcp'] = { fn = M.mcp, desc = 'Show MCP server configuration' }, ['/models'] = { fn = M.configure_provider, desc = 'Switch provider/model' }, + ['/variant'] = { fn = M.configure_variant, desc = 'Switch model variant' }, ['/new'] = { fn = M.open_input_new_session, desc = 'Create new session' }, ['/redo'] = { fn = M.redo, desc = 'Redo last action' }, ['/sessions'] = { fn = M.select_session, desc = 'Select session' }, @@ -1365,6 +1379,7 @@ M.legacy_command_map = { OpencodeSelectChildSession = 'session child', OpencodeTogglePane = 'toggle_pane', OpencodeConfigureProvider = 'models', + OpencodeConfigureVariant = 'variant', OpencodeRun = 'run', OpencodeRunNewSession = 'run_new', OpencodeDiff = 'diff open', diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index b0aa156f..4dd9d4ce 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -235,7 +235,7 @@ end --- Create and send a new message to a session --- @param id string Session ID (required) ---- @param message_data {messageID?: string, model?: {providerID: string, modelID: string}, agent?: string, system?: string, tools?: table, parts: OpencodeMessagePart[]} Message creation data +--- @param message_data {messageID?: string, model?: {providerID: string, modelID: string}, agent?: string, variant?: string, system?: string, tools?: table, parts: OpencodeMessagePart[]} Message creation data --- @param directory string|nil Directory path --- @return Promise<{info: MessageInfo, parts: OpencodeMessagePart[]}> function OpencodeApiClient:create_message(id, message_data, directory) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 43a22eaa..7c5a8ea6 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -26,6 +26,7 @@ M.defaults = { ['os'] = { 'select_session', desc = 'Select session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, + ['oV'] = { 'configure_variant', desc = 'Configure model variant' }, ['oz'] = { 'toggle_zoom', desc = 'Toggle zoom' }, ['ov'] = { 'paste_image', desc = 'Paste image from clipboard' }, ['od'] = { 'diff_open', desc = 'Open diff view' }, @@ -55,6 +56,7 @@ M.defaults = { ['i'] = { 'focus_input' }, ['gr'] = { 'references', desc = 'Browse code references' }, [''] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' }, + [''] = { 'cycle_variant', mode = { 'n' } }, ['oS'] = { 'select_child_session' }, ['oD'] = { 'debug_message' }, ['oO'] = { 'debug_output' }, @@ -73,6 +75,7 @@ M.defaults = { [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, [''] = { 'next_prompt_history', mode = { 'n', 'i' } }, [''] = { 'switch_mode', mode = { 'n', 'i' } }, + [''] = { 'cycle_variant', mode = { 'n', 'i' } }, [''] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' }, ['gr'] = { 'references', desc = 'Browse code references' }, ['oS'] = { 'select_child_session' }, diff --git a/lua/opencode/config_file.lua b/lua/opencode/config_file.lua index bcfc0521..52636e9d 100644 --- a/lua/opencode/config_file.lua +++ b/lua/opencode/config_file.lua @@ -63,6 +63,10 @@ function M.get_opencode_providers() end) end +--- Get model information for a specific provider and model +--- @param provider string Provider ID +--- @param model string Model ID +--- @return OpencodeModel|nil Model information with variants M.get_model_info = function(provider, model) local providers_response = M.get_opencode_providers():peek() diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 0b338745..fa3748f2 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -154,13 +154,18 @@ M.send_message = Promise.async(function(prompt, opts) context.load() opts.model = opts.model or M.initialize_current_model():await() opts.agent = opts.agent or state.current_mode or config.default_mode - + opts.variant = opts.variant or state.current_variant local params = {} if opts.model then local provider, model = opts.model:match('^(.-)/(.+)$') params.model = { providerID = provider, modelID = model } state.current_model = opts.model + + if opts.variant then + params.variant = opts.variant + state.current_variant = opts.variant + end end if opts.agent then @@ -255,7 +260,7 @@ function M.before_run(opts) end function M.configure_provider() - require('opencode.provider').select(function(selection) + require('opencode.model_picker').select(function(selection) if not selection then if state.windows then ui.focus_input() @@ -268,11 +273,86 @@ function M.configure_provider() if state.windows then ui.focus_input() else - vim.notify('Changed provider to ' .. selection.display, vim.log.levels.INFO) + vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO) end end) end +function M.configure_variant() + require('opencode.variant_picker').select(function(selection) + if not selection then + if state.windows then + ui.focus_input() + end + return + end + + state.current_variant = selection.name + + if state.windows then + ui.focus_input() + else + vim.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO) + end + end) +end + +M.cycle_variant = Promise.async(function() + if not state.current_model then + vim.notify('No model selected', vim.log.levels.WARN) + return + end + + local provider, model = state.current_model:match('^(.-)/(.+)$') + if not provider or not model then + return + end + + local config_file = require('opencode.config_file') + local model_info = config_file.get_model_info(provider, model) + + if not model_info or not model_info.variants then + vim.notify('Current model does not support variants', vim.log.levels.WARN) + return + end + + local variants = {} + for variant_name, _ in pairs(model_info.variants) do + table.insert(variants, variant_name) + end + + util.sort_by_priority(variants, function(item) + return item + end, { low = 1, medium = 2, high = 3 }) + + if #variants == 0 then + return + end + + local total_count = #variants + 1 + + local current_index + if state.current_variant == nil then + current_index = total_count + else + current_index = util.index_of(variants, state.current_variant) or 0 + end + + local next_index = (current_index % total_count) + 1 + + local next_variant + if next_index > #variants then + next_variant = nil + else + next_variant = variants[next_index] + end + + state.current_variant = next_variant + + local model_state = require('opencode.model_state') + model_state.set_variant(provider, model, next_variant) +end) + M.cancel = Promise.async(function() if state.windows and state.active_session then if state.is_running() then @@ -461,6 +541,23 @@ function M.setup() state.subscribe('opencode_server', on_opencode_server) state.subscribe('user_message_count', M._on_user_message_count_change) state.subscribe('pending_permissions', M._on_current_permission_change) + state.subscribe('current_model', function(key, new_val, old_val) + if new_val ~= old_val then + state.current_variant = nil + + -- Load saved variant for the new model + if new_val then + local provider, model = new_val:match('^(.-)/(.+)$') + if provider and model then + local model_state = require('opencode.model_state') + local saved_variant = model_state.get_variant(provider, model) + if saved_variant then + state.current_variant = saved_variant + end + end + end + end + end) vim.schedule(function() M.opencode_ok() diff --git a/lua/opencode/provider.lua b/lua/opencode/model_picker.lua similarity index 51% rename from lua/opencode/provider.lua rename to lua/opencode/model_picker.lua index 47212393..14efd59e 100644 --- a/lua/opencode/provider.lua +++ b/lua/opencode/model_picker.lua @@ -1,131 +1,7 @@ local config = require('opencode.config') +local model_state = require('opencode.model_state') local M = {} ----Get the path to the model state file ----@return string -local function get_model_state_path() - local home = vim.uv.os_homedir() - return home .. '/.local/state/opencode/model.json' -end - ----Load model state (favorites and recent) in OpenCode CLI format ----@return table -local function load_model_state() - local state_path = get_model_state_path() - local file = io.open(state_path, 'r') - if not file then - return { recent = {}, favorite = {}, variant = {} } - end - - local content = file:read('*a') - file:close() - - local ok, data = pcall(vim.json.decode, content) - if not ok or type(data) ~= 'table' then - return { recent = {}, favorite = {}, variant = {} } - end - - data.recent = data.recent or {} - data.favorite = data.favorite or {} - data.variant = data.variant or {} - - return data -end - ----Save model state (favorites and recent) in OpenCode CLI format ----@param state table -local function save_model_state(state) - local state_path = get_model_state_path() - local state_dir = vim.fn.fnamemodify(state_path, ':h') - - if not vim.fn.isdirectory(state_dir) then - vim.fn.mkdir(state_dir, 'p') - end - - local file = io.open(state_path, 'w') - if not file then - vim.notify('Failed to save model state', vim.log.levels.WARN) - return - end - - local ok, json = pcall(vim.json.encode, state) - if not ok then - file:close() - vim.notify('Failed to encode model state', vim.log.levels.WARN) - return - end - - file:write(json) - file:close() -end - ----Record that a model was accessed ----@param provider_id string ----@param model_id string -local function record_model_access(provider_id, model_id) - local state = load_model_state() - - state.recent = vim.tbl_filter(function(item) - return not (item.providerID == provider_id and item.modelID == model_id) - end, state.recent) - - table.insert(state.recent, 1, { - providerID = provider_id, - modelID = model_id, - }) - - if #state.recent > 10 then - for i = #state.recent, 11, -1 do - table.remove(state.recent, i) - end - end - - save_model_state(state) -end - ----Toggle a model as favorite ----@param provider_id string ----@param model_id string -local function toggle_favorite(provider_id, model_id) - local state = load_model_state() - - -- Check if already in favorites - local found_idx = nil - for i, item in ipairs(state.favorite) do - if item.providerID == provider_id and item.modelID == model_id then - found_idx = i - break - end - end - - if found_idx then - table.remove(state.favorite, found_idx) - vim.notify('Removed from favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO) - else - table.insert(state.favorite, { - providerID = provider_id, - modelID = model_id, - }) - vim.notify('Added to favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO) - end - - save_model_state(state) -end - ----Get model index in a state list ----@param provider_id string ----@param model_id string ----@param list table Array of model entries with providerID and modelID ----@return number|nil Index in the list (1-based) or nil if not found -local function get_model_index(provider_id, model_id, list) - for i, item in ipairs(list) do - if item.providerID == provider_id and item.modelID == model_id then - return i - end - end - return nil -end - function M._get_models() local config_file = require('opencode.config_file') local response = config_file.get_opencode_providers():wait() @@ -138,15 +14,15 @@ function M._get_models() local preferred_icon = icons.get('preferred') local last_used_icon = icons.get('last_used') - local state = load_model_state() + local state = model_state.load() local models = {} for _, provider in ipairs(response.providers) do for _, model in pairs(provider.models) do local provider_id = provider.id local model_id = model.id - local fav_idx = get_model_index(provider_id, model_id, state.favorite) - local recent_idx = get_model_index(provider_id, model_id, state.recent) + local fav_idx = model_state.get_model_index(provider_id, model_id, state.favorite) + local recent_idx = model_state.get_model_index(provider_id, model_id, state.recent) local icon = nil if fav_idx then @@ -247,7 +123,7 @@ function M.select(cb) return models end - toggle_favorite(selected.provider, selected.model) + model_state.toggle_favorite(selected.provider, selected.model) return M._get_models() end, @@ -256,7 +132,7 @@ function M.select(cb) }, callback = function(selection) if selection then - record_model_access(selection.provider, selection.model) + model_state.record_model_access(selection.provider, selection.model) end cb(selection) end, diff --git a/lua/opencode/model_state.lua b/lua/opencode/model_state.lua new file mode 100644 index 00000000..6d6fdc7b --- /dev/null +++ b/lua/opencode/model_state.lua @@ -0,0 +1,155 @@ +local M = {} + +---Get the path to the model state file +---@return string +local function get_model_state_path() + local home = vim.uv.os_homedir() + return home .. '/.local/state/opencode/model.json' +end + +---Load model state (favorites, recent, and variants) in OpenCode CLI format +---@return table +function M.load() + local state_path = get_model_state_path() + local file = io.open(state_path, 'r') + if not file then + return { recent = {}, favorite = {}, variant = {} } + end + + local content = file:read('*a') + file:close() + + local ok, data = pcall(vim.json.decode, content) + if not ok or type(data) ~= 'table' then + return { recent = {}, favorite = {}, variant = {} } + end + + data.recent = data.recent or {} + data.favorite = data.favorite or {} + data.variant = data.variant or {} + + return data +end + +---Save model state (favorites, recent, and variants) in OpenCode CLI format +---@param state table +function M.save(state) + local state_path = get_model_state_path() + local state_dir = vim.fn.fnamemodify(state_path, ':h') + + if not vim.fn.isdirectory(state_dir) then + vim.fn.mkdir(state_dir, 'p') + end + + local file = io.open(state_path, 'w') + if not file then + vim.notify('Failed to save model state', vim.log.levels.WARN) + return + end + + local ok, json = pcall(vim.json.encode, state) + if not ok then + file:close() + vim.notify('Failed to encode model state', vim.log.levels.WARN) + return + end + + file:write(json) + file:close() +end + +---Get the saved variant for a model +---@param provider_id string +---@param model_id string +---@return string|nil +function M.get_variant(provider_id, model_id) + local state = M.load() + local key = provider_id .. '/' .. model_id + return state.variant[key] +end + +---Save the variant for a model +---@param provider_id string +---@param model_id string +---@param variant_name string|nil +function M.set_variant(provider_id, model_id, variant_name) + local state = M.load() + local key = provider_id .. '/' .. model_id + + if variant_name then + state.variant[key] = variant_name + else + state.variant[key] = nil + end + + M.save(state) +end + +---Record that a model was accessed +---@param provider_id string +---@param model_id string +function M.record_model_access(provider_id, model_id) + local state = M.load() + + state.recent = vim.tbl_filter(function(item) + return not (item.providerID == provider_id and item.modelID == model_id) + end, state.recent) + + table.insert(state.recent, 1, { + providerID = provider_id, + modelID = model_id, + }) + + if #state.recent > 10 then + for i = #state.recent, 11, -1 do + table.remove(state.recent, i) + end + end + + M.save(state) +end + +---Toggle a model as favorite +---@param provider_id string +---@param model_id string +function M.toggle_favorite(provider_id, model_id) + local state = M.load() + + -- Check if already in favorites + local found_idx = nil + for i, item in ipairs(state.favorite) do + if item.providerID == provider_id and item.modelID == model_id then + found_idx = i + break + end + end + + if found_idx then + table.remove(state.favorite, found_idx) + vim.notify('Removed from favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO) + else + table.insert(state.favorite, { + providerID = provider_id, + modelID = model_id, + }) + vim.notify('Added to favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO) + end + + M.save(state) +end + +---Get model index in a state list +---@param provider_id string +---@param model_id string +---@param list table Array of model entries with providerID and modelID +---@return number|nil Index in the list (1-based) or nil if not found +function M.get_model_index(provider_id, model_id, list) + for i, item in ipairs(list) do + if item.providerID == provider_id and item.modelID == model_id then + return i + end + end + return nil +end + +return M diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 6aca6a2d..0aa66c95 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -27,6 +27,7 @@ ---@field restore_points RestorePoint[] ---@field current_model string|nil ---@field current_model_info table|nil +---@field current_variant string|nil ---@field messages OpencodeMessage[]|nil ---@field current_message OpencodeMessage|nil ---@field last_user_message OpencodeMessage|nil @@ -76,6 +77,7 @@ local _state = { restore_points = {}, current_model = nil, current_model_info = nil, + current_variant = nil, -- messages messages = nil, current_message = nil, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index dec63915..ba332ca0 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -372,6 +372,7 @@ ---@field context? OpencodeContextConfig ---@field model? string ---@field agent? string +---@field variant? string ---@class CompletionContext ---@field trigger_char string The character that triggered completion @@ -468,6 +469,9 @@ ---@field context number Maximum context length in tokens ---@field output number Maximum output length in tokens +---@class OpencodeModelVariant +---@field reasoningEffort string Reasoning effort level (e.g., "low", "medium", "high") + ---@class OpencodeModel ---@field id string Unique identifier for the model ---@field name string Human-readable name of the model @@ -482,6 +486,7 @@ ---@field open_weights boolean Whether the model has open weights ---@field limit OpencodeModelLimits Token limits for the model ---@field cost OpencodeModelCost Pricing information for the model +---@field variants table|nil Model variants with different configurations ---@class OpencodeProvider ---@field id string Unique identifier for the provider diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 083d0468..fafa6109 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -44,6 +44,10 @@ local function build_right_segments() if not state.is_running() and state.current_model and config.ui.display_model then table.insert(segments, { state.current_model, 'OpencodeHint' }) + if state.current_variant then + table.insert(segments, { '·', 'OpencodeHint' }) + table.insert(segments, { state.current_variant, 'OpencodeVariant' }) + end table.insert(segments, { ' ' }) end @@ -149,6 +153,7 @@ function M.setup(windows) -- for model changes state.subscribe('current_model', on_change) state.subscribe('current_mode', on_change) + state.subscribe('current_variant', on_change) state.subscribe('active_session', on_change) -- to show C-c message state.subscribe('job_count', on_job_count_changed) @@ -173,6 +178,7 @@ function M.close() state.unsubscribe('current_model', on_change) state.unsubscribe('current_mode', on_change) + state.unsubscribe('current_variant', on_change) state.unsubscribe('active_session', on_change) state.unsubscribe('job_count', on_job_count_changed) state.unsubscribe('restore_points', on_change) diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index a2097de5..77b047aa 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -23,6 +23,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextualActions', { bg = '#90A4AE', fg = '#1976D2', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeInputLegend', { bg = '#757575', fg = '#424242', bold = false, default = true }) vim.api.nvim_set_hl(0, 'OpencodeHint', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeVariant', { link = '@attribute', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeGuardDenied', { fg = '#F44336', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true }) @@ -59,6 +60,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextualActions', { bg = '#3b4261', fg = '#61AFEF', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeInputLegend', { link = '@label', bold = false, default = true }) vim.api.nvim_set_hl(0, 'OpencodeHint', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeVariant', { link = '@attribute', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeGuardDenied', { fg = '#EF5350', bold = true, default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true }) diff --git a/lua/opencode/ui/mcp_picker.lua b/lua/opencode/ui/mcp_picker.lua index fe69dbb9..c44fa21e 100644 --- a/lua/opencode/ui/mcp_picker.lua +++ b/lua/opencode/ui/mcp_picker.lua @@ -2,6 +2,7 @@ local M = {} local base_picker = require('opencode.ui.base_picker') local icons = require('opencode.ui.icons') local Promise = require('opencode.promise') +local util = require('opencode.util') ---Format MCP server item for picker ---@param mcp_item table MCP server definition @@ -69,21 +70,14 @@ function M.pick(callback) }) end - table.sort(items, function(a, b) - local status_priority = { - connected = 1, - failed = 2, - disabled = 3, - disconnected = 4, - } - local a_priority = status_priority[a.status] or 5 - local b_priority = status_priority[b.status] or 5 - - if a_priority ~= b_priority then - return a_priority < b_priority - end - return a.name < b.name - end) + util.sort_by_priority(items, function(item) + return item.status + end, { + connected = 1, + failed = 2, + disabled = 3, + disconnected = 4, + }) return items end) diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index bbfff2d6..e36cb88b 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -348,9 +348,9 @@ function M.strdisplaywidth(str) return vim.fn.strdisplaywidth(str) end ---- Parse run command arguments with optional agent, model, and context prefixes. +--- Parse run command arguments with optional agent, model, variant, and context prefixes. --- Returns opts table and remaining prompt string. ---- Format: [agent=] [model=] [context=] +--- Format: [agent=] [model=] [variant=] [context=] --- Also supports quick context syntax like "#buffer #git_diff" in the prompt --- @param args string[] --- @return table opts, string prompt @@ -361,6 +361,7 @@ function M.parse_run_args(args) for i, token in ipairs(args) do local agent = token:match('^agent=(.+)$') local model = token:match('^model=(.+)$') + local variant = token:match('^variant=(.+)$') local context = token:match('^context=(.+)$') if agent then @@ -369,6 +370,9 @@ function M.parse_run_args(args) elseif model then opts.model = model prompt_start_idx = i + 1 + elseif variant then + opts.variant = variant + prompt_start_idx = i + 1 elseif context then opts.context = M.parse_dot_args(context:gsub(',', ' ')) prompt_start_idx = i + 1 @@ -526,4 +530,26 @@ function M.get_visual_range() } end +--- Sort items by priority level (low, medium, high) and then alphabetically by a key. +--- @param items table[] Array of items to sort +--- @param key_fn fun(item: table): string Function to extract the key from each item +--- @param priority_map? table Optional custom priority map (defaults to {low=1, medium=2, high=3}) +--- @return table[] sorted_items The sorted array (sorts in-place and returns the same array) +function M.sort_by_priority(items, key_fn, priority_map) + local default_priority = 99 + + table.sort(items, function(a, b) + local a_key = key_fn(a) + local b_key = key_fn(b) + local a_priority = priority_map[a_key] or default_priority + local b_priority = priority_map[b_key] or default_priority + if a_priority ~= b_priority then + return a_priority < b_priority + end + return a_key < b_key + end) + + return items +end + return M diff --git a/lua/opencode/variant_picker.lua b/lua/opencode/variant_picker.lua new file mode 100644 index 00000000..451f7a84 --- /dev/null +++ b/lua/opencode/variant_picker.lua @@ -0,0 +1,105 @@ +local M = {} +local base_picker = require('opencode.ui.base_picker') +local state = require('opencode.state') +local config_file = require('opencode.config_file') +local model_state = require('opencode.model_state') +local util = require('opencode.util') + +---Get variants for the current model +---@return table[] variants Array of variant items +local function get_current_model_variants() + if not state.current_model then + return {} + end + + local provider, model = state.current_model:match('^(.-)/(.+)$') + if not provider or not model then + return {} + end + + local model_info = config_file.get_model_info(provider, model) + if not model_info or not model_info.variants then + return {} + end + + local variants = {} + for variant_name, variant_config in pairs(model_info.variants) do + table.insert(variants, { + name = variant_name, + config = variant_config, + }) + end + + util.sort_by_priority(variants, function(item) + return item.name + end, { low = 1, medium = 2, high = 3 }) + + return variants +end + +---Show variant picker +---@param callback fun(selection: table?) Callback when variant is selected +function M.select(callback) + local variants = get_current_model_variants() + + if #variants == 0 then + vim.notify('Current model does not support variants', vim.log.levels.WARN) + if callback then + callback(nil) + end + return + end + + -- Get saved variant from model state if no current variant is set + if not state.current_variant and state.current_model then + local provider, model = state.current_model:match('^(.-)/(.+)$') + if provider and model then + local saved_variant = model_state.get_variant(provider, model) + if saved_variant then + state.current_variant = saved_variant + end + end + end + + base_picker.pick({ + title = 'Select variant', + items = variants, + format_fn = function(item, width) + local item_width = width or vim.api.nvim_win_get_width(0) + local is_current = state.current_variant == item.name + local current_indicator = is_current and '*' or ' ' + + local name_width = item_width - vim.api.nvim_strwidth(current_indicator) + + local picker_item = base_picker.create_picker_item({ + { + text = current_indicator, + highlight = is_current and 'OpencodeContextSwitchOn' or 'OpencodeHint', + }, + { + text = base_picker.align(item.name, name_width, { truncate = true }), + highlight = is_current and 'OpencodeContextSwitchOn' or nil, + }, + }) + + return picker_item + end, + actions = {}, + callback = function(selection) + if selection and state.current_model then + state.current_variant = selection.name + + -- Save variant to model state + local provider, model = state.current_model:match('^(.-)/(.+)$') + if provider and model then + model_state.set_variant(provider, model, selection.name) + end + end + if callback then + callback(selection) + end + end, + }) +end + +return M