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 |
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/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/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
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({