From 16ed9129a77b4738c6a171ebdf047ce6d7ef45b1 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 20:36:14 +0200 Subject: [PATCH 01/18] Add types for boolean config options and boolean capability --- lua/agentic/acp/acp_client_types.lua | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/lua/agentic/acp/acp_client_types.lua b/lua/agentic/acp/acp_client_types.lua index 5a20db4f..d7749c55 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,22 +180,37 @@ --- @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"|"boolean" --- @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 +--- @field id string +--- @field category? agentic.acp.ConfigOption.Category +--- @field type "boolean" +--- @field currentValue boolean +--- @field description? string +--- @field name string + +--- @alias agentic.acp.AnyConfigOption +--- | agentic.acp.ConfigOption +--- | agentic.acp.BooleanConfigOption --- @class agentic.acp.SessionCreationResponse --- @field sessionId string From ace423785d6735518279b322a5f7b55ce83052e6 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 20:43:02 +0200 Subject: [PATCH 02/18] Correct config option union ingress types --- lua/agentic/acp/acp_client_types.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/agentic/acp/acp_client_types.lua b/lua/agentic/acp/acp_client_types.lua index d7749c55..5a0717f5 100644 --- a/lua/agentic/acp/acp_client_types.lua +++ b/lua/agentic/acp/acp_client_types.lua @@ -194,7 +194,7 @@ --- @class agentic.acp.ConfigOption --- @field id string --- @field category? agentic.acp.ConfigOption.Category ---- @field type? "select"|"boolean" +--- @field type? "select" --- @field currentValue string --- @field description string --- @field name string @@ -216,7 +216,7 @@ --- @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 } @@ -279,7 +279,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 From a50c628acead8f0bbafccb8f273b9764faf16b8a Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 20:51:34 +0200 Subject: [PATCH 03/18] Advertise boolean session config option capability --- lua/agentic/acp/acp_client.lua | 5 +++++ lua/agentic/acp/acp_client.test.lua | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lua/agentic/acp/acp_client.lua b/lua/agentic/acp/acp_client.lua index d33d74e1..20232b6e 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 = {}, diff --git a/lua/agentic/acp/acp_client.test.lua b/lua/agentic/acp/acp_client.test.lua index adfc3771..89acc15a 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,27 @@ describe("ACPClient", function() create_transport_stub:revert() end) + describe("initialize", function() + it("advertises boolean session config options as an object", function() + 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) From a86a721bc5c8661902bd79b7a6553cc7736ae2df Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 20:58:40 +0200 Subject: [PATCH 04/18] Send boolean variant from set_config_option --- lua/agentic/acp/acp_client.lua | 14 +++++-- lua/agentic/acp/acp_client.test.lua | 57 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lua/agentic/acp/acp_client.lua b/lua/agentic/acp/acp_client.lua index 20232b6e..08d6fdec 100644 --- a/lua/agentic/acp/acp_client.lua +++ b/lua/agentic/acp/acp_client.lua @@ -787,20 +787,28 @@ end --- Set a config option value for a session --- @param session_id string --- @param config_id string ---- @param config_value string +--- @param config_value string|boolean --- @param callback fun(result: table|nil, err: agentic.acp.ACPError|nil) +--- @param is_boolean boolean|nil function ACPClient:set_config_option( session_id, config_id, config_value, - callback + callback, + is_boolean ) local params = { sessionId = session_id, configId = config_id, - value = config_value, } + if is_boolean then + params.type = "boolean" + params.value = config_value + else + params.value = config_value + end + 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 89acc15a..7c5f0488 100644 --- a/lua/agentic/acp/acp_client.test.lua +++ b/lua/agentic/acp/acp_client.test.lua @@ -304,6 +304,63 @@ 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("s1", "model", "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("s1", "fast", true, function() end, true) + + 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("s1", "fast", false, function() end, true) + + 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 From 0c6f89b683426c63d72d876f274f7c2e638322de Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 21:06:32 +0200 Subject: [PATCH 05/18] Retain all config options in an ordered list --- lua/agentic/acp/agent_config_options.lua | 29 ++++++--- lua/agentic/acp/agent_config_options.test.lua | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index 65295433..d7089f39 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, @@ -76,6 +78,7 @@ function AgentConfigOptions:new(buffers, callbacks) end function AgentConfigOptions:clear() + self.options = {} self.mode = nil self.model = nil self.thought_level = nil @@ -84,6 +87,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 +101,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 +113,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 @@ -130,16 +136,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 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 + else + Logger.debug("Unknown config option", option) + end end end end diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 2fda534e..4d3ec38b 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -62,6 +62,30 @@ 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.ConfigOption local multi_thought = { id = "thought-multi", @@ -149,6 +173,38 @@ describe("agentic.acp.AgentConfigOptions", function() 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, + }) + + assert.equal(5, #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.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 +220,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,6 +275,7 @@ 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) @@ -1061,6 +1119,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) @@ -1100,6 +1159,7 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:clear() config_options:restore_snapshot(snapshot) + assert.equal(3, #config_options.options) 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 +1185,7 @@ describe("agentic.acp.AgentConfigOptions", function() local snapshot = config_options:snapshot() config_options:clear() + assert.equal(3, #snapshot.options) assert.equal("mode-1", snapshot.mode.id) assert.equal("model-1", snapshot.model.id) assert.equal("thought-1", snapshot.thought_level.id) From a42cd537ec2927c2bcd78f4e5b2a1007f07c51bd Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 21:21:33 +0200 Subject: [PATCH 06/18] Correct ordered option ingress annotations --- lua/agentic/acp/agent_config_options.lua | 8 ++--- lua/agentic/acp/agent_config_options.test.lua | 32 +++++++++++++++++-- lua/agentic/session_manager.lua | 2 +- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index d7089f39..e0b457a4 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -121,7 +121,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() @@ -141,11 +141,11 @@ function AgentConfigOptions:set_options(configOptions) self.options[#self.options + 1] = stored_option if cat == "mode" then - self.mode = stored_option + self.mode = stored_option --[[@as agentic.acp.ConfigOption]] elseif cat == "model" then - self.model = stored_option + self.model = stored_option --[[@as agentic.acp.ConfigOption]] elseif cat == "thought_level" then - self.thought_level = stored_option + self.thought_level = stored_option --[[@as agentic.acp.ConfigOption]] else Logger.debug("Unknown config option", option) end diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 4d3ec38b..bfcbfcae 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -86,6 +86,15 @@ describe("agentic.acp.AgentConfigOptions", function() }, } + --- @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", @@ -182,14 +191,17 @@ describe("agentic.acp.AgentConfigOptions", function() thought_option, fast_option, agent_option, + boolean_option, }) - assert.equal(5, #config_options.options) + 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] @@ -1099,6 +1111,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 = { @@ -1137,6 +1152,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 = { @@ -1159,7 +1177,11 @@ describe("agentic.acp.AgentConfigOptions", function() config_options:clear() config_options:restore_snapshot(snapshot) - assert.equal(3, #config_options.options) + 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) @@ -1185,7 +1207,11 @@ describe("agentic.acp.AgentConfigOptions", function() local snapshot = config_options:snapshot() config_options:clear() - assert.equal(3, #snapshot.options) + 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/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() From 952c538170b707fb5c11fa0065e5b691b4bf2f34 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 21:29:31 +0200 Subject: [PATCH 07/18] Add generic config option change handler --- lua/agentic/acp/agent_config_options.lua | 140 +++++++------ lua/agentic/acp/agent_config_options.test.lua | 184 +++++++++++++++++- 2 files changed, 264 insertions(+), 60 deletions(-) diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index e0b457a4..b2caabc9 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -290,6 +290,11 @@ function AgentConfigOptions:get_model_id() or self.legacy_agent_models.current_model_id end +--- @return string|nil session_id +function AgentConfigOptions:get_session_id() + return self.callbacks.get_session_id() +end + --- @param mode_value string --- @return agentic.acp.ConfigOption.Option|nil function AgentConfigOptions:get_mode(mode_value) @@ -525,9 +530,79 @@ function AgentConfigOptions:_make_change_response( end end +--- @param config_id string +--- @param value string|boolean +--- @param on_applied fun()|nil +function AgentConfigOptions:handle_change(config_id, value, on_applied) + --- @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 is_boolean = target.type == "boolean" + 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) + target.currentValue = value + + if target.category == "mode" then + self.legacy_agent_modes.current_mode_id = value --[[@as string]] + self.callbacks.on_set_mode_success(value --[[@as string]]) + elseif target.category == "model" then + self.legacy_agent_models.current_model_id = value --[[@as string]] + 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_applied then + on_applied() + end + end + ) + + agent:set_config_option(session_id, config_id, value, response, is_boolean) +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 @@ -547,9 +622,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") @@ -567,17 +639,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 @@ -597,9 +670,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") @@ -619,59 +689,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 bfcbfcae..5fad8f7e 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -132,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() @@ -617,6 +619,129 @@ describe("agentic.acp.AgentConfigOptions", function() ) 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("exposes the current session id", function() + assert.equal("s1", config:get_session_id()) + end) + + it("dispatches select options without the boolean flag", function() + config:handle_change("model-1", "claude-opus") + + assert.stub(set_config_stub).was.called(1) + local call = set_config_stub.calls[1] + assert.equal("s1", call[2]) + assert.equal("model-1", call[3]) + assert.equal("claude-opus", call[4]) + assert.equal("function", type(call[5])) + assert.is_falsy(call[6]) + end) + + it("dispatches boolean options with the boolean flag", function() + config:handle_change("darkmode", true) + + assert.stub(set_config_stub).was.called(1) + local call = set_config_stub.calls[1] + assert.equal("s1", call[2]) + assert.equal("darkmode", call[3]) + assert.is_true(call[4]) + assert.equal("function", type(call[5])) + assert.is_true(call[6]) + 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("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, _sid, _cid, _val, 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, _sid, _cid, _val, 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 @@ -630,18 +755,25 @@ 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) 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() @@ -656,10 +788,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( @@ -685,6 +823,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 From 3d1b5731037d3c5c1b2ba6e5ffa25a2b9dba3665 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 21:39:47 +0200 Subject: [PATCH 08/18] Add settings modal floating window --- lua/agentic/ui/config_options_modal.lua | 189 ++++++++++++++++++ lua/agentic/ui/config_options_modal.test.lua | 195 +++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 lua/agentic/ui/config_options_modal.lua create mode 100644 lua/agentic/ui/config_options_modal.test.lua diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua new file mode 100644 index 00000000..ed41c6cd --- /dev/null +++ b/lua/agentic/ui/config_options_modal.lua @@ -0,0 +1,189 @@ +local BufHelpers = require("agentic.utils.buf_helpers") +local Logger = require("agentic.utils.logger") + +--- @class agentic.ui.ConfigOptionsModal +--- @field _config_options agentic.acp.AgentConfigOptions +--- @field _opened_session_id string +--- @field _bufnr? integer +--- @field _winid? integer +--- @field _line_option_ids table +local ConfigOptionsModal = {} +ConfigOptionsModal.__index = ConfigOptionsModal + +--- @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 + +--- @param config_options agentic.acp.AgentConfigOptions +--- @param opened_session_id string +--- @return agentic.ui.ConfigOptionsModal +function ConfigOptionsModal:new(config_options, opened_session_id) + self = setmetatable({ + _config_options = config_options, + _opened_session_id = opened_session_id, + _bufnr = nil, + _winid = nil, + _line_option_ids = {}, + }, self) + return self +end + +function ConfigOptionsModal:open() + local width = math.floor(vim.o.columns * 0.5) + local height = math.max(#self._config_options.options, 1) + local row = math.floor((vim.o.lines - height) / 2) + 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 Settings ", + title_pos = "center", + footer = " toggle/select · q/ close ", + footer_pos = "right", + }) + + BufHelpers.keymap_set(self._bufnr, "n", "q", function() + if self._winid and vim.api.nvim_win_is_valid(self._winid) then + vim.api.nvim_win_close(self._winid, true) + end + end) + BufHelpers.keymap_set(self._bufnr, "n", "", function() + if self._winid and vim.api.nvim_win_is_valid(self._winid) then + vim.api.nvim_win_close(self._winid, true) + end + end) + BufHelpers.keymap_set(self._bufnr, "n", "", function() + self:_activate_current_option() + 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 + + self._line_option_ids = {} + --- @type string[] + local lines = {} + + for line_number, option in ipairs(self._config_options.options) do + local rendered_value + if option.type == "boolean" then + rendered_value = option.currentValue and "[x]" or "[ ]" + else + rendered_value = get_select_value_name(option) + end + + lines[#lines + 1] = option.name .. ": " .. rendered_value + self._line_option_ids[line_number] = option.id + end + + if #lines == 0 then + lines[1] = "No settings available" + end + + vim.bo[self._bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self._bufnr, 0, -1, false, lines) + vim.bo[self._bufnr].modifiable = false +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 self._config_options:get_session_id() ~= self._opened_session_id then + Logger.notify( + "The agent session changed. Reopen settings to make changes.", + vim.log.levels.WARN, + { title = "Agentic" } + ) + return + end + + local option = find_option(self._config_options.options, option_id) + if not option then + return + end + + local on_applied = function() + vim.schedule(function() + if + self._bufnr + and self._winid + and vim.api.nvim_buf_is_valid(self._bufnr) + and vim.api.nvim_win_is_valid(self._winid) + then + self:_render() + end + end) + end + + if option.type == "boolean" then + self._config_options:handle_change( + option.id, + not option.currentValue, + on_applied + ) + return + end + + --- @diagnostic disable-next-line: invisible + self._config_options:_show_selector( + option, + "Select " .. option.name .. ":", + function(value) + self._config_options:handle_change(option.id, value, on_applied) + 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..91aa768c --- /dev/null +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -0,0 +1,195 @@ +local assert = require("tests.helpers.assert") +local spy = require("tests.helpers.spy") + +describe("agentic.ui.ConfigOptionsModal", function() + local ConfigOptionsModal = require("agentic.ui.config_options_modal") + + --- @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 function open_modal() + modal = ConfigOptionsModal:new(config_options, "sess-1") + 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 + + local function get_lines() + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end + + before_each(function() + session_id = "sess-1" + handle_change_spy = spy.new() + show_selector_spy = spy.new() + 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", + }, + }, + handle_change = function(_, id, value, on_applied) + handle_change_spy(id, value, on_applied) + end, + _show_selector = function(_, option, prompt, callback) + show_selector_spy(option, prompt, callback) + end, + get_session_id = function() + return session_id + end, + } + 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 select names and unchecked booleans", function() + open_modal() + + assert.same(get_lines(), { "Model: Opus", "Fast mode: [ ]" }) + assert.is_false(vim.bo[bufnr].modifiable) + end) + + it("renders checked booleans", function() + config_options.options[2].currentValue = true + open_modal() + + assert.equal(get_lines()[2], "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: unknown") + end) + + it("renders an empty placeholder with no dispatch", function() + config_options.options = {} + open_modal() + + assert.same(get_lines(), { "No settings 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(2) + + 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(2) + + 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("rejects dispatch after the session changes", function() + open_modal() + session_id = "sess-2" + + press_enter(2) + + 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(2) + 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()[2], "Fast mode: [x]") + end) +end) From c7a9971952b20930847fd735383c1264d0e190b4 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 21:44:21 +0200 Subject: [PATCH 09/18] Guard select dispatch after session changes --- lua/agentic/ui/config_options_modal.lua | 22 +++++++++++++++----- lua/agentic/ui/config_options_modal.test.lua | 10 +++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index ed41c6cd..91d252e9 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -10,6 +10,14 @@ local Logger = require("agentic.utils.logger") local ConfigOptionsModal = {} ConfigOptionsModal.__index = ConfigOptionsModal +local function notify_session_changed() + Logger.notify( + "The agent session changed. Reopen settings 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) @@ -141,11 +149,7 @@ function ConfigOptionsModal:_activate_current_option() end if self._config_options:get_session_id() ~= self._opened_session_id then - Logger.notify( - "The agent session changed. Reopen settings to make changes.", - vim.log.levels.WARN, - { title = "Agentic" } - ) + notify_session_changed() return end @@ -181,6 +185,14 @@ function ConfigOptionsModal:_activate_current_option() option, "Select " .. option.name .. ":", function(value) + if + self._config_options:get_session_id() + ~= self._opened_session_id + then + notify_session_changed() + return + end + self._config_options:handle_change(option.id, value, on_applied) end ) diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index 91aa768c..55be521b 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -167,6 +167,16 @@ describe("agentic.ui.ConfigOptionsModal", function() 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" From c733305c65378ff6b4fbf475943f8b2466c53ef0 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 22:45:05 +0200 Subject: [PATCH 10/18] Add settings modal keymap and docs - verify live select and boolean changes - verify modal remains open - verify stale session rejection --- README.md | 2 + doc/agentic.txt | 2 + lua/agentic/acp/agent_config_options.lua | 25 ++++ lua/agentic/acp/agent_config_options.test.lua | 117 ++++++++++++++++-- lua/agentic/config_default.lua | 1 + 5 files changed, 137 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b070be1b..3cc9a6e3 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 settings 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_settings = "o", -- Open settings modal }, -- Keybindings for the prompt buffer only diff --git a/doc/agentic.txt b/doc/agentic.txt index fe15703f..1d7f4568 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 settings 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_settings = "o", }, prompt = { submit = { diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index b2caabc9..134116cf 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -72,6 +72,15 @@ function AgentConfigOptions:new(buffers, callbacks) end, { desc = "Agentic: Select Thought Effort Level" } ) + + BufHelpers.multi_keymap_set( + Config.keymaps.widget.open_settings, + bufnr, + function() + self:_show_settings_modal() + end, + { desc = "Agentic: Open Settings" } + ) end return self @@ -295,6 +304,22 @@ function AgentConfigOptions:get_session_id() return self.callbacks.get_session_id() end +function AgentConfigOptions:_show_settings_modal() + local session_id = self: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(self, session_id):open() +end + --- @param mode_value string --- @return agentic.acp.ConfigOption.Option|nil function AgentConfigOptions:get_mode(mode_value) diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 5fad8f7e..0d34306c 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -168,19 +168,116 @@ 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_settings) + + for i = 1, 4 do + assert.equal("function", type(multi_keymap_stub.calls[i][3])) + end + + assert.equal( + Config.keymaps.widget.open_settings, + multi_keymap_stub.calls[4][1] + ) + assert.equal(test_bufnr, multi_keymap_stub.calls[4][2]) + assert.equal( + "Agentic: Open Settings", + multi_keymap_stub.calls[4][4].desc + ) + end) + end) + + describe("open settings 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_settings_callback() + local open_settings = + require("agentic.config").keymaps.widget.open_settings + + for i = #multi_keymap_stub.calls, 1, -1 do + local call = multi_keymap_stub.calls[i] + if vim.deep_equal(call[1], open_settings) then + return call[3] end end - ) + + error("open_settings 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_settings_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_settings_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 the owning instance and session id", 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_settings_callback()() + + assert.spy(get_session_id_spy).was.called(1) + assert.spy(modal_new_spy).was.called(1) + assert.is_true(modal_new_spy.calls[1][2] == config) + assert.equal("sess-captured", modal_new_spy.calls[1][3]) + assert.spy(modal_open_spy).was.called(1) + assert.stub(notify_stub).was.called(0) + end) end) describe("set_options", function() diff --git a/lua/agentic/config_default.lua b/lua/agentic/config_default.lua index 30e8c21c..7035d075 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_settings = "o", }, --- Keys bindings for the prompt buffer From 40266cc3472039a515920fb0d9bee46463fbc95b Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 23:06:01 +0200 Subject: [PATCH 11/18] Expose config option selector --- lua/agentic/acp/agent_config_options.lua | 10 +++++----- lua/agentic/ui/config_options_modal.lua | 3 +-- lua/agentic/ui/config_options_modal.test.lua | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index 134116cf..a4fa454a 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -155,7 +155,7 @@ function AgentConfigOptions:set_options(configOptions) self.model = stored_option --[[@as agentic.acp.ConfigOption]] elseif cat == "thought_level" then self.thought_level = stored_option --[[@as agentic.acp.ConfigOption]] - else + elseif cat ~= "" and cat ~= "model_config" and cat ~= "other" then Logger.debug("Unknown config option", option) end end @@ -358,7 +358,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_mode_selector() - local shown = self:_show_selector( + local shown = self:show_selector( self.mode, "Select agent mode config:", function(mode) @@ -389,7 +389,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_thought_level_selector() - local shown = self:_show_selector( + local shown = self:show_selector( self.thought_level, "Select thought effort level:", function(value) @@ -412,7 +412,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_model_selector() - local shown = self:_show_selector( + local shown = self:show_selector( self.model, "Select model to change:", function(model) @@ -488,7 +488,7 @@ end --- @param prompt string --- @param handle_change fun(value: string): any --- @return boolean shown -function AgentConfigOptions:_show_selector(target, prompt, handle_change) +function AgentConfigOptions:show_selector(target, prompt, handle_change) if not target or not target.options or #target.options == 0 then return false end diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index 91d252e9..602233d0 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -180,8 +180,7 @@ function ConfigOptionsModal:_activate_current_option() return end - --- @diagnostic disable-next-line: invisible - self._config_options:_show_selector( + self._config_options:show_selector( option, "Select " .. option.name .. ":", function(value) diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index 55be521b..f6184edf 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -67,7 +67,7 @@ describe("agentic.ui.ConfigOptionsModal", function() handle_change = function(_, id, value, on_applied) handle_change_spy(id, value, on_applied) end, - _show_selector = function(_, option, prompt, callback) + show_selector = function(_, option, prompt, callback) show_selector_spy(option, prompt, callback) end, get_session_id = function() From 4e6d0335ee8b3ddcc4f85ff2ff1e68be71c7b406 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Mon, 13 Jul 2026 23:06:16 +0200 Subject: [PATCH 12/18] Cover unknown config option logging --- lua/agentic/acp/agent_config_options.test.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 0d34306c..acf30eb2 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -392,6 +392,17 @@ describe("agentic.acp.AgentConfigOptions", function() 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", @@ -400,6 +411,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) From c20fbeb87ed1159ae0fed795f3fe2d0c8d96c376 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Tue, 14 Jul 2026 22:29:41 +0200 Subject: [PATCH 13/18] Simplify config option APIs and modal coupling --- lua/agentic/acp/acp_client.lua | 25 +----- lua/agentic/acp/acp_client.test.lua | 21 ++++- lua/agentic/acp/acp_client_types.lua | 20 ++++- lua/agentic/acp/agent_config_options.lua | 84 ++++++++++++------- lua/agentic/acp/agent_config_options.test.lua | 79 +++++++++-------- lua/agentic/config_selector.test.lua | 41 +++++---- lua/agentic/ui/config_options_modal.lua | 57 ++++++------- lua/agentic/ui/config_options_modal.test.lua | 24 +++--- 8 files changed, 197 insertions(+), 154 deletions(-) diff --git a/lua/agentic/acp/acp_client.lua b/lua/agentic/acp/acp_client.lua index 08d6fdec..8c7dac29 100644 --- a/lua/agentic/acp/acp_client.lua +++ b/lua/agentic/acp/acp_client.lua @@ -785,30 +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|boolean +--- @param params agentic.acp.SetConfigOptionParams --- @param callback fun(result: table|nil, err: agentic.acp.ACPError|nil) ---- @param is_boolean boolean|nil -function ACPClient:set_config_option( - session_id, - config_id, - config_value, - callback, - is_boolean -) - local params = { - sessionId = session_id, - configId = config_id, - } - - if is_boolean then - params.type = "boolean" - params.value = config_value - else - params.value = config_value - end - +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 7c5f0488..b738fb44 100644 --- a/lua/agentic/acp/acp_client.test.lua +++ b/lua/agentic/acp/acp_client.test.lua @@ -158,6 +158,7 @@ describe("ACPClient", function() 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) @@ -310,7 +311,11 @@ describe("ACPClient", function() --- @diagnostic disable-next-line: invisible local send_request_stub = spy.stub(client, "_send_request") - client:set_config_option("s1", "model", "opus", function() end) + client:set_config_option({ + sessionId = "s1", + configId = "model", + value = "opus", + }, function() end) local params = send_request_stub.calls[1][3] assert.equal( @@ -330,7 +335,12 @@ describe("ACPClient", function() --- @diagnostic disable-next-line: invisible local send_request_stub = spy.stub(client, "_send_request") - client:set_config_option("s1", "fast", true, function() end, true) + client:set_config_option({ + sessionId = "s1", + configId = "fast", + type = "boolean", + value = true, + }, function() end) assert.equal( "session/set_config_option", @@ -349,7 +359,12 @@ describe("ACPClient", function() --- @diagnostic disable-next-line: invisible local send_request_stub = spy.stub(client, "_send_request") - client:set_config_option("s1", "fast", false, function() end, true) + client:set_config_option({ + sessionId = "s1", + configId = "fast", + type = "boolean", + value = false, + }, function() end) local params = send_request_stub.calls[1][3] assert.equal( diff --git a/lua/agentic/acp/acp_client_types.lua b/lua/agentic/acp/acp_client_types.lua index 5a0717f5..69f9ee82 100644 --- a/lua/agentic/acp/acp_client_types.lua +++ b/lua/agentic/acp/acp_client_types.lua @@ -200,13 +200,25 @@ --- @field name string --- @field options? agentic.acp.ConfigOption.Option[] ---- @class agentic.acp.BooleanConfigOption ---- @field id string ---- @field category? agentic.acp.ConfigOption.Category +--- @class agentic.acp.BooleanConfigOption : agentic.acp.ConfigOption --- @field type "boolean" --- @field currentValue boolean --- @field description? string ---- @field name 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 diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index a4fa454a..3efd1f32 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -149,12 +149,12 @@ function AgentConfigOptions:set_options(configOptions) local stored_option = vim.deepcopy(option) self.options[#self.options + 1] = stored_option - if cat == "mode" then - self.mode = stored_option --[[@as agentic.acp.ConfigOption]] - elseif cat == "model" then - self.model = stored_option --[[@as agentic.acp.ConfigOption]] - elseif cat == "thought_level" then - self.thought_level = stored_option --[[@as agentic.acp.ConfigOption]] + 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 @@ -299,13 +299,8 @@ function AgentConfigOptions:get_model_id() or self.legacy_agent_models.current_model_id end ---- @return string|nil session_id -function AgentConfigOptions:get_session_id() - return self.callbacks.get_session_id() -end - function AgentConfigOptions:_show_settings_modal() - local session_id = self:get_session_id() + local session_id = self.callbacks.get_session_id() if #self.options == 0 or not session_id then Logger.notify( @@ -317,7 +312,20 @@ function AgentConfigOptions:_show_settings_modal() end local ConfigOptionsModal = require("agentic.ui.config_options_modal") - ConfigOptionsModal:new(self, session_id):open() + 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) + self:_show_selector(option, prompt, handle_change) + end, + }):open() end --- @param mode_value string @@ -358,7 +366,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_mode_selector() - local shown = self:show_selector( + local shown = self:_show_selector( self.mode, "Select agent mode config:", function(mode) @@ -389,7 +397,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_thought_level_selector() - local shown = self:show_selector( + local shown = self:_show_selector( self.thought_level, "Select thought effort level:", function(value) @@ -412,7 +420,7 @@ end --- @return boolean shown function AgentConfigOptions:_show_model_selector() - local shown = self:show_selector( + local shown = self:_show_selector( self.model, "Select model to change:", function(model) @@ -488,7 +496,7 @@ end --- @param prompt string --- @param handle_change fun(value: string): any --- @return boolean shown -function AgentConfigOptions:show_selector(target, prompt, handle_change) +function AgentConfigOptions:_show_selector(target, prompt, handle_change) if not target or not target.options or #target.options == 0 then return false end @@ -557,8 +565,8 @@ end --- @param config_id string --- @param value string|boolean ---- @param on_applied fun()|nil -function AgentConfigOptions:handle_change(config_id, value, on_applied) +--- @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 @@ -573,7 +581,6 @@ function AgentConfigOptions:handle_change(config_id, value, on_applied) return end - local is_boolean = target.type == "boolean" local session_id = self.callbacks.get_session_id() if not session_id then @@ -591,13 +598,17 @@ function AgentConfigOptions:handle_change(config_id, value, on_applied) target.name, tostring(value), function(result) - target.currentValue = value - - if target.category == "mode" then - self.legacy_agent_modes.current_mode_id = value --[[@as string]] - self.callbacks.on_set_mode_success(value --[[@as string]]) - elseif target.category == "model" then - self.legacy_agent_models.current_model_id = value --[[@as string]] + 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 @@ -611,13 +622,26 @@ function AgentConfigOptions:handle_change(config_id, value, on_applied) { title = "Agentic Setting changed" } ) - if on_applied then - on_applied() + if on_done then + on_done() end end ) - agent:set_config_option(session_id, config_id, value, response, is_boolean) + 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 diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index acf30eb2..2353eda0 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -255,7 +255,7 @@ describe("agentic.acp.AgentConfigOptions", function() assert.spy(modal_open_spy).was.called(0) end) - it("opens the modal with the owning instance and session id", function() + it("opens the modal with config option callbacks", function() local get_session_id_spy = spy.new(function() return "sess-captured" end) @@ -273,8 +273,11 @@ describe("agentic.acp.AgentConfigOptions", function() assert.spy(get_session_id_spy).was.called(1) assert.spy(modal_new_spy).was.called(1) - assert.is_true(modal_new_spy.calls[1][2] == config) - assert.equal("sess-captured", modal_new_spy.calls[1][3]) + 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) @@ -609,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 ) @@ -722,8 +724,8 @@ 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) @@ -763,32 +765,31 @@ describe("agentic.acp.AgentConfigOptions", function() notify_stub:revert() end) - it("exposes the current session id", function() - assert.equal("s1", config:get_session_id()) - end) - - it("dispatches select options without the boolean flag", function() + 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.equal("s1", call[2]) - assert.equal("model-1", call[3]) - assert.equal("claude-opus", call[4]) - assert.equal("function", type(call[5])) - assert.is_falsy(call[6]) + assert.same(call[2], { + sessionId = "s1", + configId = "model-1", + value = "claude-opus", + }) + assert.equal("function", type(call[3])) end) - it("dispatches boolean options with the boolean flag", function() + 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.equal("s1", call[2]) - assert.equal("darkmode", call[3]) - assert.is_true(call[4]) - assert.equal("function", type(call[5])) - assert.is_true(call[6]) + 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() @@ -799,6 +800,13 @@ describe("agentic.acp.AgentConfigOptions", function() 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() @@ -807,7 +815,7 @@ describe("agentic.acp.AgentConfigOptions", 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, } @@ -835,7 +843,7 @@ describe("agentic.acp.AgentConfigOptions", function() }) --[[@as agentic.acp.ConfigOption]] --- @type any local agent = { - set_config_option = function(_self, _sid, _cid, _val, cb) + set_config_option = function(_self, _params, cb) cb({ configOptions = { refreshed_model } }, nil) end, } @@ -867,7 +875,7 @@ describe("agentic.acp.AgentConfigOptions", 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, } @@ -889,7 +897,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, } @@ -915,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, } @@ -1088,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 ) @@ -1310,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) @@ -1372,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 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/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index 602233d0..b897d791 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -2,14 +2,19 @@ local BufHelpers = require("agentic.utils.buf_helpers") local Logger = require("agentic.utils.logger") --- @class agentic.ui.ConfigOptionsModal ---- @field _config_options agentic.acp.AgentConfigOptions ---- @field _opened_session_id string +--- @field _callbacks agentic.ui.ConfigOptionsModal.Callbacks --- @field _bufnr? integer --- @field _winid? integer --- @field _line_option_ids table 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)) + local function notify_session_changed() Logger.notify( "The agent session changed. Reopen settings to make changes.", @@ -43,13 +48,11 @@ local function find_option(options, id) return nil end ---- @param config_options agentic.acp.AgentConfigOptions ---- @param opened_session_id string +--- @param callbacks agentic.ui.ConfigOptionsModal.Callbacks --- @return agentic.ui.ConfigOptionsModal -function ConfigOptionsModal:new(config_options, opened_session_id) +function ConfigOptionsModal:new(callbacks) self = setmetatable({ - _config_options = config_options, - _opened_session_id = opened_session_id, + _callbacks = callbacks, _bufnr = nil, _winid = nil, _line_option_ids = {}, @@ -59,7 +62,7 @@ end function ConfigOptionsModal:open() local width = math.floor(vim.o.columns * 0.5) - local height = math.max(#self._config_options.options, 1) + local height = math.max(#self._callbacks.get_options(), 1) local row = math.floor((vim.o.lines - height) / 2) local col = math.floor((vim.o.columns - width) / 2) @@ -111,7 +114,7 @@ function ConfigOptionsModal:_render() --- @type string[] local lines = {} - for line_number, option in ipairs(self._config_options.options) do + for line_number, option in ipairs(self._callbacks.get_options()) do local rendered_value if option.type == "boolean" then rendered_value = option.currentValue and "[x]" or "[ ]" @@ -132,6 +135,12 @@ function ConfigOptionsModal:_render() vim.bo[self._bufnr].modifiable = false end +function ConfigOptionsModal:_render_after_applied() + vim.schedule(function() + self:_render() + end) +end + function ConfigOptionsModal:_activate_current_option() if not self._bufnr @@ -148,51 +157,39 @@ function ConfigOptionsModal:_activate_current_option() return end - if self._config_options:get_session_id() ~= self._opened_session_id then + if not self._callbacks.is_session_active() then notify_session_changed() return end - local option = find_option(self._config_options.options, option_id) + local option = find_option(self._callbacks.get_options(), option_id) if not option then return end - local on_applied = function() - vim.schedule(function() - if - self._bufnr - and self._winid - and vim.api.nvim_buf_is_valid(self._bufnr) - and vim.api.nvim_win_is_valid(self._winid) - then - self:_render() - end - end) + local on_done = function() + self:_render_after_applied() end if option.type == "boolean" then - self._config_options:handle_change( + self._callbacks.handle_change( option.id, not option.currentValue, - on_applied + on_done ) return end - self._config_options:show_selector( + self._callbacks.show_selector( option, "Select " .. option.name .. ":", function(value) - if - self._config_options:get_session_id() - ~= self._opened_session_id - then + if not self._callbacks.is_session_active() then notify_session_changed() return end - self._config_options:handle_change(option.id, value, on_applied) + self._callbacks.handle_change(option.id, value, on_done) end ) end diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index f6184edf..510adccb 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -21,7 +21,20 @@ describe("agentic.ui.ConfigOptionsModal", function() local session_id local function open_modal() - modal = ConfigOptionsModal:new(config_options, "sess-1") + 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) + show_selector_spy(option, prompt, callback) + end, + }) modal:open() bufnr = vim.api.nvim_get_current_buf() winid = vim.api.nvim_get_current_win() @@ -64,15 +77,6 @@ describe("agentic.ui.ConfigOptionsModal", function() name = "Fast mode", }, }, - handle_change = function(_, id, value, on_applied) - handle_change_spy(id, value, on_applied) - end, - show_selector = function(_, option, prompt, callback) - show_selector_spy(option, prompt, callback) - end, - get_session_id = function() - return session_id - end, } end) From 1b0c0e63493bed75234ab1afcc920d452523b696 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Tue, 14 Jul 2026 22:50:19 +0200 Subject: [PATCH 14/18] Prefix select values with dropdown icon --- lua/agentic/ui/config_options_modal.lua | 2 +- lua/agentic/ui/config_options_modal.test.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index b897d791..6ba10246 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -119,7 +119,7 @@ function ConfigOptionsModal:_render() if option.type == "boolean" then rendered_value = option.currentValue and "[x]" or "[ ]" else - rendered_value = get_select_value_name(option) + rendered_value = " " .. get_select_value_name(option) end lines[#lines + 1] = option.name .. ": " .. rendered_value diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index 510adccb..4a1d8475 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -96,7 +96,7 @@ describe("agentic.ui.ConfigOptionsModal", function() it("renders select names and unchecked booleans", function() open_modal() - assert.same(get_lines(), { "Model: Opus", "Fast mode: [ ]" }) + assert.same(get_lines(), { "Model:  Opus", "Fast mode: [ ]" }) assert.is_false(vim.bo[bufnr].modifiable) end) @@ -111,7 +111,7 @@ describe("agentic.ui.ConfigOptionsModal", function() config_options.options[1].currentValue = "unknown" open_modal() - assert.equal(get_lines()[1], "Model: unknown") + assert.equal(get_lines()[1], "Model:  unknown") end) it("renders an empty placeholder with no dispatch", function() From bda0a4e2d0ad02e6d811a68310453ea951525c33 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Wed, 15 Jul 2026 14:57:44 +0200 Subject: [PATCH 15/18] Notify when a select option has no values --- lua/agentic/acp/agent_config_options.lua | 2 +- lua/agentic/ui/config_options_modal.lua | 29 ++++++++++++-------- lua/agentic/ui/config_options_modal.test.lua | 27 ++++++++++++++++-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index 3efd1f32..6130c7b7 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -323,7 +323,7 @@ function AgentConfigOptions:_show_settings_modal() self:handle_change(config_id, value, on_done) end, show_selector = function(option, prompt, handle_change) - self:_show_selector(option, prompt, handle_change) + return self:_show_selector(option, prompt, handle_change) end, }):open() end diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index 6ba10246..4318f354 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -13,7 +13,7 @@ ConfigOptionsModal.__index = ConfigOptionsModal --- @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)) +--- @field show_selector fun(option: agentic.acp.ConfigOption, prompt: string, handle_change: fun(value: string)): boolean local function notify_session_changed() Logger.notify( @@ -83,16 +83,13 @@ function ConfigOptionsModal:open() footer_pos = "right", }) - BufHelpers.keymap_set(self._bufnr, "n", "q", function() - if self._winid and vim.api.nvim_win_is_valid(self._winid) then - vim.api.nvim_win_close(self._winid, true) - end - end) - BufHelpers.keymap_set(self._bufnr, "n", "", function() - if self._winid and vim.api.nvim_win_is_valid(self._winid) then - vim.api.nvim_win_close(self._winid, true) - end - end) + 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) @@ -180,7 +177,7 @@ function ConfigOptionsModal:_activate_current_option() return end - self._callbacks.show_selector( + local shown = self._callbacks.show_selector( option, "Select " .. option.name .. ":", function(value) @@ -192,6 +189,14 @@ function ConfigOptionsModal:_activate_current_option() 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 index 4a1d8475..dd1d02eb 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -2,7 +2,8 @@ local assert = require("tests.helpers.assert") local spy = require("tests.helpers.spy") describe("agentic.ui.ConfigOptionsModal", function() - local ConfigOptionsModal = require("agentic.ui.config_options_modal") + --- @type agentic.ui.ConfigOptionsModal + local ConfigOptionsModal --- @type integer local bufnr @@ -32,7 +33,7 @@ describe("agentic.ui.ConfigOptionsModal", function() handle_change_spy(id, value, on_done) end, show_selector = function(option, prompt, callback) - show_selector_spy(option, prompt, callback) + return show_selector_spy(option, prompt, callback) end, }) modal:open() @@ -54,9 +55,14 @@ describe("agentic.ui.ConfigOptionsModal", function() 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() + show_selector_spy = spy.new(function() + return true + end) config_options = { options = { { @@ -171,6 +177,21 @@ describe("agentic.ui.ConfigOptionsModal", function() 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) From 064c7c699ed03ebcd3ab31399ad862c890599be8 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Sun, 19 Jul 2026 16:40:18 +0200 Subject: [PATCH 16/18] Format settings modal rows with descriptions and j/k nav --- lua/agentic/ui/config_options_modal.lua | 142 ++++++++++++++++--- lua/agentic/ui/config_options_modal.test.lua | 82 +++++++++-- 2 files changed, 195 insertions(+), 29 deletions(-) diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index 4318f354..c9d93e42 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -1,11 +1,17 @@ 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 @@ -48,6 +54,63 @@ local function find_option(options, id) 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 settings 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) @@ -56,14 +119,16 @@ function ConfigOptionsModal:new(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.5) - local height = math.max(#self._callbacks.get_options(), 1) - local row = math.floor((vim.o.lines - height) / 2) + 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) @@ -93,6 +158,16 @@ function ConfigOptionsModal:open() 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 @@ -107,29 +182,56 @@ function ConfigOptionsModal:_render() return end - self._line_option_ids = {} - --- @type string[] - local lines = {} + 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 - for line_number, option in ipairs(self._callbacks.get_options()) do - local rendered_value - if option.type == "boolean" then - rendered_value = option.currentValue and "[x]" or "[ ]" - else - rendered_value = " " .. get_select_value_name(option) - end + vim.bo[self._bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self._bufnr, 0, -1, false, lines) + vim.bo[self._bufnr].modifiable = false - lines[#lines + 1] = option.name .. ": " .. rendered_value - self._line_option_ids[line_number] = option.id + 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 - if #lines == 0 then - lines[1] = "No settings available" +--- 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 - vim.bo[self._bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self._bufnr, 0, -1, false, lines) - vim.bo[self._bufnr].modifiable = false + local cursor_row = vim.api.nvim_win_get_cursor(self._winid)[1] + + local current = 1 + for index, row in ipairs(self._option_rows) do + if row == cursor_row then + current = index + break + end + end + + local n = #self._option_rows + local target = ((current - 1 + direction + n) % n) + 1 + + pcall( + vim.api.nvim_win_set_cursor, + self._winid, + { self._option_rows[target], 0 } + ) end function ConfigOptionsModal:_render_after_applied() diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index dd1d02eb..33e5f55e 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -21,6 +21,8 @@ describe("agentic.ui.ConfigOptionsModal", function() local schedule_stub local session_id + local select_icon = string.char(0xef, 0x81, 0xb8) + local function open_modal() modal = ConfigOptionsModal:new({ get_options = function() @@ -50,10 +52,36 @@ describe("agentic.ui.ConfigOptionsModal", function() 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") @@ -99,25 +127,61 @@ describe("agentic.ui.ConfigOptionsModal", function() end end) - it("renders select names and unchecked booleans", function() + it("renders aligned rows with descriptions and booleans", function() open_modal() - assert.same(get_lines(), { "Model:  Opus", "Fast mode: [ ]" }) + 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("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()[2], "Fast mode: [x]") + 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:  unknown") + assert.equal( + get_lines()[1], + "Model: " .. select_icon .. " unknown" + ) end) it("renders an empty placeholder with no dispatch", function() @@ -133,7 +197,7 @@ describe("agentic.ui.ConfigOptionsModal", function() it("toggles a false boolean and keeps the window open", function() open_modal() - press_enter(2) + press_enter(4) assert.spy(handle_change_spy).was.called(1) assert.equal(handle_change_spy.calls[1][1], "fast") @@ -146,7 +210,7 @@ describe("agentic.ui.ConfigOptionsModal", function() config_options.options[2].currentValue = true open_modal() - press_enter(2) + press_enter(4) assert.spy(handle_change_spy).was.called(1) assert.equal(handle_change_spy.calls[1][1], "fast") @@ -206,7 +270,7 @@ describe("agentic.ui.ConfigOptionsModal", function() open_modal() session_id = "sess-2" - press_enter(2) + press_enter(4) assert.spy(handle_change_spy).was.called(0) assert.spy(show_selector_spy).was.called(0) @@ -218,13 +282,13 @@ describe("agentic.ui.ConfigOptionsModal", function() callback() end) open_modal() - press_enter(2) + 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()[2], "Fast mode: [x]") + assert.equal(get_lines()[4], "Fast mode: [x]") end) end) From 2400c6f42fa01b327c8a10ce91c364e5ffff4601 Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Sun, 19 Jul 2026 16:47:24 +0200 Subject: [PATCH 17/18] Rename settings modal to options across code and docs --- README.md | 4 ++-- doc/agentic.txt | 4 ++-- lua/agentic/acp/agent_config_options.lua | 8 +++---- lua/agentic/acp/agent_config_options.test.lua | 24 +++++++++---------- lua/agentic/config_default.lua | 2 +- lua/agentic/ui/config_options_modal.lua | 6 ++--- lua/agentic/ui/config_options_modal.test.lua | 2 +- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 3cc9a6e3..f0b779d9 100644 --- a/README.md +++ b/README.md @@ -725,7 +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 settings modal | +| `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 | @@ -759,7 +759,7 @@ your setup: switch_provider = "s", -- Switch ACP provider switch_model = "m", -- Switch model change_thought_level = "t", -- Select thought effort level - open_settings = "o", -- Open settings modal + open_options = "o", -- Open options modal }, -- Keybindings for the prompt buffer only diff --git a/doc/agentic.txt b/doc/agentic.txt index 1d7f4568..4ff1ebab 100644 --- a/doc/agentic.txt +++ b/doc/agentic.txt @@ -577,7 +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 settings modal + o n Open options modal q n Close chat widget d n/v Remove file/selection/diagnostic ]] n Next chat heading @@ -613,7 +613,7 @@ Override default keybindings via the `keymaps` config option: switch_provider = "s", switch_model = "m", change_thought_level = "t", - open_settings = "o", + open_options = "o", }, prompt = { submit = { diff --git a/lua/agentic/acp/agent_config_options.lua b/lua/agentic/acp/agent_config_options.lua index 6130c7b7..9fef16d0 100644 --- a/lua/agentic/acp/agent_config_options.lua +++ b/lua/agentic/acp/agent_config_options.lua @@ -74,12 +74,12 @@ function AgentConfigOptions:new(buffers, callbacks) ) BufHelpers.multi_keymap_set( - Config.keymaps.widget.open_settings, + Config.keymaps.widget.open_options, bufnr, function() - self:_show_settings_modal() + self:_show_options_modal() end, - { desc = "Agentic: Open Settings" } + { desc = "Agentic: Open Options" } ) end @@ -299,7 +299,7 @@ function AgentConfigOptions:get_model_id() or self.legacy_agent_models.current_model_id end -function AgentConfigOptions:_show_settings_modal() +function AgentConfigOptions:_show_options_modal() local session_id = self.callbacks.get_session_id() if #self.options == 0 or not session_id then diff --git a/lua/agentic/acp/agent_config_options.test.lua b/lua/agentic/acp/agent_config_options.test.lua index 2353eda0..c30bb7ca 100644 --- a/lua/agentic/acp/agent_config_options.test.lua +++ b/lua/agentic/acp/agent_config_options.test.lua @@ -172,25 +172,25 @@ describe("agentic.acp.AgentConfigOptions", function() local Config = require("agentic.config") assert.stub(multi_keymap_stub).was.called(4) - assert.equal("o", Config.keymaps.widget.open_settings) + 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_settings, + 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 Settings", + "Agentic: Open Options", multi_keymap_stub.calls[4][4].desc ) end) end) - describe("open settings keymap", function() + describe("open options keymap", function() local modal_module_name = "agentic.ui.config_options_modal" local original_modal_module --- @type TestStub @@ -201,18 +201,18 @@ describe("agentic.acp.AgentConfigOptions", function() local modal_open_spy --- @return function callback - local function get_open_settings_callback() - local open_settings = - require("agentic.config").keymaps.widget.open_settings + 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_settings) then + if vim.deep_equal(call[1], open_options) then return call[3] end end - error("open_settings keymap was not registered") + error("open_options keymap was not registered") end before_each(function() @@ -234,7 +234,7 @@ describe("agentic.acp.AgentConfigOptions", function() it("warns without opening when no config options exist", function() local config = make_with_agent({}, { id = "sess-1" }) - get_open_settings_callback()() + get_open_options_callback()() assert.stub(notify_stub).was.called(1) assert.equal(vim.log.levels.WARN, notify_stub.calls[1][2]) @@ -247,7 +247,7 @@ describe("agentic.acp.AgentConfigOptions", function() local config = make_with_agent({}, { id = nil }) config:set_options({ model_option }) - get_open_settings_callback()() + get_open_options_callback()() assert.stub(notify_stub).was.called(1) assert.equal(vim.log.levels.WARN, notify_stub.calls[1][2]) @@ -269,7 +269,7 @@ describe("agentic.acp.AgentConfigOptions", function() }) config:set_options({ model_option }) - get_open_settings_callback()() + get_open_options_callback()() assert.spy(get_session_id_spy).was.called(1) assert.spy(modal_new_spy).was.called(1) diff --git a/lua/agentic/config_default.lua b/lua/agentic/config_default.lua index 7035d075..6ec29e1f 100644 --- a/lua/agentic/config_default.lua +++ b/lua/agentic/config_default.lua @@ -445,7 +445,7 @@ local ConfigDefault = { switch_provider = "s", switch_model = "m", change_thought_level = "t", - open_settings = "o", + open_options = "o", }, --- Keys bindings for the prompt buffer diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index c9d93e42..244a3294 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -23,7 +23,7 @@ ConfigOptionsModal.__index = ConfigOptionsModal local function notify_session_changed() Logger.notify( - "The agent session changed. Reopen settings to make changes.", + "The agent session changed. Reopen options to make changes.", vim.log.levels.WARN, { title = "Agentic" } ) @@ -105,7 +105,7 @@ local function build_lines(options) end if #lines == 0 then - lines[1] = "No settings available" + lines[1] = "No options available" end return lines, line_option_ids, description_rows, option_rows @@ -142,7 +142,7 @@ function ConfigOptionsModal:open() col = col, style = "minimal", border = "rounded", - title = " Agentic Settings ", + title = " Agentic Options ", title_pos = "center", footer = " toggle/select · q/ close ", footer_pos = "right", diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index 33e5f55e..a81b782c 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -188,7 +188,7 @@ describe("agentic.ui.ConfigOptionsModal", function() config_options.options = {} open_modal() - assert.same(get_lines(), { "No settings available" }) + 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) From 543bb352efd6d03afe57521bfe9dc6175d1f059b Mon Sep 17 00:00:00 2001 From: Carlos Gomes Date: Sun, 19 Jul 2026 17:00:38 +0200 Subject: [PATCH 18/18] Pick nearest option row for j/k from non-option rows --- lua/agentic/ui/config_options_modal.lua | 31 +++++++++++++++++--- lua/agentic/ui/config_options_modal.test.lua | 13 ++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lua/agentic/ui/config_options_modal.lua b/lua/agentic/ui/config_options_modal.lua index 244a3294..7ceeda70 100644 --- a/lua/agentic/ui/config_options_modal.lua +++ b/lua/agentic/ui/config_options_modal.lua @@ -215,17 +215,40 @@ function ConfigOptionsModal:_jump_to_option(direction) end local cursor_row = vim.api.nvim_win_get_cursor(self._winid)[1] + local rows = self._option_rows + local n = #rows - local current = 1 - for index, row in ipairs(self._option_rows) do + local current + for index, row in ipairs(rows) do if row == cursor_row then current = index break end end - local n = #self._option_rows - local target = ((current - 1 + direction + n) % n) + 1 + 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, diff --git a/lua/agentic/ui/config_options_modal.test.lua b/lua/agentic/ui/config_options_modal.test.lua index a81b782c..7b60732a 100644 --- a/lua/agentic/ui/config_options_modal.test.lua +++ b/lua/agentic/ui/config_options_modal.test.lua @@ -151,6 +151,19 @@ describe("agentic.ui.ConfigOptionsModal", function() 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()