Skip to content

Commit a8e53d5

Browse files
authored
feat: add intelligent model picker with favorites, recent usage (#182)
This pull request adds an intelligent model picker with favorites and recent usage tracking to enhance the model selection experience. Users can mark models as favorites using <C-f> in the picker, and the list automatically sorts to show favorites first, followed by recently used models, then alphabetically sorted remaining models. Changes: Using state persistence for model favorites and recent usage in ~/.local/state/opencode/model.json (same as opencode CLI) Implemented model picker with visual indicators (star for favorites, clock for recent) Added <C-f> keybinding to toggle favorite status in the model picker Updated documentation with new feature details and configuration options
1 parent f52a22d commit a8e53d5

4 files changed

Lines changed: 290 additions & 28 deletions

File tree

README.md

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,10 @@
22

33
> neovim frontend for opencode - a terminal-based AI coding agent
44
5-
## Main Features
6-
7-
### Chat Panel
8-
95
<div align="center">
106
<img src="https://raw.githubusercontent.com/sst/opencode/dev/packages/web/src/assets/logo-ornate-dark.svg" alt="Opencode logo" width="30%" />
117
</div>
128

13-
### Quick buffer chat (<leader>o/) EXPERIMENTAL
14-
15-
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.
16-
17-
Don't hesitate to give it a try and provide feedback!
18-
19-
Refer to the [Quick Chat](#-quick-chat) section for more details.
20-
21-
<div align="center">
22-
<img src="https://i.imgur.com/5JNlFZn.png">
23-
</div>
24-
259
<div align="center">
2610

2711
![Neovim](https://img.shields.io/badge/NeoVim-%2357A143.svg?&style=for-the-badge&logo=neovim&logoColor=white)
@@ -34,10 +18,28 @@ Refer to the [Quick Chat](#-quick-chat) section for more details.
3418

3519
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.
3620

21+
## Main Features
22+
23+
### Chat Panel
24+
25+
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.
26+
3727
<div align="center">
3828
<img src="https://github.com/user-attachments/assets/197d69ba-6db9-4989-97ff-557c89000cf5">
3929
</div>
4030

31+
### Quick buffer chat (<leader>o/) EXPERIMENTAL
32+
33+
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.
34+
35+
Don't hesitate to give it a try and provide feedback!
36+
37+
Refer to the [Quick Chat](#-quick-chat) section for more details.
38+
39+
<div align="center">
40+
<img src="https://i.imgur.com/5JNlFZn.png">
41+
</div>
42+
4143
## 📑 Table of Contents
4244

4345
- [⚠️Caution](#caution)
@@ -189,7 +191,10 @@ require('opencode').setup({
189191
history_picker = {
190192
delete_entry = { '<C-d>', mode = { 'i', 'n' } }, -- Delete selected entry in the history picker
191193
clear_all = { '<C-X>', mode = { 'i', 'n' } }, -- Clear all entries in the history picker
192-
}
194+
},
195+
model_picker = {
196+
toggle_favorite = { '<C-f>', mode = { 'i', 'n' } },
197+
},
193198
},
194199
ui = {
195200
position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output.
@@ -347,6 +352,28 @@ require('opencode').setup({
347352
})
348353
```
349354

355+
### Model Sorting and Favorites
356+
357+
The provider/model picker supports intelligent sorting based on your favorites and usage history:
358+
359+
#### Sorting Priority
360+
361+
When you open the model picker (`<leader>op`), models are sorted in the following order:
362+
363+
1. **Favorite models** - shown with a ⭐ icon and sorted by the order they were favorited
364+
2. **Recently accessed models** - sorted by most recent usage
365+
3. **Other models** - sorted alphabetically
366+
367+
#### Managing Favorites
368+
369+
In the model picker, press **`<C-f>`** to toggle the currently selected model as a favorite. Favorite models will:
370+
371+
- Display with a ⭐ star icon prefix
372+
- Always appear at the top of the list
373+
- Persist across Neovim sessions
374+
375+
No configuration is needed - the plugin respects and updates the OpenCode CLI format automatically.
376+
350377
### UI icons (disable emojis or customize)
351378

352379
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.
@@ -443,6 +470,7 @@ You can configure a custom action in Snacks pickers to send selected files direc
443470
```
444471

445472
This allows you to:
473+
446474
1. Open any Snacks file picker (`:Snacks picker files`, `:Snacks picker git_files`, etc.)
447475
2. Select one or more files using multi-select
448476
3. Press `<localleader>o` to send those files to opencode as context

lua/opencode/config.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ M.defaults = {
9898
delete_entry = { '<C-d>', mode = { 'i', 'n' } },
9999
clear_all = { '<C-X>', mode = { 'i', 'n' } },
100100
},
101+
model_picker = {
102+
toggle_favorite = { '<C-f>', mode = { 'i', 'n' } },
103+
},
101104
quick_chat = {
102105
cancel = { '<C-c>', mode = { 'i', 'n' } },
103106
},

lua/opencode/provider.lua

Lines changed: 238 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,131 @@
1+
local config = require('opencode.config')
12
local M = {}
23

4+
---Get the path to the model state file
5+
---@return string
6+
local function get_model_state_path()
7+
local home = vim.uv.os_homedir()
8+
return home .. '/.local/state/opencode/model.json'
9+
end
10+
11+
---Load model state (favorites and recent) in OpenCode CLI format
12+
---@return table
13+
local function load_model_state()
14+
local state_path = get_model_state_path()
15+
local file = io.open(state_path, 'r')
16+
if not file then
17+
return { recent = {}, favorite = {}, variant = {} }
18+
end
19+
20+
local content = file:read('*a')
21+
file:close()
22+
23+
local ok, data = pcall(vim.json.decode, content)
24+
if not ok or type(data) ~= 'table' then
25+
return { recent = {}, favorite = {}, variant = {} }
26+
end
27+
28+
data.recent = data.recent or {}
29+
data.favorite = data.favorite or {}
30+
data.variant = data.variant or {}
31+
32+
return data
33+
end
34+
35+
---Save model state (favorites and recent) in OpenCode CLI format
36+
---@param state table
37+
local function save_model_state(state)
38+
local state_path = get_model_state_path()
39+
local state_dir = vim.fn.fnamemodify(state_path, ':h')
40+
41+
if not vim.fn.isdirectory(state_dir) then
42+
vim.fn.mkdir(state_dir, 'p')
43+
end
44+
45+
local file = io.open(state_path, 'w')
46+
if not file then
47+
vim.notify('Failed to save model state', vim.log.levels.WARN)
48+
return
49+
end
50+
51+
local ok, json = pcall(vim.json.encode, state)
52+
if not ok then
53+
file:close()
54+
vim.notify('Failed to encode model state', vim.log.levels.WARN)
55+
return
56+
end
57+
58+
file:write(json)
59+
file:close()
60+
end
61+
62+
---Record that a model was accessed
63+
---@param provider_id string
64+
---@param model_id string
65+
local function record_model_access(provider_id, model_id)
66+
local state = load_model_state()
67+
68+
state.recent = vim.tbl_filter(function(item)
69+
return not (item.providerID == provider_id and item.modelID == model_id)
70+
end, state.recent)
71+
72+
table.insert(state.recent, 1, {
73+
providerID = provider_id,
74+
modelID = model_id,
75+
})
76+
77+
if #state.recent > 10 then
78+
for i = #state.recent, 11, -1 do
79+
table.remove(state.recent, i)
80+
end
81+
end
82+
83+
save_model_state(state)
84+
end
85+
86+
---Toggle a model as favorite
87+
---@param provider_id string
88+
---@param model_id string
89+
local function toggle_favorite(provider_id, model_id)
90+
local state = load_model_state()
91+
92+
-- Check if already in favorites
93+
local found_idx = nil
94+
for i, item in ipairs(state.favorite) do
95+
if item.providerID == provider_id and item.modelID == model_id then
96+
found_idx = i
97+
break
98+
end
99+
end
100+
101+
if found_idx then
102+
table.remove(state.favorite, found_idx)
103+
vim.notify('Removed from favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO)
104+
else
105+
table.insert(state.favorite, {
106+
providerID = provider_id,
107+
modelID = model_id,
108+
})
109+
vim.notify('Added to favorites: ' .. provider_id .. '/' .. model_id, vim.log.levels.INFO)
110+
end
111+
112+
save_model_state(state)
113+
end
114+
115+
---Get model index in a state list
116+
---@param provider_id string
117+
---@param model_id string
118+
---@param list table Array of model entries with providerID and modelID
119+
---@return number|nil Index in the list (1-based) or nil if not found
120+
local function get_model_index(provider_id, model_id, list)
121+
for i, item in ipairs(list) do
122+
if item.providerID == provider_id and item.modelID == model_id then
123+
return i
124+
end
125+
end
126+
return nil
127+
end
128+
3129
function M._get_models()
4130
local config_file = require('opencode.config_file')
5131
local response = config_file.get_opencode_providers():wait()
@@ -8,31 +134,132 @@ function M._get_models()
8134
return {}
9135
end
10136

137+
local icons = require('opencode.ui.icons')
138+
local preferred_icon = icons.get('preferred')
139+
local last_used_icon = icons.get('last_used')
140+
141+
local state = load_model_state()
142+
11143
local models = {}
12144
for _, provider in ipairs(response.providers) do
13145
for _, model in pairs(provider.models) do
146+
local provider_id = provider.id
147+
local model_id = model.id
148+
local fav_idx = get_model_index(provider_id, model_id, state.favorite)
149+
local recent_idx = get_model_index(provider_id, model_id, state.recent)
150+
151+
local icon = nil
152+
if fav_idx then
153+
icon = preferred_icon
154+
elseif recent_idx then
155+
icon = last_used_icon
156+
end
157+
14158
table.insert(models, {
15-
provider = provider.id,
16-
model = model.id,
17-
display = provider.name .. ': ' .. model.name,
159+
provider = provider_id,
160+
provider_name = provider.name,
161+
model = model_id,
162+
model_name = model.name,
163+
icon = icon,
164+
favorite_index = fav_idx or 999, -- High number for non-favorite items
165+
recent_index = recent_idx or 999, -- High number for non-recent items
18166
})
19167
end
20168
end
169+
170+
table.sort(models, function(a, b)
171+
if a.favorite_index < 999 and b.favorite_index < 999 then
172+
return a.favorite_index < b.favorite_index
173+
end
174+
175+
if a.favorite_index < 999 and b.favorite_index >= 999 then
176+
return true
177+
end
178+
179+
if a.favorite_index >= 999 and b.favorite_index < 999 then
180+
return false
181+
end
182+
183+
if a.recent_index ~= b.recent_index then
184+
return a.recent_index < b.recent_index
185+
end
186+
187+
if a.provider_name ~= b.provider_name then
188+
return a.provider_name < b.provider_name
189+
end
190+
191+
return a.model_name < b.model_name
192+
end)
193+
21194
return models
22195
end
23196

24197
function M.select(cb)
25198
local models = M._get_models()
199+
local base_picker = require('opencode.ui.base_picker')
200+
201+
local max_provider_width, max_icon_width = 0, 0
202+
for _, m in ipairs(models) do
203+
max_provider_width = math.max(max_provider_width, vim.api.nvim_strwidth(m.provider_name))
204+
if m.icon and m.icon ~= '' then
205+
max_icon_width = math.max(max_icon_width, vim.api.nvim_strwidth(m.icon))
206+
end
207+
end
208+
local icon_width = max_icon_width > 0 and max_icon_width + 1 or 0
209+
local provider_icon_width = max_provider_width + icon_width
26210

27-
local picker = require('opencode.ui.picker')
28-
picker.select(models, {
29-
prompt = 'Select model:',
30-
format_item = function(item)
31-
return item.display
211+
base_picker.pick({
212+
title = 'Select model',
213+
items = models,
214+
format_fn = function(item, width)
215+
local icon = item.icon or ''
216+
local item_width = width or vim.api.nvim_win_get_width(0)
217+
local model_width = item_width - provider_icon_width
218+
219+
local picker_item = {
220+
content = base_picker.align(item.model_name, model_width, { truncate = true }),
221+
time_text = base_picker.align(item.provider_name, max_provider_width, { align = 'left' })
222+
.. (icon_width > 0 and base_picker.align(icon, icon_width, { align = 'right' }) or ''),
223+
debug_text = nil,
224+
}
225+
226+
function picker_item:to_string()
227+
return table.concat({ self.content, self.time_text or '', self.debug_text or '' }, ' ')
228+
end
229+
230+
function picker_item:to_formatted_text()
231+
return {
232+
{ self.content },
233+
self.time_text and { ' ' .. self.time_text, 'OpencodeHint' } or { '' },
234+
self.debug_text and { ' ' .. self.debug_text, 'OpencodeHint' } or { '' },
235+
}
236+
end
237+
238+
return picker_item
32239
end,
33-
}, function(selection)
34-
cb(selection)
35-
end)
240+
actions = {
241+
toggle_favorite = {
242+
key = config.keymap.model_picker.toggle_favorite,
243+
label = 'Toggle favorite',
244+
fn = function(selected)
245+
if not selected then
246+
return models
247+
end
248+
249+
toggle_favorite(selected.provider, selected.model)
250+
251+
return M._get_models()
252+
end,
253+
reload = true,
254+
} --[[@as PickerAction]],
255+
},
256+
callback = function(selection)
257+
if selection then
258+
record_model_access(selection.provider, selection.model)
259+
end
260+
cb(selection)
261+
end,
262+
})
36263
end
37264

38265
return M

0 commit comments

Comments
 (0)