summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThePrimeagen <the.primeagen@gmail.com>2026-02-20 06:23:33 -0700
committerGitHub <noreply@github.com>2026-02-20 06:23:33 -0700
commitf03187fb3c28a33ae85f587c56d73fd4d56f2a6a (patch)
tree386d565a28a9bd171155ca81ef2a4231756c19fd
parentd34e4dcbcc1f95d8f31f851baad18da12c59e7df (diff)
parent4c9b4b16fcd86435fa8c5a1d74a1b67d758ad368 (diff)
downloada4-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.md38
-rw-r--r--lua/99/extensions/fzf_lua.lua72
-rw-r--r--lua/99/extensions/pickers.lua79
-rw-r--r--lua/99/extensions/telescope.lua94
-rw-r--r--lua/99/init.lua20
-rw-r--r--lua/99/providers.lua57
6 files changed, 360 insertions, 0 deletions
diff --git a/README.md b/README.md
index 8ab6a93..2ac774e 100644
--- a/README.md
+++ b/README.md
@@ -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 })