diff options
| author | ThePrimeagen <the.primeagen@gmail.com> | 2026-02-20 06:23:33 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-20 06:23:33 -0700 |
| commit | f03187fb3c28a33ae85f587c56d73fd4d56f2a6a (patch) | |
| tree | 386d565a28a9bd171155ca81ef2a4231756c19fd | |
| parent | d34e4dcbcc1f95d8f31f851baad18da12c59e7df (diff) | |
| parent | 4c9b4b16fcd86435fa8c5a1d74a1b67d758ad368 (diff) | |
| download | a4-f03187fb3c28a33ae85f587c56d73fd4d56f2a6a.tar.xz a4-f03187fb3c28a33ae85f587c56d73fd4d56f2a6a.zip | |
Merge pull request #92 from 0xr3ngar/master
feat: add dynamic model and provider selection ( telescope + fzf_lua plugin support )
| -rw-r--r-- | README.md | 38 | ||||
| -rw-r--r-- | lua/99/extensions/fzf_lua.lua | 72 | ||||
| -rw-r--r-- | lua/99/extensions/pickers.lua | 79 | ||||
| -rw-r--r-- | lua/99/extensions/telescope.lua | 94 | ||||
| -rw-r--r-- | lua/99/init.lua | 20 | ||||
| -rw-r--r-- | lua/99/providers.lua | 57 |
6 files changed, 360 insertions, 0 deletions
@@ -161,6 +161,44 @@ _99.setup({ }) ``` +## Extensions + +### Telescope Model Selector + +If you have [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) installed, you can switch models on the fly via the Telescope picker: + +```lua +vim.keymap.set("n", "<leader>9m", function() + require("99.extensions.telescope").select_model() +end) +``` + +The selected model is used for all subsequent requests in the current session. + +### Telescope Provider Selector + +Switch between providers (OpenCode, Claude, Cursor, Kiro) without restarting Neovim. Switching provider also resets the model to that provider's default. + +```lua +vim.keymap.set("n", "<leader>9p", function() + require("99.extensions.telescope").select_provider() +end) +``` + +### fzf-lua + +If you use [fzf-lua](https://github.com/ibhagwan/fzf-lua) instead of telescope, the same pickers are available: + +```lua +vim.keymap.set("n", "<leader>9m", function() + require("99.extensions.fzf_lua").select_model() +end) + +vim.keymap.set("n", "<leader>9p", function() + require("99.extensions.fzf_lua").select_provider() +end) +``` + ## Reporting a bug To report a bug, please provide the full running debug logs. This may require diff --git a/lua/99/extensions/fzf_lua.lua b/lua/99/extensions/fzf_lua.lua new file mode 100644 index 0000000..e087028 --- /dev/null +++ b/lua/99/extensions/fzf_lua.lua @@ -0,0 +1,72 @@ +local pickers_util = require("99.extensions.pickers") + +local M = {} + +-- move the current value to the top of the list so fzf opens with it focused +--- @param list string[] +--- @param current string +--- @return string[] +local function promote_current(list, current) + local out = { unpack(list) } + for i, item in ipairs(out) do + if item == current then + table.remove(out, i) + table.insert(out, 1, current) + break + end + end + return out +end + +--- @param provider _99.Providers.BaseProvider? +function M.select_model(provider) + pickers_util.get_models(provider, function(models, current) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify( + "99: fzf-lua is required for this extension", + vim.log.levels.ERROR + ) + return + end + + fzf.fzf_exec(promote_current(models, current), { + prompt = "99: Select Model (current: " .. current .. ")> ", + actions = { + ["enter"] = function(selected) + if not selected or #selected == 0 then + return + end + pickers_util.on_model_selected(selected[1]) + end, + }, + }) + end) +end + +function M.select_provider() + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify( + "99: fzf-lua is required for this extension", + vim.log.levels.ERROR + ) + return + end + + local info = pickers_util.get_providers() + + fzf.fzf_exec(promote_current(info.names, info.current), { + prompt = "99: Select Provider (current: " .. info.current .. ")> ", + actions = { + ["enter"] = function(selected) + if not selected or #selected == 0 then + return + end + pickers_util.on_provider_selected(selected[1], info.lookup) + end, + }, + }) +end + +return M diff --git a/lua/99/extensions/pickers.lua b/lua/99/extensions/pickers.lua new file mode 100644 index 0000000..026c5b0 --- /dev/null +++ b/lua/99/extensions/pickers.lua @@ -0,0 +1,79 @@ +local _99 = require("99") + +local M = {} + +local function is_selectable_provider(provider) + return type(provider) == "table" + and type(provider._get_provider_name) == "function" + and type(provider._build_command) == "function" +end + +--- @param provider _99.Providers.BaseProvider? +--- @param callback fun(models: string[], current: string): nil +function M.get_models(provider, callback) + provider = provider or _99.get_provider() + + provider.fetch_models(function(models, err) + if err then + vim.notify("99: " .. err, vim.log.levels.ERROR) + return + end + if not models or #models == 0 then + vim.notify("99: No models available", vim.log.levels.WARN) + return + end + callback(models, _99.get_model()) + end) +end + +--- @return { names: string[], lookup: table<string, _99.Providers.BaseProvider>, current: string } +function M.get_providers() + local names = {} + local lookup = {} + + for name, provider in pairs(_99.Providers) do + if is_selectable_provider(provider) then + table.insert(names, name) + lookup[name] = provider + end + end + table.sort(names) + local current = "" + local current_provider = _99.get_provider() + if is_selectable_provider(current_provider) then + current = current_provider._get_provider_name() + elseif #names > 0 then + current = names[1] + end + + return { + names = names, + lookup = lookup, + current = current, + } +end + +--- @param model string +function M.on_model_selected(model) + _99.set_model(model) + vim.notify("99: Model set to " .. model) +end + +--- @param name string +--- @param lookup table<string, _99.Providers.BaseProvider> +function M.on_provider_selected(name, lookup) + local provider = lookup[name] + if not provider then + vim.notify( + "99: Invalid provider selection: " .. tostring(name), + vim.log.levels.ERROR + ) + return + end + _99.set_provider(provider) + vim.notify( + "99: Provider set to " .. name .. " (model: " .. _99.get_model() .. ")" + ) +end + +return M diff --git a/lua/99/extensions/telescope.lua b/lua/99/extensions/telescope.lua new file mode 100644 index 0000000..a899df2 --- /dev/null +++ b/lua/99/extensions/telescope.lua @@ -0,0 +1,94 @@ +local pickers_util = require("99.extensions.pickers") + +local M = {} + +--- @param list string[] +--- @param value string +--- @return number +local function index_of(list, value) + for i, item in ipairs(list) do + if item == value then + return i + end + end + return 1 +end + +--- @param provider _99.Providers.BaseProvider? +function M.select_model(provider) + pickers_util.get_models(provider, function(models, current) + local ok, pickers = pcall(require, "telescope.pickers") + if not ok then + vim.notify( + "99: telescope.nvim is required for this extension", + vim.log.levels.ERROR + ) + return + end + + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + + pickers + .new({}, { + prompt_title = "99: Select Model (current: " .. current .. ")", + default_selection_index = index_of(models, current), + finder = finders.new_table({ results = models }), + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + actions.close(prompt_bufnr) + local selection = action_state.get_selected_entry() + if not selection then + return + end + pickers_util.on_model_selected(selection[1]) + end) + return true + end, + }) + :find() + end) +end + +function M.select_provider() + local ok, pickers = pcall(require, "telescope.pickers") + if not ok then + vim.notify( + "99: telescope.nvim is required for this extension", + vim.log.levels.ERROR + ) + return + end + + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + + local info = pickers_util.get_providers() + + pickers + .new({}, { + prompt_title = "99: Select Provider (current: " .. info.current .. ")", + default_selection_index = index_of(info.names, info.current), + finder = finders.new_table({ results = info.names }), + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + actions.close(prompt_bufnr) + local selection = action_state.get_selected_entry() + if not selection then + return + end + pickers_util.on_provider_selected(selection[1], info.lookup) + end) + return true + end, + }) + :find() +end + +return M diff --git a/lua/99/init.lua b/lua/99/init.lua index f804f2f..a512ab6 100644 --- a/lua/99/init.lua +++ b/lua/99/init.lua @@ -667,6 +667,26 @@ function _99.set_model(model) return _99 end +--- @return string +function _99.get_model() + return _99_state.model +end + +--- @return _99.Providers.BaseProvider +function _99.get_provider() + return _99_state.provider_override or Providers.OpenCodeProvider +end + +--- @param provider _99.Providers.BaseProvider +--- @return _99 +function _99.set_provider(provider) + _99_state.provider_override = provider + if provider._get_default_model then + _99_state.model = provider._get_default_model() + end + return _99 +end + function _99.__debug() Logger:configure({ path = nil, diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 82f8b6a..3a2c3ec 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -22,6 +22,11 @@ end --- @field _get_provider_name fun(self: _99.Providers.BaseProvider): string local BaseProvider = {} +--- @param callback fun(models: string[]|nil, err: string|nil): nil +function BaseProvider.fetch_models(callback) + callback(nil, "This provider does not support listing models") +end + --- @param request _99.Request function BaseProvider:_retrieve_response(request) local logger = request.logger:set_area(self:_get_provider_name()) @@ -161,6 +166,19 @@ function OpenCodeProvider._get_default_model() return "opencode/claude-sonnet-4-5" end +function OpenCodeProvider.fetch_models(callback) + vim.system({ "opencode", "models" }, { text = true }, function(obj) + vim.schedule(function() + if obj.code ~= 0 then + callback(nil, "Failed to fetch models from opencode") + return + end + local models = vim.split(obj.stdout, "\n", { trimempty = true }) + callback(models, nil) + end) + end) +end + --- @class ClaudeCodeProvider : _99.Providers.BaseProvider local ClaudeCodeProvider = setmetatable({}, { __index = BaseProvider }) @@ -188,6 +206,24 @@ function ClaudeCodeProvider._get_default_model() return "claude-sonnet-4-5" end +-- TODO: the claude CLI has no way to list available models. +-- We could use the Anthropic API (https://docs.anthropic.com/en/api/models) +-- but that requires the user to have an ANTHROPIC_API_KEY set which isn't ideal. +-- Until Anthropic adds a CLI command for this, we have to hardcode the list here. +-- See https://github.com/anthropics/claude-code/issues/12612 +function ClaudeCodeProvider.fetch_models(callback) + callback({ + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-sonnet-4-0", + "claude-opus-4-0", + "claude-3-7-sonnet-latest", + }, nil) +end + --- @class CursorAgentProvider : _99.Providers.BaseProvider local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) @@ -208,6 +244,27 @@ function CursorAgentProvider._get_default_model() return "sonnet-4.5" end +function CursorAgentProvider.fetch_models(callback) + vim.system({ "cursor-agent", "models" }, { text = true }, function(obj) + vim.schedule(function() + if obj.code ~= 0 then + callback(nil, "Failed to fetch models from cursor-agent") + return + end + local models = {} + for _, line in ipairs(vim.split(obj.stdout, "\n", { trimempty = true })) do + -- `cursor-agent models` outputs lines like "model-id - description", + -- so we grab everything before the first " - " separator + local id = line:match("^(%S+)%s+%-") + if id then + table.insert(models, id) + end + end + callback(models, nil) + end) + end) +end + --- @class KiroProvider : _99.Providers.BaseProvider local KiroProvider = setmetatable({}, { __index = BaseProvider }) |
