diff --git a/README.md b/README.md index b070be1b..f0b779d9 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,7 @@ These keybindings are automatically set in Agentic buffers: | `s` | n | Switch ACP provider (preserves chat history) | | `m` | n | Switch model without (preserves chat history) | | `t` | n | Select thought effort level (model-dependent on Claude) | +| `o` | n | Open options modal | | `q` | n | Close chat widget | | `d` | n | Remove file, code selection, or diagnostic at cursor | | `d` | v | Remove multiple selected files, code selections, or diagnostics | @@ -758,6 +759,7 @@ your setup: switch_provider = "s", -- Switch ACP provider switch_model = "m", -- Switch model change_thought_level = "t", -- Select thought effort level + open_options = "o", -- Open options modal }, -- Keybindings for the prompt buffer only diff --git a/doc/agentic.txt b/doc/agentic.txt index fe15703f..4ff1ebab 100644 --- a/doc/agentic.txt +++ b/doc/agentic.txt @@ -577,6 +577,7 @@ These keybindings are automatically set in Agentic buffers: s n Switch ACP provider (keeps history) m n Switch model (keeps history) t n Select thought effort level (model-dependent) + o n Open options modal q n Close chat widget d n/v Remove file/selection/diagnostic ]] n Next chat heading @@ -612,6 +613,7 @@ Override default keybindings via the `keymaps` config option: switch_provider = "s", switch_model = "m", change_thought_level = "t", + open_options = "o", }, prompt = { submit = { diff --git a/lua/agentic/acp/acp_client.lua b/lua/agentic/acp/acp_client.lua index d33d74e1..8c7dac29 100644 --- a/lua/agentic/acp/acp_client.lua +++ b/lua/agentic/acp/acp_client.lua @@ -73,6 +73,11 @@ function ACPClient:new(config, on_ready) writeTextFile = false, }, terminal = false, + session = { + configOptions = { + boolean = vim.empty_dict(), + }, + }, }, auth_methods = {}, ready_listeners = {}, @@ -780,22 +785,9 @@ function ACPClient:set_mode(session_id, mode_id, callback) end --- Set a config option value for a session ---- @param session_id string ---- @param config_id string ---- @param config_value string +--- @param params agentic.acp.SetConfigOptionParams --- @param callback fun(result: table|nil, err: agentic.acp.ACPError|nil) -function ACPClient:set_config_option( - session_id, - config_id, - config_value, - callback -) - local params = { - sessionId = session_id, - configId = config_id, - value = config_value, - } - +function ACPClient:set_config_option(params, callback) self:_send_request("session/set_config_option", params, callback) end diff --git a/lua/agentic/acp/acp_client.test.lua b/lua/agentic/acp/acp_client.test.lua index adfc3771..b738fb44 100644 --- a/lua/agentic/acp/acp_client.test.lua +++ b/lua/agentic/acp/acp_client.test.lua @@ -30,6 +30,9 @@ describe("ACPClient", function() --- @type fun(message: agentic.acp.ResponseRaw)|nil local captured_on_message + --- @type agentic.acp.InitializeParams|nil + local captured_initialize_params + local PROMPT_CAPS = { image = false, audio = false, embeddedContext = false } @@ -71,6 +74,7 @@ describe("ACPClient", function() transport_send_stub:invokes(function(_self, data) local decoded = vim.json.decode(data) if decoded.method == "initialize" and captured_on_message then + captured_initialize_params = decoded.params captured_on_message({ jsonrpc = "2.0", method = "initialize", @@ -118,6 +122,7 @@ describe("ACPClient", function() before_each(function() package.loaded["agentic.acp.acp_client"] = nil package.loaded["agentic.acp.acp_transport"] = nil + captured_initialize_params = nil local Logger = require("agentic.utils.logger") logger_debug_stub = spy.stub(Logger, "debug") @@ -151,6 +156,28 @@ describe("ACPClient", function() create_transport_stub:revert() end) + describe("initialize", function() + it("advertises boolean session config options as an object", function() + assert.equal("[]", vim.json.encode({})) + create_ready_client() + + assert.is_not_nil(captured_initialize_params) + --- @cast captured_initialize_params agentic.acp.InitializeParams + assert.is_not_nil( + captured_initialize_params.clientCapabilities.session + ) + + local config_options = + captured_initialize_params.clientCapabilities.session.configOptions + assert.is_not_nil(config_options) + --- @cast config_options agentic.acp.SessionConfigOptionsCapabilities + assert.is_not_nil(config_options.boolean) + + local encoded = vim.json.encode(captured_initialize_params) + assert.is_not_nil(encoded:find('"boolean":{}', 1, true)) + end) + end) + describe("list_sessions", function() it("sends session/list request", function() local client = create_ready_client(LIST_CAPS) @@ -278,6 +305,77 @@ describe("ACPClient", function() end) end) + describe("set_config_option", function() + it("sends select values without a type", function() + local client = create_ready_client() + --- @diagnostic disable-next-line: invisible + local send_request_stub = spy.stub(client, "_send_request") + + client:set_config_option({ + sessionId = "s1", + configId = "model", + value = "opus", + }, function() end) + + local params = send_request_stub.calls[1][3] + assert.equal( + "session/set_config_option", + send_request_stub.calls[1][2] + ) + assert.same({ + sessionId = "s1", + configId = "model", + value = "opus", + }, params) + assert.is_nil(params.type) + end) + + it("sends boolean true with the boolean type", function() + local client = create_ready_client() + --- @diagnostic disable-next-line: invisible + local send_request_stub = spy.stub(client, "_send_request") + + client:set_config_option({ + sessionId = "s1", + configId = "fast", + type = "boolean", + value = true, + }, function() end) + + assert.equal( + "session/set_config_option", + send_request_stub.calls[1][2] + ) + assert.same({ + sessionId = "s1", + configId = "fast", + type = "boolean", + value = true, + }, send_request_stub.calls[1][3]) + end) + + it("sends boolean false with the boolean type", function() + local client = create_ready_client() + --- @diagnostic disable-next-line: invisible + local send_request_stub = spy.stub(client, "_send_request") + + client:set_config_option({ + sessionId = "s1", + configId = "fast", + type = "boolean", + value = false, + }, function() end) + + local params = send_request_stub.calls[1][3] + assert.equal( + "session/set_config_option", + send_request_stub.calls[1][2] + ) + assert.is_false(params.value) + assert.equal("boolean", params.type) + end) + end) + describe("_drain_pending_callbacks", function() local original_schedule = vim.schedule diff --git a/lua/agentic/acp/acp_client_types.lua b/lua/agentic/acp/acp_client_types.lua index 5a20db4f..69f9ee82 100644 --- a/lua/agentic/acp/acp_client_types.lua +++ b/lua/agentic/acp/acp_client_types.lua @@ -7,9 +7,16 @@ --- @field name string --- @field version string +--- @class agentic.acp.SessionConfigOptionsCapabilities +--- @field boolean? table + +--- @class agentic.acp.ClientSessionCapabilities +--- @field configOptions? agentic.acp.SessionConfigOptionsCapabilities + --- @class agentic.acp.ClientCapabilities --- @field fs agentic.acp.FileSystemCapability --- @field terminal boolean +--- @field session? agentic.acp.ClientSessionCapabilities --- @class agentic.acp.InitializeParams --- @field protocolVersion number @@ -173,28 +180,55 @@ --- @field currentModelId string --- @class agentic.acp.ConfigOption.Option ---- @field description string +--- @field description? string --- @field name string --- @field value string --- @alias agentic.acp.ConfigOption.Category --- | "mode" --- | "model" +--- | "model_config" --- | "thought_level" +--- | "other" --- @class agentic.acp.ConfigOption --- @field id string ---- @field category agentic.acp.ConfigOption.Category +--- @field category? agentic.acp.ConfigOption.Category +--- @field type? "select" --- @field currentValue string --- @field description string --- @field name string ---- @field options agentic.acp.ConfigOption.Option[] +--- @field options? agentic.acp.ConfigOption.Option[] + +--- @class agentic.acp.BooleanConfigOption : agentic.acp.ConfigOption +--- @field type "boolean" +--- @field currentValue boolean +--- @field description? string + +--- @class agentic.acp.SelectConfigOptionParams +--- @field sessionId string +--- @field configId string +--- @field value string + +--- @class agentic.acp.BooleanConfigOptionParams +--- @field sessionId string +--- @field configId string +--- @field type "boolean" +--- @field value boolean + +--- @alias agentic.acp.SetConfigOptionParams +--- | agentic.acp.SelectConfigOptionParams +--- | agentic.acp.BooleanConfigOptionParams + +--- @alias agentic.acp.AnyConfigOption +--- | agentic.acp.ConfigOption +--- | agentic.acp.BooleanConfigOption --- @class agentic.acp.SessionCreationResponse --- @field sessionId string --- @field modes? agentic.acp.ModesInfo --- @field models? agentic.acp.ModelsInfo ---- @field configOptions? agentic.acp.ConfigOption[] +--- @field configOptions? agentic.acp.AnyConfigOption[] --- @alias agentic.acp.ResponseRawParams --- | { sessionId: string, update: agentic.acp.SessionUpdateMessage } @@ -257,7 +291,7 @@ --- @class agentic.acp.ConfigOptionsUpdate --- @field sessionUpdate "config_option_update" ---- @field configOptions agentic.acp.ConfigOption[] +--- @field configOptions agentic.acp.AnyConfigOption[] --- @alias agentic.acp.SessionUpdateMessage --- | agentic.acp.UserMessageChunk diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index 65295433..9fef16d0 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -18,6 +18,7 @@ local CATEGORY_ALIASES = { --- @field get_session_id fun(): string|nil --- @class agentic.acp.AgentConfigOptions +--- @field options agentic.acp.AnyConfigOption[] --- @field mode? agentic.acp.ConfigOption --- @field model? agentic.acp.ConfigOption --- @field thought_level? agentic.acp.ConfigOption @@ -35,6 +36,7 @@ function AgentConfigOptions:new(buffers, callbacks) local AgentModels = require("agentic.acp.agent_models") self = setmetatable({ + options = {}, mode = nil, model = nil, thought_level = nil, @@ -70,12 +72,22 @@ function AgentConfigOptions:new(buffers, callbacks) end, { desc = "Agentic: Select Thought Effort Level" } ) + + BufHelpers.multi_keymap_set( + Config.keymaps.widget.open_options, + bufnr, + function() + self:_show_options_modal() + end, + { desc = "Agentic: Open Options" } + ) end return self end function AgentConfigOptions:clear() + self.options = {} self.mode = nil self.model = nil self.thought_level = nil @@ -84,6 +96,7 @@ function AgentConfigOptions:clear() end --- @class agentic.acp.AgentConfigOptions.Snapshot +--- @field options agentic.acp.AnyConfigOption[] --- @field mode? agentic.acp.ConfigOption --- @field model? agentic.acp.ConfigOption --- @field thought_level? agentic.acp.ConfigOption @@ -97,6 +110,7 @@ end function AgentConfigOptions:snapshot() --- @type agentic.acp.AgentConfigOptions.Snapshot local snapshot = { + options = self.options, mode = self.mode, model = self.model, thought_level = self.thought_level, @@ -108,6 +122,7 @@ end --- @param snapshot agentic.acp.AgentConfigOptions.Snapshot function AgentConfigOptions:restore_snapshot(snapshot) + self.options = snapshot.options self.mode = snapshot.mode self.model = snapshot.model self.thought_level = snapshot.thought_level @@ -115,7 +130,7 @@ function AgentConfigOptions:restore_snapshot(snapshot) self.legacy_agent_models:restore(snapshot.legacy_models) end ---- @param configOptions agentic.acp.ConfigOption[]|nil +--- @param configOptions agentic.acp.AnyConfigOption[]|nil function AgentConfigOptions:set_options(configOptions) self:clear() @@ -130,16 +145,19 @@ function AgentConfigOptions:set_options(configOptions) local raw = type(option.category) == "string" and option.category or "" local cat = CATEGORY_ALIASES[raw] or raw - local stored_option = vim.deepcopy(option) - - if cat == "mode" then - self.mode = stored_option - elseif cat == "model" then - self.model = stored_option - elseif cat == "thought_level" then - self.thought_level = stored_option - elseif cat:sub(1, 1) ~= "_" then - Logger.debug("Unknown config option", option) + if cat:sub(1, 1) ~= "_" then + local stored_option = vim.deepcopy(option) + self.options[#self.options + 1] = stored_option + + if option.type ~= "boolean" and cat == "mode" then + self.mode = stored_option + elseif option.type ~= "boolean" and cat == "model" then + self.model = stored_option + elseif option.type ~= "boolean" and cat == "thought_level" then + self.thought_level = stored_option + elseif cat ~= "" and cat ~= "model_config" and cat ~= "other" then + Logger.debug("Unknown config option", option) + end end end end @@ -281,6 +299,35 @@ function AgentConfigOptions:get_model_id() or self.legacy_agent_models.current_model_id end +function AgentConfigOptions:_show_options_modal() + local session_id = self.callbacks.get_session_id() + + if #self.options == 0 or not session_id then + Logger.notify( + "No config options are available", + vim.log.levels.WARN, + { title = "Agentic" } + ) + return + end + + local ConfigOptionsModal = require("agentic.ui.config_options_modal") + ConfigOptionsModal:new({ + get_options = function() + return self.options + end, + is_session_active = function() + return self.callbacks.get_session_id() == session_id + end, + handle_change = function(config_id, value, on_done) + self:handle_change(config_id, value, on_done) + end, + show_selector = function(option, prompt, handle_change) + return self:_show_selector(option, prompt, handle_change) + end, + }):open() +end + --- @param mode_value string --- @return agentic.acp.ConfigOption.Option|nil function AgentConfigOptions:get_mode(mode_value) @@ -516,9 +563,95 @@ function AgentConfigOptions:_make_change_response( end end +--- @param config_id string +--- @param value string|boolean +--- @param on_done fun()|nil +function AgentConfigOptions:handle_change(config_id, value, on_done) + --- @type agentic.acp.AnyConfigOption|nil + local target + for _, option in ipairs(self.options) do + if option.id == config_id then + target = option + break + end + end + + if not target then + Logger.debug("Unknown config option", config_id) + return + end + + local session_id = self.callbacks.get_session_id() + + if not session_id then + return + end + + local agent = self.callbacks.get_agent_instance() + + if not agent then + return + end + + local response = self:_make_change_response( + session_id, + target.name, + tostring(value), + function(result) + if target.type == "boolean" and type(value) == "boolean" then + target.currentValue = value + elseif target.type ~= "boolean" and type(value) == "string" then + target.currentValue = value + + if target.category == "mode" then + self.legacy_agent_modes.current_mode_id = value + self.callbacks.on_set_mode_success(value) + elseif target.category == "model" then + self.legacy_agent_models.current_model_id = value + end + end + + if result and type(result.configOptions) == "table" then + self:set_options(result.configOptions) + end + + self.callbacks.on_config_options_applied() + Logger.notify( + target.name .. " changed to: " .. tostring(value), + vim.log.levels.INFO, + { title = "Agentic Setting changed" } + ) + + if on_done then + on_done() + end + end + ) + + if target.type == "boolean" and type(value) == "boolean" then + agent:set_config_option({ + sessionId = session_id, + configId = config_id, + type = "boolean", + value = value, + }, response) + elseif target.type ~= "boolean" and type(value) == "string" then + agent:set_config_option({ + sessionId = session_id, + configId = config_id, + value = value, + }, response) + end +end + --- @param mode_id string --- @param is_legacy boolean function AgentConfigOptions:handle_mode_change(mode_id, is_legacy) + if not is_legacy then + self:handle_change(self.mode.id, mode_id) + return + end + local session_id = self.callbacks.get_session_id() if not session_id then @@ -538,9 +671,6 @@ function AgentConfigOptions:handle_mode_change(mode_id, is_legacy) function(result) -- keep legacy state in sync so legacy selectors reflect the change self.legacy_agent_modes.current_mode_id = mode_id - if not is_legacy and self.mode then - self.mode.currentValue = mode_id - end if result and type(result.configOptions) == "table" then Logger.debug("received result after setting mode") @@ -558,17 +688,18 @@ function AgentConfigOptions:handle_mode_change(mode_id, is_legacy) end ) - if is_legacy then - agent:set_mode(session_id, mode_id, response) - else - agent:set_config_option(session_id, self.mode.id, mode_id, response) - end + agent:set_mode(session_id, mode_id, response) end --- @param model_id string --- @param is_legacy boolean --- @param on_done fun()|nil function AgentConfigOptions:handle_model_change(model_id, is_legacy, on_done) + if not is_legacy then + self:handle_change(self.model.id, model_id, on_done) + return + end + local session_id = self.callbacks.get_session_id() if not session_id then @@ -588,9 +719,6 @@ function AgentConfigOptions:handle_model_change(model_id, is_legacy, on_done) function(result) -- keep legacy state in sync so legacy selectors reflect the change self.legacy_agent_models.current_model_id = model_id - if not is_legacy and self.model then - self.model.currentValue = model_id - end if result and type(result.configOptions) == "table" then Logger.debug("received result after setting model") @@ -610,59 +738,17 @@ function AgentConfigOptions:handle_model_change(model_id, is_legacy, on_done) end ) - if is_legacy then - agent:set_model(session_id, model_id, response) - else - agent:set_config_option(session_id, self.model.id, model_id, response) - end + agent:set_model(session_id, model_id, response) end --- @param value string function AgentConfigOptions:handle_thought_level_change(value) - local session_id = self.callbacks.get_session_id() - - if not session_id then - return - end - - local thought = self.thought_level - - if not thought then + if not self.thought_level then Logger.debug("no thought_level option available") return end - local config_id = thought.id - local agent = self.callbacks.get_agent_instance() - - if not agent then - return - end - - local response = self:_make_change_response( - session_id, - "thought effort level", - value, - function(result) - if self.thought_level then - self.thought_level.currentValue = value - end - - if result and type(result.configOptions) == "table" then - Logger.debug("received result after setting thought_level") - self:set_options(result.configOptions) - end - self.callbacks.on_config_options_applied() - - Logger.notify( - "Thought effort level changed to: " .. value, - vim.log.levels.INFO, - { title = "Agentic Thought Effort Level changed" } - ) - end - ) - - agent:set_config_option(session_id, config_id, value, response) + self:handle_change(self.thought_level.id, value) end return AgentConfigOptions diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 2fda534e..c30bb7ca 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -62,6 +62,39 @@ describe("agentic.acp.AgentConfigOptions", function() }, } + --- @type agentic.acp.ConfigOption + local fast_option = { + id = "fast", + category = "model_config", + currentValue = "off", + description = "Fast mode", + name = "Fast", + options = { + { value = "on", name = "On" }, + { value = "off", name = "Off" }, + }, + } + + --- @type agentic.acp.ConfigOption + local agent_option = { + id = "agent", + currentValue = "default", + description = "Agent selection", + name = "Agent", + options = { + { value = "default", name = "Default" }, + }, + } + + --- @type agentic.acp.BooleanConfigOption + local boolean_option = { + id = "auto-approve", + category = "other", + type = "boolean", + currentValue = true, + name = "Auto Approve", + } + --- @type agentic.acp.ConfigOption local multi_thought = { id = "thought-multi", @@ -99,14 +132,16 @@ describe("agentic.acp.AgentConfigOptions", function() --- @param agent any Fake ACP client capturing setter calls --- @param session_holder { id: string|nil } --- @param on_config_options_applied? fun() Override to spy header refresh + --- @param on_set_mode_success? fun(mode_id: string) --- @return agentic.acp.AgentConfigOptions local function make_with_agent( agent, session_holder, - on_config_options_applied + on_config_options_applied, + on_set_mode_success ) return AgentConfigOptions:new({ chat = test_bufnr }, { - on_set_mode_success = function() end, + on_set_mode_success = on_set_mode_success or function() end, on_config_options_applied = on_config_options_applied or function() end, get_agent_instance = function() @@ -133,22 +168,157 @@ describe("agentic.acp.AgentConfigOptions", function() end) describe("constructor", function() - it( - "registers 3 keymaps per buffer (mode, model, thought_level)", - function() - assert.stub(multi_keymap_stub).was.called(3) + it("registers 4 keymaps per buffer", function() + local Config = require("agentic.config") - for i = 1, 3 do - assert.equal( - "function", - type(multi_keymap_stub.calls[i][3]) - ) + assert.stub(multi_keymap_stub).was.called(4) + assert.equal("o", Config.keymaps.widget.open_options) + + for i = 1, 4 do + assert.equal("function", type(multi_keymap_stub.calls[i][3])) + end + + assert.equal( + Config.keymaps.widget.open_options, + multi_keymap_stub.calls[4][1] + ) + assert.equal(test_bufnr, multi_keymap_stub.calls[4][2]) + assert.equal( + "Agentic: Open Options", + multi_keymap_stub.calls[4][4].desc + ) + end) + end) + + describe("open options keymap", function() + local modal_module_name = "agentic.ui.config_options_modal" + local original_modal_module + --- @type TestStub + local notify_stub + --- @type TestSpy + local modal_new_spy + --- @type TestSpy + local modal_open_spy + + --- @return function callback + local function get_open_options_callback() + local open_options = + require("agentic.config").keymaps.widget.open_options + + for i = #multi_keymap_stub.calls, 1, -1 do + local call = multi_keymap_stub.calls[i] + if vim.deep_equal(call[1], open_options) then + return call[3] end end - ) + + error("open_options keymap was not registered") + end + + before_each(function() + original_modal_module = package.loaded[modal_module_name] + modal_open_spy = spy.new() + local modal = { open = modal_open_spy } + modal_new_spy = spy.new(function() + return modal + end) + package.loaded[modal_module_name] = { new = modal_new_spy } + notify_stub = spy.stub(require("agentic.utils.logger"), "notify") + end) + + after_each(function() + notify_stub:revert() + package.loaded[modal_module_name] = original_modal_module + end) + + it("warns without opening when no config options exist", function() + local config = make_with_agent({}, { id = "sess-1" }) + + get_open_options_callback()() + + assert.stub(notify_stub).was.called(1) + assert.equal(vim.log.levels.WARN, notify_stub.calls[1][2]) + assert.spy(modal_new_spy).was.called(0) + assert.spy(modal_open_spy).was.called(0) + assert.equal(0, #config.options) + end) + + it("warns without opening when the session id is nil", function() + local config = make_with_agent({}, { id = nil }) + config:set_options({ model_option }) + + get_open_options_callback()() + + assert.stub(notify_stub).was.called(1) + assert.equal(vim.log.levels.WARN, notify_stub.calls[1][2]) + assert.spy(modal_new_spy).was.called(0) + assert.spy(modal_open_spy).was.called(0) + end) + + it("opens the modal with config option callbacks", function() + local get_session_id_spy = spy.new(function() + return "sess-captured" + end) + local config = AgentConfigOptions:new({ chat = test_bufnr }, { + on_set_mode_success = function() end, + on_config_options_applied = function() end, + get_agent_instance = function() + return nil + end, + get_session_id = get_session_id_spy --[[@as fun(): string|nil]], + }) + config:set_options({ model_option }) + + get_open_options_callback()() + + assert.spy(get_session_id_spy).was.called(1) + assert.spy(modal_new_spy).was.called(1) + local modal_callbacks = modal_new_spy.calls[1][2] + assert.is_true(modal_callbacks.get_options() == config.options) + assert.is_true(modal_callbacks.is_session_active()) + assert.equal("function", type(modal_callbacks.handle_change)) + assert.equal("function", type(modal_callbacks.show_selector)) + assert.spy(modal_open_spy).was.called(1) + assert.stub(notify_stub).was.called(0) + end) end) describe("set_options", function() + it( + "retains non-extension options in wire order with named views", + function() + config_options:set_options({ + mode_option, + model_option, + thought_option, + fast_option, + agent_option, + boolean_option, + }) + + assert.equal(6, #config_options.options) + assert.equal("mode-1", config_options.options[1].id) + assert.equal("model-1", config_options.options[2].id) + assert.equal("thought-1", config_options.options[3].id) + assert.equal("fast", config_options.options[4].id) + assert.equal("agent", config_options.options[5].id) + assert.equal("auto-approve", config_options.options[6].id) + assert.equal(true, config_options.options[6].currentValue) + assert.is_true(config_options.mode == config_options.options[1]) + assert.is_true( + config_options.model == config_options.options[2] + ) + assert.is_true( + config_options.thought_level == config_options.options[3] + ) + + config_options:set_options({ agent_option }) + + assert.equal(1, #config_options.options) + assert.equal("agent", config_options.options[1].id) + end + ) + it("assigns all known categories from a single call", function() config_options:set_options({ mode_option, @@ -164,6 +334,7 @@ describe("agentic.acp.AgentConfigOptions", function() it("does nothing when configOptions is nil", function() config_options:set_options(nil) + assert.equal(0, #config_options.options) assert.is_nil(config_options.mode) assert.is_nil(config_options.model) assert.is_nil(config_options.thought_level) @@ -218,11 +389,23 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:set_options({ custom }) assert.equal(0, debug_stub.call_count) + assert.equal(0, #config_options.options) assert.is_nil(config_options.mode) assert.is_nil(config_options.model) assert.is_nil(config_options.thought_level) end) + it("does not log known generic or uncategorized options", function() + config_options:set_options({ + fast_option, + boolean_option, + agent_option, + }) + + assert.equal(0, debug_stub.call_count) + assert.equal(3, #config_options.options) + end) + it("logs debug for unknown non-underscore categories", function() local unknown = vim.tbl_extend("force", mode_option, { category = "totally_made_up", @@ -231,6 +414,7 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:set_options({ unknown }) assert.equal(1, debug_stub.call_count) + assert.equal(1, #config_options.options) assert.is_nil(config_options.mode) assert.is_nil(config_options.model) assert.is_nil(config_options.thought_level) @@ -428,10 +612,9 @@ describe("agentic.acp.AgentConfigOptions", function() assert.stub(set_config_stub).was.called(1) assert.stub(legacy_stub).was.called(0) local call = set_config_stub.calls[1] - -- call[1]=self, [2]=session_id, [3]=configId, [4]=value - assert.equal("s1", call[2]) - assert.equal(case.config_id, call[3]) - assert.equal(case.other_value, call[4]) + assert.equal("s1", call[2].sessionId) + assert.equal(case.config_id, call[2].configId) + assert.equal(case.other_value, call[2].value) end ) @@ -541,12 +724,141 @@ describe("agentic.acp.AgentConfigOptions", function() assert.stub(set_config_stub).was.called(1) local call = set_config_stub.calls[1] - assert.equal("model-1", call[3]) - assert.equal("claude-opus", call[4]) + assert.equal("model-1", call[2].configId) + assert.equal("claude-opus", call[2].value) end ) end) + describe("handle_change", function() + --- @type TestStub + local notify_stub + --- @type TestStub + local set_config_stub + --- @type { id: string|nil } + local session_holder + --- @type agentic.acp.AgentConfigOptions + local config + + before_each(function() + --- @type any + local agent = { set_config_option = function() end } + session_holder = { id = "s1" } + config = make_with_agent(agent, session_holder) + config:set_options({ + model_option, + thought_option, + { + id = "darkmode", + category = "other", + type = "boolean", + currentValue = false, + name = "Dark Mode", + }, + }) + set_config_stub = spy.stub(agent, "set_config_option") + notify_stub = spy.stub(require("agentic.utils.logger"), "notify") + end) + + after_each(function() + set_config_stub:revert() + notify_stub:revert() + end) + + it("dispatches select option params", function() + config:handle_change("model-1", "claude-opus") + + assert.stub(set_config_stub).was.called(1) + local call = set_config_stub.calls[1] + assert.same(call[2], { + sessionId = "s1", + configId = "model-1", + value = "claude-opus", + }) + assert.equal("function", type(call[3])) + end) + + it("dispatches boolean option params", function() + config:handle_change("darkmode", true) + + assert.stub(set_config_stub).was.called(1) + local call = set_config_stub.calls[1] + assert.same(call[2], { + sessionId = "s1", + configId = "darkmode", + type = "boolean", + value = true, + }) + assert.equal("function", type(call[3])) + end) + + it("skips unknown options and missing sessions", function() + config:handle_change("nonexistent", "x") + session_holder.id = nil + config:handle_change("model-1", "claude-opus") + + assert.stub(set_config_stub).was.called(0) + end) + + it("skips values that do not match the option type", function() + config:handle_change("model-1", true) + config:handle_change("darkmode", "yes") + + assert.stub(set_config_stub).was.called(0) + end) + + it("applies the value and invokes callbacks in order", function() + local applied = spy.new(function() end) + local on_applied = spy.new(function() + assert.equal("claude-opus", config.model.currentValue) + assert.equal(1, applied.call_count) + end) + --- @type any + local agent = { + set_config_option = function(_self, _params, cb) + cb({}, nil) + end, + } + config = + make_with_agent(agent, session_holder, applied --[[@as fun()]]) + config:set_options({ model_option }) + + config:handle_change( + "model-1", + "claude-opus", + on_applied --[[@as fun()]] + ) + + assert.equal("claude-opus", config.model.currentValue) + assert.equal(1, applied.call_count) + assert.equal(1, on_applied.call_count) + assert.has_no_errors(function() + config:handle_change("model-1", "claude-sonnet") + end) + end) + + it("refreshes returned config options before on_applied", function() + local refreshed_model = vim.tbl_extend("force", model_option, { + currentValue = "claude-opus", + }) --[[@as agentic.acp.ConfigOption]] + --- @type any + local agent = { + set_config_option = function(_self, _params, cb) + cb({ configOptions = { refreshed_model } }, nil) + end, + } + config = make_with_agent(agent, session_holder) + config:set_options({ model_option }) + + config:handle_change("model-1", "claude-opus", function() + assert.equal("claude-opus", config.model.currentValue) + assert.is_true(config.model ~= model_option) + end) + + assert.equal("claude-opus", config.model.currentValue) + end) + end) + describe("successful changes without returned configOptions", function() --- @type TestStub local notify_stub @@ -560,25 +872,32 @@ describe("agentic.acp.AgentConfigOptions", function() end) it("updates modern mode currentValue", function() + local mode_applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({}, nil) end, } - local config = make_with_agent(agent, { id = "s1" }) + local config = make_with_agent( + agent, + { id = "s1" }, + nil, + mode_applied --[[@as fun(mode_id: string)]] + ) config:set_options({ mode_option }) config:handle_mode_change("plan", false) assert.equal("plan", config:get_mode_id()) + assert.spy(mode_applied).was.called_with("plan") end) it("updates modern model currentValue and refreshes headers", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({}, nil) end, } @@ -586,10 +905,16 @@ describe("agentic.acp.AgentConfigOptions", function() make_with_agent(agent, { id = "s1" }, applied --[[@as fun()]]) config:set_options({ model_option }) - config:handle_model_change("claude-opus", false) + local on_done = spy.new(function() end) + config:handle_model_change( + "claude-opus", + false, + on_done --[[@as fun()]] + ) assert.equal("claude-opus", config:get_model_id()) assert.equal(1, applied.call_count) + assert.equal(1, on_done.call_count) end) it( @@ -598,7 +923,7 @@ describe("agentic.acp.AgentConfigOptions", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({}, nil) end, } @@ -615,6 +940,44 @@ describe("agentic.acp.AgentConfigOptions", function() assert.equal(1, applied.call_count) end ) + + it("preserves legacy mode and model success callbacks", function() + local mode_applied = spy.new(function() end) + local model_done = spy.new(function() end) + --- @type any + local agent = { + set_mode = function(_self, _sid, _value, cb) + cb({}, nil) + end, + set_model = function(_self, _sid, _value, cb) + cb({}, nil) + end, + } + local config = make_with_agent( + agent, + { id = "s1" }, + nil, + mode_applied --[[@as fun(mode_id: string)]] + ) + config:set_legacy_modes({ + availableModes = { + { id = "plan", name = "Plan", description = "" }, + }, + currentModeId = "normal", + }) + config:set_legacy_models({ + availableModels = { + { modelId = "opus", name = "Opus", description = "" }, + }, + currentModelId = "sonnet", + }) + + config:handle_mode_change("plan", true) + config:handle_model_change("opus", true, model_done --[[@as fun()]]) + + assert.spy(mode_applied).was.called_with("plan") + assert.equal(1, model_done.call_count) + end) end) --- _show_mode_selector and _show_model_selector share legacy-fallback @@ -733,8 +1096,8 @@ describe("agentic.acp.AgentConfigOptions", function() assert.stub(set_config_stub).was.called(1) assert.stub(legacy_stub).was.called(0) local call = set_config_stub.calls[1] - assert.equal(case.config_id, call[3]) - assert.equal(case.second_value, call[4]) + assert.equal(case.config_id, call[2].configId) + assert.equal(case.second_value, call[2].value) end ) @@ -955,9 +1318,8 @@ describe("agentic.acp.AgentConfigOptions", function() assert.stub(set_config_stub).was.called(1) local call = set_config_stub.calls[1] - -- call[3]=configId (option.id), call[4]=value - assert.equal("thought-multi", call[3]) - assert.equal("high", call[4]) + assert.equal("thought-multi", call[2].configId) + assert.equal("high", call[2].value) set_config_stub:revert() end) @@ -1017,7 +1379,7 @@ describe("agentic.acp.AgentConfigOptions", function() assert.equal(case.notify, notify_stub.call_count) if case.dispatch == 1 then - assert.equal(case.target, set_config_stub.calls[1][4]) + assert.equal(case.target, set_config_stub.calls[1][2].value) end end) end @@ -1041,6 +1403,9 @@ describe("agentic.acp.AgentConfigOptions", function() mode_option, model_option, thought_option, + fast_option, + agent_option, + boolean_option, }) config_options.legacy_agent_modes:set_modes({ availableModes = { @@ -1061,6 +1426,7 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:clear() + assert.equal(0, #config_options.options) assert.is_nil(config_options.mode) assert.is_nil(config_options.model) assert.is_nil(config_options.thought_level) @@ -1078,6 +1444,9 @@ describe("agentic.acp.AgentConfigOptions", function() mode_option, model_option, thought_option, + fast_option, + agent_option, + boolean_option, }) config_options.legacy_agent_modes:set_modes({ availableModes = { @@ -1100,6 +1469,11 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:clear() config_options:restore_snapshot(snapshot) + assert.equal(6, #config_options.options) + assert.equal("fast", config_options.options[4].id) + assert.equal("agent", config_options.options[5].id) + assert.equal("auto-approve", config_options.options[6].id) + assert.equal(true, config_options.options[6].currentValue) assert.equal("mode-1", config_options.mode.id) assert.equal("model-1", config_options.model.id) assert.equal("thought-1", config_options.thought_level.id) @@ -1125,6 +1499,11 @@ describe("agentic.acp.AgentConfigOptions", function() local snapshot = config_options:snapshot() config_options:clear() + assert.equal(6, #snapshot.options) + assert.equal("fast", snapshot.options[4].id) + assert.equal("agent", snapshot.options[5].id) + assert.equal("auto-approve", snapshot.options[6].id) + assert.equal(true, snapshot.options[6].currentValue) assert.equal("mode-1", snapshot.mode.id) assert.equal("model-1", snapshot.model.id) assert.equal("thought-1", snapshot.thought_level.id) diff --git a/lua/agentic/config_default.lua b/lua/agentic/config_default.lua index 30e8c21c..6ec29e1f 100644 --- a/lua/agentic/config_default.lua +++ b/lua/agentic/config_default.lua @@ -445,6 +445,7 @@ local ConfigDefault = { switch_provider = "s", switch_model = "m", change_thought_level = "t", + open_options = "o", }, --- Keys bindings for the prompt buffer diff --git a/lua/agentic/config_selector.test.lua b/lua/agentic/config_selector.test.lua index 5640ad36..aa6cbe7b 100644 --- a/lua/agentic/config_selector.test.lua +++ b/lua/agentic/config_selector.test.lua @@ -90,8 +90,11 @@ describe("config selector", function() it("marks default model and updates after selection", function() --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, model_id, cb) - cb({ configOptions = { make_model_option(model_id) } }, nil) + set_config_option = function(_self, params, cb) + cb( + { configOptions = { make_model_option(params.value) } }, + nil + ) end, } local config = @@ -145,7 +148,7 @@ describe("config selector", function() --- @type any local agent = { set_model = set_model_fn, - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({ configOptions = {} }, nil) end, } @@ -255,12 +258,11 @@ describe("config selector", function() config:handle_thought_level_change("max") assert.equal(1, set_config_stub.call_count) - -- call[1]=self, [2]=session_id, [3]=configId, [4]=value, [5]=cb local call = set_config_stub.calls[1] - assert.equal("sess-1", call[2]) - assert.equal("claude-effort-cfg", call[3]) - assert.equal("max", call[4]) - assert.equal("function", type(call[5])) + assert.equal("sess-1", call[2].sessionId) + assert.equal("claude-effort-cfg", call[2].configId) + assert.equal("max", call[2].value) + assert.equal("function", type(call[3])) end) it("uses Codex id when provider sends thought_level", function() @@ -271,15 +273,15 @@ describe("config selector", function() config:handle_thought_level_change("high") local call = set_config_stub.calls[1] - assert.equal("codex-thought-cfg", call[3]) - assert.equal("high", call[4]) + assert.equal("codex-thought-cfg", call[2].configId) + assert.equal("high", call[2].value) end) it("applies new configOptions on success", function() config:set_options({ make_thought_option("claude-effort-cfg", "effort"), }) - set_config_stub:invokes(function(_self, _sid, _cid, _value, cb) + set_config_stub:invokes(function(_self, _params, cb) cb({ configOptions = { make_thought_option("claude-effort-cfg", "effort"), @@ -297,7 +299,7 @@ describe("config selector", function() config:set_options({ make_thought_option("claude-effort-cfg", "effort"), }) - set_config_stub:invokes(function(_self, _sid, _cid, _value, cb) + set_config_stub:invokes(function(_self, _params, cb) cb(nil, { message = "boom" }) end) @@ -314,7 +316,7 @@ describe("config selector", function() make_thought_option("claude-effort-cfg", "effort"), }) local captured_cb - set_config_stub:invokes(function(_self, _sid, _cid, _value, cb) + set_config_stub:invokes(function(_self, _params, cb) captured_cb = cb end) @@ -356,8 +358,11 @@ describe("config selector", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, model_id, cb) - cb({ configOptions = { make_model_option(model_id) } }, nil) + set_config_option = function(_self, params, cb) + cb( + { configOptions = { make_model_option(params.value) } }, + nil + ) end, } local config = AgentConfigOptions:new( @@ -378,7 +383,7 @@ describe("config selector", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({ configOptions = { make_thought_option() } }, nil) end, } @@ -398,7 +403,7 @@ describe("config selector", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({}, nil) end, } @@ -418,7 +423,7 @@ describe("config selector", function() local applied = spy.new(function() end) --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb(nil, { message = "boom" }) end, } diff --git a/lua/agentic/session_manager.lua b/lua/agentic/session_manager.lua index 321558e8..7a72c462 100644 --- a/lua/agentic/session_manager.lua +++ b/lua/agentic/session_manager.lua @@ -885,7 +885,7 @@ function SessionManager:add_buffer_diagnostics_to_context(bufnr) return self.diagnostics_list:add_many(diagnostics) end ---- @param new_config_options agentic.acp.ConfigOption[] +--- @param new_config_options agentic.acp.AnyConfigOption[] function SessionManager:_handle_new_config_options(new_config_options) self.config_options:set_options(new_config_options) local mode_id = self.config_options:get_mode_id() diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua new file mode 100644 index 00000000..7ceeda70 --- /dev/null +++ b/lua/agentic/ui/config_options_modal.lua @@ -0,0 +1,327 @@ +local BufHelpers = require("agentic.utils.buf_helpers") +local Logger = require("agentic.utils.logger") + +local NS_CONFIG_OPTIONS = + vim.api.nvim_create_namespace("agentic_config_options") + +local SELECT_ICON = string.char(0xef, 0x81, 0xb8) + +--- @class agentic.ui.ConfigOptionsModal +--- @field _callbacks agentic.ui.ConfigOptionsModal.Callbacks +--- @field _bufnr? integer +--- @field _winid? integer +--- @field _line_option_ids table +--- @field _option_rows integer[] sorted 1-based rows of `name: value` lines +local ConfigOptionsModal = {} +ConfigOptionsModal.__index = ConfigOptionsModal + +--- @class agentic.ui.ConfigOptionsModal.Callbacks +--- @field get_options fun(): agentic.acp.AnyConfigOption[] +--- @field is_session_active fun(): boolean +--- @field handle_change fun(config_id: string, value: string|boolean, on_done: fun()) +--- @field show_selector fun(option: agentic.acp.ConfigOption, prompt: string, handle_change: fun(value: string)): boolean + +local function notify_session_changed() + Logger.notify( + "The agent session changed. Reopen options to make changes.", + vim.log.levels.WARN, + { title = "Agentic" } + ) +end + +--- @param option agentic.acp.ConfigOption +--- @return string value_name +local function get_select_value_name(option) + for _, value in ipairs(option.options or {}) do + if value.value == option.currentValue then + return value.name + end + end + + return option.currentValue +end + +--- @param options agentic.acp.AnyConfigOption[] +--- @param id string +--- @return agentic.acp.AnyConfigOption|nil option +local function find_option(options, id) + for _, option in ipairs(options) do + if option.id == id then + return option + end + end + + return nil +end + +--- Build the rendered buffer content for the given options. +--- Each option renders a `name: value` row, an optional description row, and a +--- blank separator between blocks. `line_option_ids` maps the 1-based row of a +--- `name: value` line to its option id; description and separator rows are +--- unmapped. `description_rows` holds 0-based rows carrying a description. +--- @param options agentic.acp.AnyConfigOption[] +--- @return string[] lines +--- @return table line_option_ids +--- @return integer[] description_rows +--- @return integer[] option_rows +local function build_lines(options) + --- @type string[] + local lines = {} + --- @type table + local line_option_ids = {} + --- @type integer[] + local description_rows = {} + --- @type integer[] + local option_rows = {} + + local label_width = 0 + for _, option in ipairs(options) do + label_width = math.max(label_width, #option.name + 1) + end + label_width = label_width + 2 + + for index, option in ipairs(options) do + local rendered_value + if option.type == "boolean" then + rendered_value = option.currentValue and "[x]" or "[ ]" + else + rendered_value = SELECT_ICON .. " " .. get_select_value_name(option) + end + + local label = option.name .. ":" + local padding = string.rep(" ", label_width - #label) + lines[#lines + 1] = label .. padding .. rendered_value + line_option_ids[#lines] = option.id + option_rows[#option_rows + 1] = #lines + + if option.description and option.description ~= "" then + lines[#lines + 1] = option.description + description_rows[#description_rows + 1] = #lines - 1 + end + + if index < #options then + lines[#lines + 1] = "" + end + end + + if #lines == 0 then + lines[1] = "No options available" + end + + return lines, line_option_ids, description_rows, option_rows +end + +--- @param callbacks agentic.ui.ConfigOptionsModal.Callbacks +--- @return agentic.ui.ConfigOptionsModal +function ConfigOptionsModal:new(callbacks) + self = setmetatable({ + _callbacks = callbacks, + _bufnr = nil, + _winid = nil, + _line_option_ids = {}, + _option_rows = {}, + }, self) + return self +end + +function ConfigOptionsModal:open() + local width = math.floor(vim.o.columns * 0.35) + local lines = build_lines(self._callbacks.get_options()) + local height = math.max(#lines, 1) + local row = math.floor(vim.o.lines * 0.25) + local col = math.floor((vim.o.columns - width) / 2) + + self._bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[self._bufnr].bufhidden = "wipe" + + self._winid = vim.api.nvim_open_win(self._bufnr, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = " Agentic Options ", + title_pos = "center", + footer = " toggle/select ยท q/ close ", + footer_pos = "right", + }) + + for _, key in ipairs({ "q", "" }) do + BufHelpers.keymap_set(self._bufnr, "n", key, function() + if self._winid and vim.api.nvim_win_is_valid(self._winid) then + vim.api.nvim_win_close(self._winid, true) + end + end) + end + BufHelpers.keymap_set(self._bufnr, "n", "", function() + self:_activate_current_option() + end) + for _, key in ipairs({ "j", "" }) do + BufHelpers.keymap_set(self._bufnr, "n", key, function() + self:_jump_to_option(1) + end) + end + for _, key in ipairs({ "k", "" }) do + BufHelpers.keymap_set(self._bufnr, "n", key, function() + self:_jump_to_option(-1) + end) + end + + self:_render() +end + +function ConfigOptionsModal:_render() + if + not self._bufnr + or not self._winid + or not vim.api.nvim_buf_is_valid(self._bufnr) + or not vim.api.nvim_win_is_valid(self._winid) + then + return + end + + local lines, line_option_ids, description_rows, option_rows = + build_lines(self._callbacks.get_options()) + self._line_option_ids = line_option_ids + self._option_rows = option_rows + + vim.bo[self._bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self._bufnr, 0, -1, false, lines) + vim.bo[self._bufnr].modifiable = false + + vim.api.nvim_buf_clear_namespace(self._bufnr, NS_CONFIG_OPTIONS, 0, -1) + for _, row in ipairs(description_rows) do + vim.api.nvim_buf_set_extmark(self._bufnr, NS_CONFIG_OPTIONS, row, 0, { + end_col = #lines[row + 1], + hl_group = "Comment", + }) + end +end + +--- Move the cursor to the next/previous `name: value` row, wrapping at the +--- ends. Description and separator rows are skipped. If the cursor sits between +--- option rows, the nearest option row in `direction` is chosen. +--- @param direction integer 1 for down, -1 for up +--- @protected +function ConfigOptionsModal:_jump_to_option(direction) + if + not self._winid + or not vim.api.nvim_win_is_valid(self._winid) + or #self._option_rows == 0 + then + return + end + + local cursor_row = vim.api.nvim_win_get_cursor(self._winid)[1] + local rows = self._option_rows + local n = #rows + + local current + for index, row in ipairs(rows) do + if row == cursor_row then + current = index + break + end + end + + local target + if current then + target = ((current - 1 + direction + n) % n) + 1 + elseif direction > 0 then + -- Cursor sits between option rows: pick the first row below it, + -- wrapping to the top when none remain. + target = 1 + for index, row in ipairs(rows) do + if row > cursor_row then + target = index + break + end + end + else + -- Pick the first row above the cursor, wrapping to the bottom. + target = n + for index = n, 1, -1 do + if rows[index] < cursor_row then + target = index + break + end + end + end + + pcall( + vim.api.nvim_win_set_cursor, + self._winid, + { self._option_rows[target], 0 } + ) +end + +function ConfigOptionsModal:_render_after_applied() + vim.schedule(function() + self:_render() + end) +end + +function ConfigOptionsModal:_activate_current_option() + if + not self._bufnr + or not self._winid + or not vim.api.nvim_buf_is_valid(self._bufnr) + or not vim.api.nvim_win_is_valid(self._winid) + then + return + end + + local line_number = vim.api.nvim_win_get_cursor(self._winid)[1] + local option_id = self._line_option_ids[line_number] + if not option_id then + return + end + + if not self._callbacks.is_session_active() then + notify_session_changed() + return + end + + local option = find_option(self._callbacks.get_options(), option_id) + if not option then + return + end + + local on_done = function() + self:_render_after_applied() + end + + if option.type == "boolean" then + self._callbacks.handle_change( + option.id, + not option.currentValue, + on_done + ) + return + end + + local shown = self._callbacks.show_selector( + option, + "Select " .. option.name .. ":", + function(value) + if not self._callbacks.is_session_active() then + notify_session_changed() + return + end + + self._callbacks.handle_change(option.id, value, on_done) + end + ) + + if not shown then + Logger.notify( + "This option has no selectable values.", + vim.log.levels.WARN, + { title = "Agentic" } + ) + end +end + +return ConfigOptionsModal diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua new file mode 100644 index 00000000..7b60732a --- /dev/null +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -0,0 +1,307 @@ +local assert = require("tests.helpers.assert") +local spy = require("tests.helpers.spy") + +describe("agentic.ui.ConfigOptionsModal", function() + --- @type agentic.ui.ConfigOptionsModal + local ConfigOptionsModal + + --- @type integer + local bufnr + --- @type integer + local winid + --- @type any + local config_options + --- @type agentic.ui.ConfigOptionsModal + local modal + --- @type TestSpy + local handle_change_spy + --- @type TestSpy + local show_selector_spy + --- @type TestStub|nil + local schedule_stub + local session_id + + local select_icon = string.char(0xef, 0x81, 0xb8) + + local function open_modal() + modal = ConfigOptionsModal:new({ + get_options = function() + return config_options.options + end, + is_session_active = function() + return session_id == "sess-1" + end, + handle_change = function(id, value, on_done) + handle_change_spy(id, value, on_done) + end, + show_selector = function(option, prompt, callback) + return show_selector_spy(option, prompt, callback) + end, + }) + modal:open() + bufnr = vim.api.nvim_get_current_buf() + winid = vim.api.nvim_get_current_win() + end + + --- @param line integer + local function press_enter(line) + vim.api.nvim_win_set_cursor(winid, { line, 0 }) + vim.api.nvim_buf_call(bufnr, function() + local mapping = vim.fn.maparg("", "n", false, true) + mapping.callback() + end) + end + + --- @param key string + local function press_key(key) + vim.api.nvim_buf_call(bufnr, function() + local mapping = vim.fn.maparg(key, "n", false, true) + mapping.callback() + end) + end + + local function cursor_line() + return vim.api.nvim_win_get_cursor(winid)[1] + end + + local function get_lines() + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end + + --- @return integer[] rows 0-indexed lines carrying a Comment highlight + local function comment_rows() + local ns = vim.api.nvim_get_namespaces()["agentic_config_options"] + local marks = + vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + local rows = {} + for _, mark in ipairs(marks) do + if mark[4].hl_group == "Comment" then + rows[#rows + 1] = mark[2] + end + end + return rows + end + + before_each(function() + package.loaded["agentic.ui.config_options_modal"] = nil + ConfigOptionsModal = require("agentic.ui.config_options_modal") + + session_id = "sess-1" + handle_change_spy = spy.new() + show_selector_spy = spy.new(function() + return true + end) + config_options = { + options = { + { + id = "model", + type = "select", + currentValue = "opus", + name = "Model", + description = "Model to use", + options = { + { value = "opus", name = "Opus" }, + { value = "sonnet", name = "Sonnet" }, + }, + }, + { + id = "fast", + type = "boolean", + currentValue = false, + name = "Fast mode", + }, + }, + } + end) + + after_each(function() + if schedule_stub then + schedule_stub:revert() + schedule_stub = nil + end + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) + end + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end) + + it("renders aligned rows with descriptions and booleans", function() + open_modal() + + assert.same(get_lines(), { + "Model: " .. select_icon .. " Opus", + "Model to use", + "", + "Fast mode: [ ]", + }) + assert.is_false(vim.bo[bufnr].modifiable) + end) + + it("highlights description lines as Comment", function() + open_modal() + + assert.same(comment_rows(), { 1 }) + end) + + it("starts the cursor on the first option row", function() + open_modal() + + assert.equal(cursor_line(), 1) + end) + + it("picks the nearest option row from a non-option row", function() + open_modal() + + -- Row 3 is the blank separator between option rows 1 and 4. + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + press_key("k") + assert.equal(cursor_line(), 1) + + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + press_key("j") + assert.equal(cursor_line(), 4) + end) + + it("jumps j/k across option rows and wraps", function() + open_modal() + + press_key("j") + assert.equal(cursor_line(), 4) + + press_key("j") + assert.equal(cursor_line(), 1) + + press_key("k") + assert.equal(cursor_line(), 4) + + press_key("k") + assert.equal(cursor_line(), 1) + end) + + it("renders checked booleans", function() + config_options.options[2].currentValue = true + open_modal() + + assert.equal(get_lines()[4], "Fast mode: [x]") + end) + + it("falls back to the raw select value", function() + config_options.options[1].currentValue = "unknown" + open_modal() + + assert.equal( + get_lines()[1], + "Model: " .. select_icon .. " unknown" + ) + end) + + it("renders an empty placeholder with no dispatch", function() + config_options.options = {} + open_modal() + + assert.same(get_lines(), { "No options available" }) + press_enter(1) + assert.spy(handle_change_spy).was.called(0) + assert.spy(show_selector_spy).was.called(0) + end) + + it("toggles a false boolean and keeps the window open", function() + open_modal() + + press_enter(4) + + assert.spy(handle_change_spy).was.called(1) + assert.equal(handle_change_spy.calls[1][1], "fast") + assert.equal(handle_change_spy.calls[1][2], true) + assert.equal(type(handle_change_spy.calls[1][3]), "function") + assert.is_true(vim.api.nvim_win_is_valid(winid)) + end) + + it("toggles a true boolean to false", function() + config_options.options[2].currentValue = true + open_modal() + + press_enter(4) + + assert.spy(handle_change_spy).was.called(1) + assert.equal(handle_change_spy.calls[1][1], "fast") + assert.equal(handle_change_spy.calls[1][2], false) + end) + + it( + "opens the existing selector and dispatches its selected value", + function() + open_modal() + + press_enter(1) + + assert.spy(handle_change_spy).was.called(0) + assert.spy(show_selector_spy).was.called(1) + assert.equal( + show_selector_spy.calls[1][1], + config_options.options[1] + ) + assert.equal(show_selector_spy.calls[1][2], "Select Model:") + assert.equal(type(show_selector_spy.calls[1][3]), "function") + + show_selector_spy.calls[1][3]("sonnet") + assert.equal(handle_change_spy.calls[1][1], "model") + assert.equal(handle_change_spy.calls[1][2], "sonnet") + assert.equal(type(handle_change_spy.calls[1][3]), "function") + assert.is_true(vim.api.nvim_win_is_valid(winid)) + end + ) + + it("notifies when the selector has no selectable values", function() + local Logger = require("agentic.utils.logger") + local notify_stub = spy.stub(Logger, "notify") + show_selector_spy = spy.new(function() + return false + end) + open_modal() + + press_enter(1) + + assert.spy(show_selector_spy).was.called(1) + assert.spy(notify_stub).was.called(1) + notify_stub:revert() + end) + + it("rejects a selected value after the session changes", function() + open_modal() + press_enter(1) + session_id = "sess-2" + + show_selector_spy.calls[1][3]("sonnet") + + assert.spy(handle_change_spy).was.called(0) + end) + + it("rejects dispatch after the session changes", function() + open_modal() + session_id = "sess-2" + + press_enter(4) + + assert.spy(handle_change_spy).was.called(0) + assert.spy(show_selector_spy).was.called(0) + end) + + it("schedules a render after the change is confirmed", function() + schedule_stub = spy.stub(vim, "schedule") + schedule_stub:invokes(function(callback) + callback() + end) + open_modal() + press_enter(4) + config_options.options[2].currentValue = true + + assert.spy(handle_change_spy).was.called(1) + handle_change_spy.calls[1][3]() + + assert.spy(schedule_stub).was.called(1) + assert.equal(get_lines()[4], "Fast mode: [x]") + end) +end)