From 0aa45fbfda4eb9703894ab9a196932bf9631aa67 Mon Sep 17 00:00:00 2001 From: alsi-lawr Date: Wed, 3 Jun 2026 00:15:49 +0100 Subject: [PATCH 1/3] refactor(config): enforce resolved runtime config --- lua/agent_term/setup/config.lua | 298 ++++++++++++++++-------- lua/agent_term/setup/runtime_config.lua | 146 +++++++++--- tests/spec/config_spec.lua | 13 ++ 3 files changed, 320 insertions(+), 137 deletions(-) diff --git a/lua/agent_term/setup/config.lua b/lua/agent_term/setup/config.lua index 37159bd..640738a 100644 --- a/lua/agent_term/setup/config.lua +++ b/lua/agent_term/setup/config.lua @@ -6,6 +6,7 @@ local schema = require("agent_term.setup.schema") local M = {} local CONTEXT_TARGET_DEFAULT = "default" +---@type table M.agent_presets = { codex = { preset = "codex", @@ -58,6 +59,7 @@ local auto_resume_args = { }, } +---@type agent_term.ContextConfig local default_context = { file_path = ".agent-term/context.json", target_view = CONTEXT_TARGET_DEFAULT, @@ -218,7 +220,7 @@ local function known_presets() end ---@param name string ----@return table|nil +---@return agent_term.AgentPreset|nil local function preset_by_name(name) local preset = M.agent_presets[name] if type(preset) ~= "table" then @@ -227,6 +229,15 @@ local function preset_by_name(name) return vim.deepcopy(preset) end +---@param preset agent_term.AgentPreset +---@return agent_term.UserAgentConfig +local function preset_to_user_agent(preset) + return { + preset = preset.preset, + cmd = vim.deepcopy(preset.cmd), + } +end + ---@param name string ---@param mode "picker"|"last" ---@return string[]|nil @@ -242,50 +253,83 @@ function M.auto_resume_args_for_preset(name, mode) return vim.deepcopy(args) end ----@param agents table +---@param agents agent_term.UserAgents +---@param index integer +---@param agent any +local function normalize_list_agent(agents, index, agent) + if type(agent) ~= "string" then + warn_invalid("agents." .. index, "a preset string") + agents[index] = nil + return + end + if agents[agent] == nil then + agents[agent] = agent + end + agents[index] = nil +end + +---@param agents agent_term.UserAgents +---@param name string +---@param preset_name string +local function expand_preset_agent(agents, name, preset_name) + local preset = preset_by_name(preset_name) + if not preset then + notify.warn( + ("Unknown agent preset `%s`. Supported presets: %s"):format(preset_name, known_presets()) + ) + agents[name] = nil + return + end + agents[name] = preset_to_user_agent(preset) +end + +---@param agents agent_term.UserAgents +---@param name string +---@param agent table +local function expand_configured_agent_preset(agents, name, agent) + if type(agent.preset) ~= "string" then + return + end + local preset = preset_by_name(agent.preset) + if not preset then + notify.warn( + ("Unknown agent preset `%s`. Supported presets: %s"):format(agent.preset, known_presets()) + ) + agent.preset = nil + return + end + agent.preset = nil + agents[name] = vim.tbl_deep_extend("force", preset, agent) +end + +---@param agents agent_term.UserAgents +---@param name string +---@param agent any +local function normalize_named_agent(agents, name, agent) + if type(agent) == "string" then + expand_preset_agent(agents, name, agent) + return + end + if type(agent) ~= "table" then + warn_invalid("agents." .. name, "a table or preset string") + agents[name] = nil + return + end + expand_configured_agent_preset(agents, name, agent) +end + +---@param agents agent_term.UserAgents local function normalize_agents(agents) for index, agent in ipairs(agents) do - if type(agent) == "string" then - if agents[agent] == nil then - agents[agent] = agent - end - agents[index] = nil - else - warn_invalid("agents." .. index, "a preset string") - agents[index] = nil - end + normalize_list_agent(agents, index, agent) end for name, agent in pairs(agents) do - if type(agent) == "string" then - local preset = preset_by_name(agent) - if preset then - agents[name] = preset - else - notify.warn( - ("Unknown agent preset `%s`. Supported presets: %s"):format(agent, known_presets()) - ) - agents[name] = nil - end - elseif type(agent) == "table" and type(agent.preset) == "string" then - local preset = preset_by_name(agent.preset) - if preset then - agent.preset = nil - agents[name] = vim.tbl_deep_extend("force", preset, agent) - else - notify.warn( - ("Unknown agent preset `%s`. Supported presets: %s"):format(agent.preset, known_presets()) - ) - agent.preset = nil - end - elseif type(agent) ~= "table" then - warn_invalid("agents." .. name, "a table or preset string") - agents[name] = nil - end + normalize_named_agent(agents, name, agent) end end ----@param validated agent_term.Config +---@param validated agent_term.UserConfig local function validate_top_level_keys(validated) local unknown = schema.get_unknown_names(validated, is_known_name(known_top_level)) warn_and_strip( @@ -314,31 +358,42 @@ local function validate_nested_section(section_opts, section, known) ) end +---@param name string +---@param agent any +local function validate_agent_nested_keys(name, agent) + validate_nested_section(agent, "agents." .. name, known_agent) + if type(agent) ~= "table" then + return + end + + validate_nested_section(agent.context, "agents." .. name .. ".context", known_nested.context) + if type(agent.context) ~= "table" or type(agent.context.hook) ~= "table" then + return + end + + validate_nested_section( + agent.context.hook, + "agents." .. name .. ".context.hook", + known_context_hook + ) +end + ---@param agents table -local function validate_agent_nested_keys(agents) +local function validate_agents_nested_keys(agents) for name, agent in pairs(agents) do - validate_nested_section(agent, "agents." .. name, known_agent) - if type(agent) == "table" then - validate_nested_section(agent.context, "agents." .. name .. ".context", known_nested.context) - if type(agent.context) == "table" and type(agent.context.hook) == "table" then - validate_nested_section( - agent.context.hook, - "agents." .. name .. ".context.hook", - known_context_hook - ) - end - end + validate_agent_nested_keys(name, agent) end end ----@param validated agent_term.Config +---@param validated agent_term.UserConfig local function validate_nested_keys(validated) for section, known in pairs(known_nested) do validate_nested_section(validated[section], section, known) end - if type(validated.agents) == "table" then - validate_agent_nested_keys(validated.agents) + if type(validated.agents) ~= "table" then + return end + validate_agents_nested_keys(validated.agents) end ---@param value any @@ -367,7 +422,7 @@ local function is_known_value(known) end end ----@param validated agent_term.Config +---@param validated agent_term.UserConfig local function validate_section_shapes(validated) if validated.agents ~= nil and type(validated.agents) ~= "table" then warn_invalid("agents", "a table") @@ -393,6 +448,34 @@ local function validate_section_shapes(validated) end end +---@param context table +---@param path string +local function validate_context_hook_values(context, path) + local hook = context.hook + if type(hook) ~= "table" then + return + end + strip_invalid_field(hook, "enabled", path .. ".hook.enabled", is_boolean, "a boolean") + if not is_empty_table(hook) then + return + end + context.hook = nil +end + +---@param context table +---@param path string +local function validate_context_include_values(context, path) + for _, key in ipairs({ + "include_file_path", + "include_filetype", + "include_cursor", + "include_selection_range", + "include_diagnostics", + }) do + strip_invalid_field(context, key, path .. "." .. key, is_boolean, "a boolean") + end +end + ---@param context table|nil ---@param path string local function validate_context_values(context, path) @@ -410,21 +493,8 @@ local function validate_context_values(context, path) strip_invalid_field(context, "hook", path .. ".hook", function(value) return type(value) == "table" end, "a table") - if type(context.hook) == "table" then - strip_invalid_field(context.hook, "enabled", path .. ".hook.enabled", is_boolean, "a boolean") - if is_empty_table(context.hook) then - context.hook = nil - end - end - for _, key in ipairs({ - "include_file_path", - "include_filetype", - "include_cursor", - "include_selection_range", - "include_diagnostics", - }) do - strip_invalid_field(context, key, path .. "." .. key, is_boolean, "a boolean") - end + validate_context_hook_values(context, path) + validate_context_include_values(context, path) end ---@param agent table @@ -440,43 +510,72 @@ local function validate_agent_values(agent, path) validate_context_values(agent.context, path .. ".context") end ----@param validated agent_term.Config +---@param agent table +local function apply_preset_cmd(agent) + if agent.cmd ~= nil or type(agent.preset) ~= "string" then + return + end + local preset = preset_by_name(agent.preset) + if not preset then + return + end + agent.cmd = preset.cmd +end + +---@param name string +---@param agent any +local function validate_agent_config_values(name, agent) + if type(agent) ~= "table" then + return + end + validate_agent_values(agent, "agents." .. name) + apply_preset_cmd(agent) +end + +---@param agents table +local function validate_agents_values(agents) + for name, agent in pairs(agents) do + validate_agent_config_values(name, agent) + end +end + +---@param float agent_term.UserFloatConfig +local function validate_float_values(float) + strip_invalid_field(float, "width", "float.width", is_number, "a number") + strip_invalid_field(float, "height", "float.height", is_number, "a number") +end + +---@param panel agent_term.UserPanelConfig +local function validate_panel_values(panel) + strip_invalid_field( + panel, + "position", + "panel.position", + is_known_value(valid_panel_positions), + "`left`, `right`, or `bottom`" + ) + strip_invalid_field(panel, "width", "panel.width", is_number, "a number") + strip_invalid_field(panel, "height", "panel.height", is_number, "a number") +end + +---@param validated agent_term.UserConfig local function validate_option_values(validated) validate_section_shapes(validated) if type(validated.agents) == "table" then - for name, agent in pairs(validated.agents) do - if type(agent) == "table" then - validate_agent_values(agent, "agents." .. name) - if agent.cmd == nil and type(agent.preset) == "string" then - local preset = preset_by_name(agent.preset) - if preset then - agent.cmd = preset.cmd - end - end - end - end + validate_agents_values(validated.agents) end if type(validated.float) == "table" then - strip_invalid_field(validated.float, "width", "float.width", is_number, "a number") - strip_invalid_field(validated.float, "height", "float.height", is_number, "a number") + validate_float_values(validated.float) end if type(validated.panel) == "table" then - strip_invalid_field( - validated.panel, - "position", - "panel.position", - is_known_value(valid_panel_positions), - "`left`, `right`, or `bottom`" - ) - strip_invalid_field(validated.panel, "width", "panel.width", is_number, "a number") - strip_invalid_field(validated.panel, "height", "panel.height", is_number, "a number") + validate_panel_values(validated.panel) end end ----@param validated agent_term.Config +---@param validated agent_term.UserConfig local function validate_keymap_names(validated) if type(validated.keymaps) ~= "table" then return @@ -494,17 +593,15 @@ end ---@return string[] function M.agent_names(agents) local names = {} - for name, _ in pairs(agents or {}) do - if type(name) == "string" then - names[#names + 1] = name - end + for name, _ in pairs(agents) do + names[#names + 1] = name end table.sort(names) return names end ----@param user_opts agent_term.Config ----@return agent_term.Config +---@param user_opts agent_term.UserConfig +---@return agent_term.UserConfig function M.validate_schema(user_opts) local validated = vim.deepcopy(user_opts) validate_top_level_keys(validated) @@ -518,8 +615,8 @@ function M.validate_schema(user_opts) return validated end ----@param user_opts? agent_term.Config ----@return agent_term.Config +---@param user_opts? agent_term.UserConfig +---@return agent_term.ResolvedConfig function M.build_options(user_opts) local validated = user_opts if type(user_opts) == "table" then @@ -529,6 +626,7 @@ function M.build_options(user_opts) if type(validated) == "table" and type(validated.agents) == "table" then defaults.agents = {} end + ---@type agent_term.ResolvedConfig local options = vim.tbl_deep_extend("force", defaults, validated or {}) for _, agent in pairs(options.agents) do agent.context = vim.tbl_deep_extend("force", vim.deepcopy(default_context), agent.context or {}) diff --git a/lua/agent_term/setup/runtime_config.lua b/lua/agent_term/setup/runtime_config.lua index e8b3e52..75569fc 100644 --- a/lua/agent_term/setup/runtime_config.lua +++ b/lua/agent_term/setup/runtime_config.lua @@ -3,13 +3,22 @@ local setup_config = require("agent_term.setup.config") local M = {} ----@class agent_term.Config ----@field agents? table +---@alias agent_term.Config agent_term.UserConfig + +---@class agent_term.UserConfig +---@field agents? agent_term.UserAgents ---@field active_agent? string ----@field float? agent_term.FloatConfig ----@field panel? agent_term.PanelConfig +---@field float? agent_term.UserFloatConfig +---@field panel? agent_term.UserPanelConfig ---@field keymaps? table|false +---@class agent_term.ResolvedConfig +---@field agents table +---@field active_agent? string +---@field float agent_term.FloatConfig +---@field panel agent_term.PanelConfig +---@field keymaps table|false + ---@alias agent_term.SupportedAgent ---| "codex" ---| "gemini" @@ -18,36 +27,75 @@ local M = {} ---| "copilot" ---| "opencode" ----@class agent_term.AgentConfig +---@class agent_term.UserAgents +---@field [string] agent_term.UserAgentConfig|agent_term.SupportedAgent +---@field [integer] agent_term.SupportedAgent + +---@class agent_term.UserAgentConfig ---@field preset? agent_term.SupportedAgent ---@field cmd? string[] ---@field auto_resume? false|"picker"|"last" ----@field context? agent_term.ContextConfig +---@field context? agent_term.UserContextConfig ----@class agent_term.FloatConfig +---@class agent_term.AgentConfig +---@field preset? agent_term.SupportedAgent +---@field cmd string[] +---@field auto_resume? false|"picker"|"last" +---@field context agent_term.ContextConfig + +---@class agent_term.AgentPreset : agent_term.UserAgentConfig +---@field preset agent_term.SupportedAgent +---@field cmd string[] + +---@class agent_term.UserFloatConfig ---@field width? number ---@field height? number ---@field border? string|table ----@class agent_term.PanelConfig +---@class agent_term.FloatConfig +---@field width number +---@field height number +---@field border string|table + +---@class agent_term.UserPanelConfig ---@field position? "left"|"right"|"bottom" ---@field width? number ---@field height? number ----@class agent_term.ContextConfig +---@class agent_term.PanelConfig +---@field position "left"|"right"|"bottom" +---@field width number +---@field height number + +---@class agent_term.UserContextConfig ---@field file_path? string ---@field target_view? "default"|"float"|"panel" ----@field hook? agent_term.ContextHookConfig +---@field hook? agent_term.UserContextHookConfig ---@field include_file_path? boolean ---@field include_filetype? boolean ---@field include_cursor? boolean ---@field include_selection_range? boolean ---@field include_diagnostics? boolean ----@class agent_term.ContextHookConfig +---@class agent_term.ContextConfig +---@field file_path string +---@field target_view "default"|"float"|"panel" +---@field hook agent_term.ContextHookConfig +---@field include_file_path boolean +---@field include_filetype boolean +---@field include_cursor boolean +---@field include_selection_range boolean +---@field include_diagnostics boolean + +---@class agent_term.UserContextHookConfig ---@field enabled? boolean +---@class agent_term.ContextHookConfig +---@field enabled boolean + +---@type agent_term.ResolvedConfig M.options = setup_config.build_options() +---@type string|nil M.active_agent = nil local function state_dir() @@ -86,39 +134,63 @@ local function first_agent_name() return M.agent_names()[1] end +---@return string +local function require_agent_name() + local fallback = first_agent_name() + if fallback then + return fallback + end + local message = "agent-term.nvim config must define at least one valid agent." + notify.error(message) + error(message, 0) +end + ---@param name string|nil ---@return boolean function M.has_agent(name) - return type(name) == "string" and type(M.options.agents[name]) == "table" + return type(name) == "string" and M.options.agents[name] ~= nil end ---@param name? string ---@return agent_term.AgentConfig|nil -function M.agent(name) +function M.maybe_agent(name) return M.options.agents[name or M.active_agent] end ---@param name? string ----@return agent_term.ContextConfig|nil +---@return agent_term.AgentConfig +function M.agent(name) + local agent = M.maybe_agent(name) + assert(agent ~= nil, "agent-term.nvim active agent is not configured") + return agent +end + +---@param name? string +---@return agent_term.ContextConfig function M.context(name) - local agent = M.agent(name) - return type(agent) == "table" and agent.context or nil + return M.agent(name).context +end + +---@param name? string +---@return boolean +function M.auto_hook_enabled(name) + return M.context(name).hook.enabled end ---@param cmd string[]|nil ---@return string local function command_name(cmd) - if type(cmd) ~= "table" or #cmd == 0 then + if cmd == nil or #cmd == 0 then return "" end - return cmd[1] or "" + return cmd[1] end ---@param name string ---@return boolean, string|nil function M.can_run_agent(name) - local agent = M.agent(name) - local cmd = type(agent) == "table" and agent.cmd or nil + local agent = M.maybe_agent(name) + local cmd = agent and agent.cmd or nil local exe = command_name(cmd) if exe == "" then return false, "" @@ -147,28 +219,28 @@ function M.set_active_agent(name) end local function resolve_initial_active_agent() - local fallback = first_agent_name() + local fallback = require_agent_name() local persisted = read_persisted_agent() - if persisted ~= nil then - if M.has_agent(persisted) then - return persisted + if persisted == nil then + if M.has_agent(M.options.active_agent) then + return M.options.active_agent end - notify.warn( - ("Persisted active agent `%s` is no longer configured. Falling back to `%s`."):format( - persisted, - fallback or "none" - ) - ) return fallback end - if M.has_agent(M.options.active_agent) then - return M.options.active_agent + if M.has_agent(persisted) then + return persisted end + notify.warn( + ("Persisted active agent `%s` is no longer configured. Falling back to `%s`."):format( + persisted, + fallback or "none" + ) + ) return fallback end ----@param user_opts? agent_term.Config ----@return agent_term.Config +---@param user_opts? agent_term.UserConfig +---@return agent_term.ResolvedConfig function M.setup(user_opts) M.options = setup_config.build_options(user_opts) M.active_agent = resolve_initial_active_agent() @@ -178,8 +250,8 @@ end ---@param name? string ---@return string[]|nil function M.auto_resume_command(name) - local agent = M.agent(name) - if type(agent) ~= "table" or agent.auto_resume == nil or agent.auto_resume == false then + local agent = M.maybe_agent(name) + if agent == nil or not agent.auto_resume then return nil end local preset = agent.preset @@ -187,7 +259,7 @@ function M.auto_resume_command(name) return nil end local args = setup_config.auto_resume_args_for_preset(preset, agent.auto_resume) - if type(args) ~= "table" or type(agent.cmd) ~= "table" then + if not args then return nil end local command = vim.deepcopy(agent.cmd) diff --git a/tests/spec/config_spec.lua b/tests/spec/config_spec.lua index f6ad7ed..b83fe76 100644 --- a/tests/spec/config_spec.lua +++ b/tests/spec/config_spec.lua @@ -219,6 +219,19 @@ describe("Given multi-agent configuration", function() assert.is_true(#notifications >= 4) end) + it("When config resolves to no agents Then setup notifies and fails", function() + local config = require("agent_term.setup.runtime_config") + + assert.has_error(function() + config.setup({ agents = { "typo" } }) + end, "agent-term.nvim config must define at least one valid agent.") + assert.match("Unknown agent preset `typo`", notifications[1].msg) + assert.are.equal( + "agent-term.nvim config must define at least one valid agent.", + notifications[#notifications].msg + ) + end) + it("When active agent is switched Then it validates and persists the selection", function() local config = require("agent_term.setup.runtime_config") config.setup({ From 871e8c23cc12b04e3144636a7b642c616fbdab5b Mon Sep 17 00:00:00 2001 From: alsi-lawr Date: Wed, 3 Jun 2026 00:15:58 +0100 Subject: [PATCH 2/3] refactor(runtime): flatten control flow --- lua/agent_term/context/commands.lua | 28 ++++++++++++++++------- lua/agent_term/context/file.lua | 6 +---- lua/agent_term/context/submission.lua | 8 +------ lua/agent_term/hooks/autocmds.lua | 27 ++++------------------ lua/agent_term/hooks/init.lua | 22 +++--------------- lua/agent_term/runtime/session.lua | 16 +++++++++----- lua/agent_term/ui/controller.lua | 32 ++++++++++++++++----------- lua/agent_term/ui/float.lua | 6 ++--- lua/agent_term/ui/panel.lua | 6 ++--- 9 files changed, 64 insertions(+), 87 deletions(-) diff --git a/lua/agent_term/context/commands.lua b/lua/agent_term/context/commands.lua index ac13814..de858c4 100644 --- a/lua/agent_term/context/commands.lua +++ b/lua/agent_term/context/commands.lua @@ -7,13 +7,30 @@ local view_controller = require("agent_term.ui.controller") local M = {} +---@return boolean local function is_panel_focused() local session = state.session() return state.has_valid_panel_win() - and session + and session ~= nil and vim.api.nvim_get_current_win() == session.panel_win end +---@param sent boolean +---@param used_hook boolean +---@param hook_failure string|nil +---@return boolean +local function notify_send_failure(sent, used_hook, hook_failure) + if sent then + return false + end + if used_hook and hook_failure then + notify.error(("Failed to write agent hook context: %s"):format(hook_failure)) + return true + end + notify.error("Agent session is not running.") + return true +end + ---@param kind "buffer"|"selection"|"diagnostics" ---@param builder fun(): string|nil, string|nil local function send_context(kind, builder) @@ -28,12 +45,7 @@ local function send_context(kind, builder) end local sent, used_hook, hook_failure = submission.submit(message, kind) - if not sent then - if used_hook and hook_failure then - notify.error(("Failed to write agent hook context: %s"):format(hook_failure)) - return - end - notify.error("Agent session is not running.") + if notify_send_failure(sent, used_hook, hook_failure) then return end @@ -50,7 +62,7 @@ function M.send_buffer_context() end) end ----@param opts? vim.api.keyset.create_user_command.command_args +---@param opts? table function M.send_selection_context(opts) send_context("selection", function() return captured_context.resolve_message("selection", function() diff --git a/lua/agent_term/context/file.lua b/lua/agent_term/context/file.lua index 742e1b3..5a61dbe 100644 --- a/lua/agent_term/context/file.lua +++ b/lua/agent_term/context/file.lua @@ -3,11 +3,7 @@ local config = require("agent_term.setup.runtime_config") local M = {} local function context_file_path() - local context_opts = config.context() - local configured = ".agent-term/context.json" - if type(context_opts) == "table" and type(context_opts.file_path) == "string" then - configured = context_opts.file_path - end + local configured = config.context().file_path return vim.fn.fnamemodify(vim.fn.expand(configured), ":p") end diff --git a/lua/agent_term/context/submission.lua b/lua/agent_term/context/submission.lua index 287b27f..a7861c9 100644 --- a/lua/agent_term/context/submission.lua +++ b/lua/agent_term/context/submission.lua @@ -4,17 +4,11 @@ local terminal = require("agent_term.runtime.session") local M = {} -local function auto_hook_enabled() - local context = config.context() - local hook = type(context) == "table" and context.hook or nil - return type(hook) == "table" and hook.enabled == true -end - ---@param message string ---@param kind "buffer"|"selection"|"diagnostics" ---@return boolean ok, boolean used_hook, string? hook_failure function M.submit(message, kind) - if auto_hook_enabled() then + if config.auto_hook_enabled() then local ok, err = context_file.write(kind, message) if not ok then return false, true, err diff --git a/lua/agent_term/hooks/autocmds.lua b/lua/agent_term/hooks/autocmds.lua index 0fa1d38..1e6d512 100644 --- a/lua/agent_term/hooks/autocmds.lua +++ b/lua/agent_term/hooks/autocmds.lua @@ -6,12 +6,6 @@ local state = require("agent_term.runtime.state") local M = {} local augroup = vim.api.nvim_create_augroup("agent_term_hooks", { clear = true }) -local function auto_hook_enabled() - local context = config.context() - local hook = type(context) == "table" and context.hook or nil - return type(hook) == "table" and hook.enabled == true -end - local function is_editor_buffer(bufnr) if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false @@ -20,7 +14,7 @@ local function is_editor_buffer(bufnr) end local function update_context() - if not auto_hook_enabled() or not state.has_running_job() then + if not config.auto_hook_enabled() or not state.has_running_job() then return end @@ -40,7 +34,6 @@ local function update_context() end end - -- For selection, we check if it should be included and if a selection exists local sel_msg = context_builder.selection_message({}) if sel_msg then context_file.write("selection", sel_msg) @@ -54,11 +47,9 @@ local function update_context() end local function clear_context() - if not auto_hook_enabled() then + if not config.auto_hook_enabled() then return end - -- Mark stale by writing empty content. The python hook script - -- checks for empty content and returns 0 (no context) in that case. context_file.write("buffer", "") end @@ -74,13 +65,7 @@ function M.setup() vim.api.nvim_create_autocmd({ "BufDelete", "BufUnload" }, { group = augroup, - callback = function() - -- Small delay to ensure we don't clear if we are just switching buffers - -- and another BufEnter is about to fire. - -- Actually, BufDelete is for the buffer being removed. - -- If it's the current buffer, we might want to clear. - clear_context() - end, + callback = clear_context, }) vim.api.nvim_create_autocmd("ModeChanged", { @@ -88,19 +73,15 @@ function M.setup() pattern = { "[vV\x16sS]:*", "*:[vV\x16sS]" }, callback = function() local mode = vim.api.nvim_get_mode().mode - -- If we are no longer in any visual or select mode, we just left it. if not mode:find("^[vV\x16sS]") then update_context() end end, }) - -- Also update when diagnostics change, as they might arrive after BufEnter vim.api.nvim_create_autocmd("DiagnosticChanged", { group = augroup, - callback = function() - update_context() - end, + callback = update_context, }) end diff --git a/lua/agent_term/hooks/init.lua b/lua/agent_term/hooks/init.lua index 994de9f..b4077e1 100644 --- a/lua/agent_term/hooks/init.lua +++ b/lua/agent_term/hooks/init.lua @@ -11,26 +11,11 @@ local installers = { } local function command_name() - local agent = config.agent() - local cmd = type(agent) == "table" and agent.cmd or nil - if type(cmd) ~= "table" or type(cmd[1]) ~= "string" then - return nil - end - return vim.fn.fnamemodify(cmd[1], ":t") + return vim.fn.fnamemodify(config.agent().cmd[1], ":t") end local function context_file_path() - local context_opts = config.context() - if type(context_opts) == "table" and type(context_opts.file_path) == "string" then - return context_opts.file_path - end - return ".agent-term/context.json" -end - -local function auto_hook_enabled() - local context = config.context() - local hook = type(context) == "table" and context.hook or nil - return type(hook) == "table" and hook.enabled == true + return config.context().file_path end function M.install() @@ -54,7 +39,6 @@ function M.install() end local context = config.context() - context.hook = context.hook or {} context.hook.enabled = true local msg @@ -77,7 +61,7 @@ function M.detect() local agent_name = command_name() local installer = agent_name and installers[agent_name] or nil - if not auto_hook_enabled() then + if not config.auto_hook_enabled() then return false end diff --git a/lua/agent_term/runtime/session.lua b/lua/agent_term/runtime/session.lua index 4f831b2..3e6d3ff 100644 --- a/lua/agent_term/runtime/session.lua +++ b/lua/agent_term/runtime/session.lua @@ -61,6 +61,9 @@ function M.send(text) return false end local session = state.session() + if not session then + return false + end vim.api.nvim_chan_send(session.job_id, text) return true end @@ -82,7 +85,7 @@ function M.ensure_session(cmd, agent_name) end local agent = config.agent(agent_name) - local run_cmd = cmd or config.auto_resume_command(agent_name) or (agent and agent.cmd) + local run_cmd = cmd or config.auto_resume_command(agent_name) or agent.cmd if not can_run_cmd(run_cmd) then notify.error(("Agent command not found: %s"):format(command_name(run_cmd))) return nil @@ -131,12 +134,13 @@ end function M.close_all_views_except(agent_name) state.each_session(function(name, session) - if name ~= agent_name then - close_win_if_valid(session.float_win) - close_win_if_valid(session.panel_win) - state.reset_float_win(name) - state.reset_panel_win(name) + if name == agent_name then + return end + close_win_if_valid(session.float_win) + close_win_if_valid(session.panel_win) + state.reset_float_win(name) + state.reset_panel_win(name) end) end diff --git a/lua/agent_term/ui/controller.lua b/lua/agent_term/ui/controller.lua index c073467..c5f52cb 100644 --- a/lua/agent_term/ui/controller.lua +++ b/lua/agent_term/ui/controller.lua @@ -78,15 +78,13 @@ end ---@return "float"|"panel" function M.resolve_context_view() - local context = config.context() - local target = context and context.target_view or CONTEXT_TARGET_DEFAULT - if target == CONTEXT_TARGET_DEFAULT then - if state.has_valid_panel_win() then - return enums.view.PANEL - end - return enums.view.FLOAT + local target = config.context().target_view + if + target == enums.view.PANEL or (target == CONTEXT_TARGET_DEFAULT and state.has_valid_panel_win()) + then + return enums.view.PANEL end - return target + return enums.view.FLOAT end ---@return boolean @@ -97,6 +95,18 @@ function M.ensure_started_for_context() return M.open(M.resolve_context_view(), { enter_insert = false }) end +---@param previous string|nil +---@param name string +---@param view "float"|"panel" +---@return boolean +local function reopen_switched_view(previous, name, view) + if previous and previous ~= name then + terminal.close_views(previous) + end + terminal.close_all_views_except(name) + return M.open(view, { agent_name = name }) +end + ---@param name? string ---@param opts? { bang?: boolean } ---@return boolean @@ -118,11 +128,7 @@ function M.switch_agent(name, opts) end if view then - if previous and previous ~= name then - terminal.close_views(previous) - end - terminal.close_all_views_except(name) - return M.open(view, { agent_name = name }) + return reopen_switched_view(previous, name, view) end if opts.bang then diff --git a/lua/agent_term/ui/float.lua b/lua/agent_term/ui/float.lua index 46cfb21..43a54c9 100644 --- a/lua/agent_term/ui/float.lua +++ b/lua/agent_term/ui/float.lua @@ -36,7 +36,7 @@ end function M.open(buf, agent_name) local session = state.session(agent_name) - if state.has_valid_float_win(agent_name) then + if session and state.has_valid_float_win(agent_name) then vim.api.nvim_set_current_win(session.float_win) return session.float_win end @@ -60,7 +60,7 @@ end function M.close(agent_name) local session = state.session(agent_name) - if not state.has_valid_float_win(agent_name) then + if not session or not state.has_valid_float_win(agent_name) then state.reset_float_win(agent_name) return end @@ -71,7 +71,7 @@ end function M.focus(agent_name) local session = state.session(agent_name) - if not state.has_valid_float_win(agent_name) then + if not session or not state.has_valid_float_win(agent_name) then return false end diff --git a/lua/agent_term/ui/panel.lua b/lua/agent_term/ui/panel.lua index d2e46b6..dd00274 100644 --- a/lua/agent_term/ui/panel.lua +++ b/lua/agent_term/ui/panel.lua @@ -60,7 +60,7 @@ vim.api.nvim_create_autocmd({ "WinResized", "VimResized", "WinNew", "BufWinEnter function M.open(buf, agent_name) local session = state.session(agent_name) - if state.has_valid_panel_win(agent_name) then + if session and state.has_valid_panel_win(agent_name) then vim.api.nvim_set_current_win(session.panel_win) return session.panel_win end @@ -97,7 +97,7 @@ end function M.close(agent_name) local session = state.session(agent_name) - if not state.has_valid_panel_win(agent_name) then + if not session or not state.has_valid_panel_win(agent_name) then state.reset_panel_win(agent_name) return end @@ -108,7 +108,7 @@ end function M.focus(agent_name) local session = state.session(agent_name) - if not state.has_valid_panel_win(agent_name) then + if not session or not state.has_valid_panel_win(agent_name) then return false end From 4b1389264ac19d431fdc4b57e213c3a51211178e Mon Sep 17 00:00:00 2001 From: alsi-lawr Date: Wed, 3 Jun 2026 00:16:10 +0100 Subject: [PATCH 3/3] ci(luals): add source diagnostics gate --- .github/workflows/ci.yml | 12 ++++++++++++ .luarc.ci.json | 16 ++++++++++++++++ AGENTS.md | 20 +++++++++++++++++--- CONTRIBUTING.md | 18 ++++++++++++++++-- README.md | 2 +- 5 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 .luarc.ci.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59f4707..9b0fde0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,18 @@ jobs: with: args: lua tests + - name: Install LuaLS + env: + LUALS_VERSION: 3.18.1 + run: | + curl -fsSL -o /tmp/lua-language-server.tar.gz "https://github.com/LuaLS/lua-language-server/releases/download/${LUALS_VERSION}/lua-language-server-${LUALS_VERSION}-linux-x64.tar.gz" + mkdir -p /tmp/lua-language-server + tar -C /tmp/lua-language-server -xzf /tmp/lua-language-server.tar.gz + echo "/tmp/lua-language-server/bin" >> "$GITHUB_PATH" + + - name: Run LuaLS diagnostics + run: lua-language-server --check=. --checklevel=Warning --check_format=pretty --configpath=.luarc.ci.json + test: name: Test runs-on: ubuntu-latest diff --git a/.luarc.ci.json b/.luarc.ci.json new file mode 100644 index 0000000..b027806 --- /dev/null +++ b/.luarc.ci.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "LuaJIT", + "diagnostics.globals": [ + "vim" + ], + "workspace.library": [ + "${workspaceFolder}/lua", + "${3rd}/luv/library" + ], + "workspace.ignoreDir": [ + ".deps", + ".git", + "tests" + ] +} diff --git a/AGENTS.md b/AGENTS.md index aa789f4..320fbc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,9 @@ - Formatting is defined by `.stylua.toml`: - tabs, width 2, max column 100, Unix line endings. - Linting is defined by `.luacheckrc`. +- LuaLS diagnostics are defined by `.luarc.json` for editor use and `.luarc.ci.json` for CI. The + CI check runs at warning level and must fail on poor types, nil-flow issues, and annotation + mismatches in source files. - Module/file names use lowercase grouped paths, for example `runtime/session.lua`. - Prefer small modules with explicit `require(...)` boundaries. - Avoid compatibility wrappers, alias modules, and speculative abstractions. @@ -84,14 +87,25 @@ runtime paths. - `stylua --check lua tests`: formatting check. - `luacheck lua tests`: lint Lua source and specs. +- `lua-language-server --check=. --checklevel=Warning --check_format=pretty --configpath=.luarc.ci.json`: + fail on LuaLS source diagnostics. - Optional direct test invocation: - `nvim --headless -u tests/minimal_init.lua -i NONE -c "PlenaryBustedDirectory tests/spec { minimal_init = 'tests/minimal_init.lua' }" -c "qa"` ## Commit And Pull Request Guidelines -- Follow Angular-style conventional commits seen in history: - - `feat(config): ...`, `refactor(lua): ...`, `test(nvim): ...`, `docs(readme): ...`, - `chore(lua): ...`. +- Branch names must be scoped by intent, for example `feature/...`, `fix/...`, `refactor/...`, + `docs/...`, `test/...`, or `ci/...`. +- Follow Angular-style conventional commits. +- Commit scopes are optional. When used, scopes must name the project area being changed, not the + implementation language. Prefer scopes such as `config`, `runtime`, `context`, `ui`, `docs`, or + `ci`; avoid broad scopes such as `lua`. +- Examples: + - `feat(config): add agent preset` + - `refactor(runtime): flatten session flow` + - `test(config): cover invalid agent setup` + - `docs(readme): simplify agent summary` + - `ci(luals): add source diagnostics gate` - Keep commits atomic and scoped to one logical change. - PRs should include: - concise summary, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66ea7c0..2d4290d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Prerequisites: - [`nvim-lua/plenary.nvim`](https://github.com/nvim-lua/plenary.nvim) - `stylua` - `luacheck` +- `lua-language-server` - Any terminal-agent commands needed for manual testing If Plenary is not installed in a standard runtime path, set `PLENARY_PATH` when running tests. @@ -82,13 +83,18 @@ Formatting and linting: ```sh stylua --check lua tests luacheck lua tests +lua-language-server --check=. --checklevel=Warning --check_format=pretty --configpath=.luarc.ci.json ``` +LuaLS diagnostics run at warning level and are treated as a quality gate. The CI config checks +source files and fails on poor annotations, unresolved nil handling, and type mismatches. + Before opening a PR, run: ```sh stylua --check lua tests luacheck lua tests +lua-language-server --check=. --checklevel=Warning --check_format=pretty --configpath=.luarc.ci.json ./run_tests.sh ``` @@ -125,9 +131,17 @@ PRs should include: - test evidence with command and result, - screenshots or gifs for UI-visible float or panel changes. -Use conventional commit style for commits where practical, for example: +Branch names should be scoped by intent, for example `feature/...`, `fix/...`, `refactor/...`, +`docs/...`, `test/...`, or `ci/...`. + +Use Angular-style conventional commits. Commit scopes are optional; when used, they should name the +project area being changed rather than the implementation language. Prefer scopes such as `config`, +`runtime`, `context`, `ui`, `docs`, or `ci`; avoid broad scopes such as `lua`. + +Examples: - `feat(config): add agent preset` -- `fix(context): preserve source buffer context` +- `refactor(runtime): flatten session flow` - `test(nvim): cover panel focus behavior` - `docs(readme): simplify agent summary` +- `ci(luals): add source diagnostics gate` diff --git a/README.md b/README.md index db16450..c2a533f 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Supported enum values are available at `require("agent_term.enums").agent`. Run checks locally - ./run_tests.sh, stylua --check lua tests, luacheck lua tests + ./run_tests.sh, stylua --check lua tests, luacheck lua tests, lua-language-server --check=. --checklevel=Warning --check_format=pretty --configpath=.luarc.ci.json Contribution Guide