From 32063f4b192f97f620445985684f985ae6b577ba Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 13 Jan 2026 09:23:54 -0500 Subject: [PATCH 1/4] feat(config): add preferred_model configuration option Add support for highlighting and sorting a preferred model to the top of the provider list. Users can now set preferred_model in config to specify their preferred provider/model combination (e.g., 'github-copilot/claude-sonnet-4'), which will be visually marked and sorted to the top of the model selection list. --- README.md | 1 + lua/opencode/config.lua | 1 + lua/opencode/provider.lua | 23 +++++++++++++++++++- lua/opencode/ui/icons.lua | 44 ++++++++++++++++++++------------------- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 98fae60f..ad48d99c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Install the plugin with your favorite package manager. See the [Configuration](# require('opencode').setup({ preferred_picker = nil, -- 'telescope', 'fzf', 'mini.pick', 'snacks', 'select', if nil, it will use the best available picker. Note mini.pick does not support multiple selections preferred_completion = nil, -- 'blink', 'nvim-cmp','vim_complete' if nil, it will use the best available completion + preferred_model = nil, -- 'provider/model' (e.g., 'github-copilot/claude-sonnet-4') to highlight and sort to top of provider list default_global_keymaps = true, -- If false, disables all default global keymaps default_mode = 'build', -- 'build' or 'plan' or any custom configured. @see [OpenCode Agents](https://opencode.ai/docs/modes/) keymap_prefix = 'o', -- Default keymap prefix for global keymaps change to your preferred prefix and it will be applied to all keymaps starting with o diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 3fb9440c..e4b17eb1 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -8,6 +8,7 @@ local M = {} M.defaults = { preferred_picker = nil, preferred_completion = nil, + preferred_model = nil, default_global_keymaps = true, default_mode = 'build', legacy_commands = true, diff --git a/lua/opencode/provider.lua b/lua/opencode/provider.lua index daa6e573..d5d4ff36 100644 --- a/lua/opencode/provider.lua +++ b/lua/opencode/provider.lua @@ -8,16 +8,37 @@ function M._get_models() return {} end + local config = require('opencode.config') + local icons = require('opencode.ui.icons') + local preferred_model = config.values.preferred_model + local preferred_icon = icons.get('preferred') + local models = {} for _, provider in ipairs(response.providers) do for _, model in pairs(provider.models) do + local model_id = provider.id .. '/' .. model.id + local is_preferred = preferred_model and model_id == preferred_model + table.insert(models, { provider = provider.id, model = model.id, - display = provider.name .. ': ' .. model.name, + display = (is_preferred and preferred_icon or '') .. provider.name .. ': ' .. model.name, + is_preferred = is_preferred, }) end end + + -- Sort models: preferred first, then alphabetically + table.sort(models, function(a, b) + if a.is_preferred and not b.is_preferred then + return true + elseif not a.is_preferred and b.is_preferred then + return false + else + return a.display < b.display + end + end) + return models end diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index d8d85318..511b92c9 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -7,41 +7,42 @@ local presets = { nerdfonts = { -- headers header_user = '▌󰭻 ', - header_assistant = ' ', + header_assistant = ' ', -- actions/tools - run = ' ', - task = ' ', - read = ' ', - edit = ' ', - write = ' ', + run = ' ', + task = ' ', + read = ' ', + edit = ' ', + write = ' ', plan = '󰝖 ', - search = ' ', + search = ' ', web = '󰖟 ', - list = ' ', - tool = ' ', + list = ' ', + tool = ' ', snapshot = '󰻛 ', restore_point = '󱗚 ', - file = ' ', - folder = ' ', + file = ' ', + folder = ' ', attached_file = '󰌷 ', agent = '󰚩 ', - reference = ' ', + reference = ' ', reasoning = '󰧑 ', - question = '', + question = '', + preferred = ' ', -- statuses - status_on = ' ', - status_off = ' ', - guard_on = ' ', + status_on = ' ', + status_off = ' ', + guard_on = ' ', -- borders and misc border = '▌', -- context bar cursor_data = '󰗧 ', - error = ' ', - warning = ' ', - info = ' ', - filter = '/', + error = ' ', + warning = ' ', + info = ' ', + filter = '/', selection = '󰫙 ', - command = ' ', + command = ' ', }, text = { -- headers @@ -66,6 +67,7 @@ local presets = { agent = '@', reference = '@', question = '?', + preferred = '*', -- statuses status_on = 'ON', status_off = 'OFF', From fe69584d6829099c3a12de632f688ad43e943f81 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 13 Jan 2026 13:44:44 -0500 Subject: [PATCH 2/4] feat: add intelligent model picker with favorites, recent usage, and persistent sorting - Adds keybinding to toggle model favorite state in the picker (configurable) - UI shows star for favorites and clock for recent --- README.md | 59 ++++++--- lua/opencode/config.lua | 4 +- lua/opencode/provider.lua | 248 ++++++++++++++++++++++++++++++++++---- lua/opencode/ui/icons.lua | 48 ++++---- 4 files changed, 297 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ad48d99c..53ebb271 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,10 @@ > neovim frontend for opencode - a terminal-based AI coding agent -## Main Features - -### Chat Panel -
Opencode logo
-### Quick buffer chat (o/) EXPERIMENTAL - -This is an experimental feature that allows you to chat with the AI using the current buffer context. In visual mode, it captures the selected text as context, while in normal mode, it uses the current line. The AI will respond with quick edits to the files that are applied by the plugin. - -Don't hesitate to give it a try and provide feedback! - -Refer to the [Quick Chat](#-quick-chat) section for more details. - -
- -
-
![Neovim](https://img.shields.io/badge/NeoVim-%2357A143.svg?&style=for-the-badge&logo=neovim&logoColor=white) @@ -34,10 +18,26 @@ Refer to the [Quick Chat](#-quick-chat) section for more details. This plugin provides a bridge between neovim and the [opencode](https://github.com/sst/opencode) AI agent, creating a chat interface while capturing editor context (current file, selections) to enhance your prompts. It maintains persistent sessions tied to your workspace, allowing for continuous conversations with the AI assistant similar to what tools like Cursor AI offer. +## Main Features +
+### Chat Panel + +### Quick buffer chat (o/) EXPERIMENTAL + +This is an experimental feature that allows you to chat with the AI using the current buffer context. In visual mode, it captures the selected text as context, while in normal mode, it uses the current line. The AI will respond with quick edits to the files that are applied by the plugin. + +Don't hesitate to give it a try and provide feedback! + +Refer to the [Quick Chat](#-quick-chat) section for more details. + +
+ +
+ ## 📑 Table of Contents - [⚠️Caution](#caution) @@ -109,7 +109,6 @@ Install the plugin with your favorite package manager. See the [Configuration](# require('opencode').setup({ preferred_picker = nil, -- 'telescope', 'fzf', 'mini.pick', 'snacks', 'select', if nil, it will use the best available picker. Note mini.pick does not support multiple selections preferred_completion = nil, -- 'blink', 'nvim-cmp','vim_complete' if nil, it will use the best available completion - preferred_model = nil, -- 'provider/model' (e.g., 'github-copilot/claude-sonnet-4') to highlight and sort to top of provider list default_global_keymaps = true, -- If false, disables all default global keymaps default_mode = 'build', -- 'build' or 'plan' or any custom configured. @see [OpenCode Agents](https://opencode.ai/docs/modes/) keymap_prefix = 'o', -- Default keymap prefix for global keymaps change to your preferred prefix and it will be applied to all keymaps starting with o @@ -191,6 +190,9 @@ require('opencode').setup({ delete_entry = { '', mode = { 'i', 'n' } }, -- Delete selected entry in the history picker clear_all = { '', mode = { 'i', 'n' } }, -- Clear all entries in the history picker } + model_picker = { + toggle_favorite = { '', mode = { 'i', 'n' } }, + }, }, ui = { position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output. @@ -348,6 +350,28 @@ require('opencode').setup({ }) ``` +### Model Sorting and Favorites + +The provider/model picker supports intelligent sorting based on your favorites and usage history: + +#### Sorting Priority + +When you open the model picker (`op`), models are sorted in the following order: + +1. **Favorite models** - shown with a ⭐ icon and sorted by the order they were favorited +2. **Recently accessed models** - sorted by most recent usage +3. **Other models** - sorted alphabetically + +#### Managing Favorites + +In the model picker, press **``** to toggle the currently selected model as a favorite. Favorite models will: + +- Display with a ⭐ star icon prefix +- Always appear at the top of the list +- Persist across Neovim sessions + +No configuration is needed - the plugin respects and updates the OpenCode CLI format automatically. + ### 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. @@ -444,6 +468,7 @@ You can configure a custom action in Snacks pickers to send selected files direc ``` This allows you to: + 1. Open any Snacks file picker (`:Snacks picker files`, `:Snacks picker git_files`, etc.) 2. Select one or more files using multi-select 3. Press `o` to send those files to opencode as context diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index e4b17eb1..f85dcbee 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -8,7 +8,6 @@ local M = {} M.defaults = { preferred_picker = nil, preferred_completion = nil, - preferred_model = nil, default_global_keymaps = true, default_mode = 'build', legacy_commands = true, @@ -99,6 +98,9 @@ M.defaults = { delete_entry = { '', mode = { 'i', 'n' } }, clear_all = { '', mode = { 'i', 'n' } }, }, + model_picker = { + toggle_favorite = { '', mode = { 'i', 'n' } }, + }, quick_chat = { cancel = { '', mode = { 'i', 'n' } }, }, diff --git a/lua/opencode/provider.lua b/lua/opencode/provider.lua index d5d4ff36..3cba6c00 100644 --- a/lua/opencode/provider.lua +++ b/lua/opencode/provider.lua @@ -1,5 +1,131 @@ +local config = require('opencode.config') 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() @@ -8,35 +134,61 @@ function M._get_models() return {} end - local config = require('opencode.config') local icons = require('opencode.ui.icons') - local preferred_model = config.values.preferred_model local preferred_icon = icons.get('preferred') + local last_used_icon = icons.get('last_used') + + local state = load_model_state() local models = {} for _, provider in ipairs(response.providers) do for _, model in pairs(provider.models) do - local model_id = provider.id .. '/' .. model.id - local is_preferred = preferred_model and model_id == preferred_model + 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 icon = nil + if fav_idx then + icon = preferred_icon + elseif recent_idx then + icon = last_used_icon + end table.insert(models, { - provider = provider.id, - model = model.id, - display = (is_preferred and preferred_icon or '') .. provider.name .. ': ' .. model.name, - is_preferred = is_preferred, + provider = provider_id, + provider_name = provider.name, + model = model_id, + model_name = model.name, + icon = icon, + favorite_index = fav_idx or 999, -- High number for non-favorite items + recent_index = recent_idx or 999, -- High number for non-recent items }) end end - -- Sort models: preferred first, then alphabetically table.sort(models, function(a, b) - if a.is_preferred and not b.is_preferred then + if a.favorite_index < 999 and b.favorite_index < 999 then + return a.favorite_index < b.favorite_index + end + + if a.favorite_index < 999 and b.favorite_index >= 999 then return true - elseif not a.is_preferred and b.is_preferred then + end + + if a.favorite_index >= 999 and b.favorite_index < 999 then return false - else - return a.display < b.display end + + if a.recent_index ~= b.recent_index then + return a.recent_index < b.recent_index + end + + if a.provider_name ~= b.provider_name then + return a.provider_name < b.provider_name + end + + return a.model_name < b.model_name end) return models @@ -44,16 +196,70 @@ end function M.select(cb) local models = M._get_models() + local base_picker = require('opencode.ui.base_picker') + + local max_provider_width, max_icon_width = 0, 0 + for _, m in ipairs(models) do + max_provider_width = math.max(max_provider_width, vim.api.nvim_strwidth(m.provider_name)) + if m.icon and m.icon ~= '' then + max_icon_width = math.max(max_icon_width, vim.api.nvim_strwidth(m.icon)) + end + end + local icon_width = max_icon_width > 0 and max_icon_width + 1 or 0 + local provider_icon_width = max_provider_width + icon_width + + base_picker.pick({ + title = 'Select model', + items = models, + format_fn = function(item, width) + local icon = item.icon or '' + local item_width = width or vim.api.nvim_win_get_width(0) + local model_width = item_width - provider_icon_width - local picker = require('opencode.ui.picker') - picker.select(models, { - prompt = 'Select model:', - format_item = function(item) - return item.display + local picker_item = { + content = base_picker.align(item.model_name, model_width, { truncate = true }), + time_text = base_picker.align(item.provider_name, max_provider_width, { align = 'left' }) + .. (icon_width > 0 and base_picker.align(icon, icon_width, { align = 'right' }) or ''), + debug_text = nil, + } + + function picker_item:to_string() + return table.concat({ self.content, self.time_text or '', self.debug_text or '' }, ' ') + end + + function picker_item:to_formatted_text() + return { + { self.content }, + self.time_text and { ' ' .. self.time_text, 'OpencodeHint' } or { '' }, + self.debug_text and { ' ' .. self.debug_text, 'OpencodeHint' } or { '' }, + } + end + + return picker_item end, - }, function(selection) - cb(selection) - end) + actions = { + toggle_favorite = { + key = config.keymap.model_picker.toggle_favorite, + label = 'Toggle favorite', + fn = function(selected) + if not selected then + return models + end + + toggle_favorite(selected.provider, selected.model) + + return M._get_models() + end, + reload = true, + } --[[@as PickerAction]], + }, + callback = function(selection) + if selection then + record_model_access(selection.provider, selection.model) + end + cb(selection) + end, + }) end return M diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index 511b92c9..875a8329 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -7,42 +7,43 @@ local presets = { nerdfonts = { -- headers header_user = '▌󰭻 ', - header_assistant = ' ', + header_assistant = ' ', -- actions/tools - run = ' ', - task = ' ', - read = ' ', - edit = ' ', - write = ' ', + run = ' ', + task = ' ', + read = ' ', + edit = ' ', + write = ' ', plan = '󰝖 ', - search = ' ', + search = ' ', web = '󰖟 ', - list = ' ', - tool = ' ', + list = ' ', + tool = ' ', snapshot = '󰻛 ', restore_point = '󱗚 ', - file = ' ', - folder = ' ', + file = ' ', + folder = ' ', attached_file = '󰌷 ', agent = '󰚩 ', - reference = ' ', + reference = ' ', reasoning = '󰧑 ', - question = '', - preferred = ' ', + question = '', -- statuses - status_on = ' ', - status_off = ' ', - guard_on = ' ', + status_on = ' ', + status_off = ' ', + guard_on = ' ', -- borders and misc border = '▌', -- context bar cursor_data = '󰗧 ', - error = ' ', - warning = ' ', - info = ' ', - filter = '/', + error = ' ', + warning = ' ', + info = ' ', + filter = '/', selection = '󰫙 ', - command = ' ', + command = ' ', + preferred = ' ', + last_used = '󰃰 ', }, text = { -- headers @@ -67,7 +68,6 @@ local presets = { agent = '@', reference = '@', question = '?', - preferred = '*', -- statuses status_on = 'ON', status_off = 'OFF', @@ -82,6 +82,8 @@ local presets = { filter = '/*', selection = "'<'> ", command = '::', + preferred = '* ', + last_used = '~ ', }, } From f41f8666078fb93107d810a67159585577ce9b56 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 13 Jan 2026 14:26:58 -0500 Subject: [PATCH 3/4] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 53ebb271..79d95c73 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ require('opencode').setup({ history_picker = { delete_entry = { '', mode = { 'i', 'n' } }, -- Delete selected entry in the history picker clear_all = { '', mode = { 'i', 'n' } }, -- Clear all entries in the history picker - } + }, model_picker = { toggle_favorite = { '', mode = { 'i', 'n' } }, }, From 2f324892150d2abc5fc7ca8add1ef27bad85d822 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 13 Jan 2026 14:31:45 -0500 Subject: [PATCH 4/4] fix: fix readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79d95c73..e977fc3a 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ This plugin provides a bridge between neovim and the [opencode](https://github.c ## Main Features +### Chat Panel + +The chat panel is a dedicated window inside Neovim that lets you hold a persistent conversation with the opencode AI agent. It displays your previous messages and responses, and automatically uses your current workspace and editor state as context so you can iterate on code without leaving Neovim. You can type prompts, review answers, and navigate back to your code buffer while keeping the ongoing chat session open. +
-### Chat Panel - ### Quick buffer chat (o/) EXPERIMENTAL This is an experimental feature that allows you to chat with the AI using the current buffer context. In visual mode, it captures the selected text as context, while in normal mode, it uses the current line. The AI will respond with quick edits to the files that are applied by the plugin.