diff --git a/AGENTS.md b/AGENTS.md
index 97c6bbd..aa789f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,8 +4,8 @@
- Prefer clean, direct design with explicit module boundaries. Add compatibility layers only when
there is a concrete supported-compatibility requirement.
-- Keep the plugin backend-neutral. Use `agent`, `backend`, `session`, `terminal`, or
- capability-oriented names for new code.
+- Keep the plugin agent-neutral. Use `agent`, `session`, `terminal`, or capability-oriented names
+ for new code.
- Use `codex` only for the Codex preset/default command, demo material, or user-facing examples
that explicitly refer to Codex.
- Do not add root-level shim modules in `lua/agent_term/` just to re-export deeper modules. Require
@@ -28,17 +28,16 @@
- `tests/helpers/`: test utilities.
- Docs/media live in `README.md`, `docs/`, and `assets/`.
-## Backend And Context Semantics
+## Agent And Context Semantics
-- Backend behavior should be expressed as capabilities on `agent`, not as hard-coded backend names.
+- Agent behavior should be expressed as capabilities on `agent`, not as hard-coded agent names.
- Presets may provide defaults, but explicit user config should override preset behavior.
-- `agent` and `backend` are aliases in config; keep behavior aligned if either spelling is touched.
-- Context submission supports backend modes:
+- Context submission supports agent modes:
- `paste`: send context visibly to the running terminal job.
- `hook`: emit the configured `User` autocmd hook and fall back to paste if no receiver handles it.
-- Hook mode is backend-driven, not view-driven. It must work from panel mode, float mode, and source
+- Hook mode is agent-driven, not view-driven. It must work from panel mode, float mode, and source
windows while a panel exists.
-- Do not infer hook context support from a backend having hooks generally. For example, Copilot and
+- Do not infer hook context support from an agent having hooks generally. For example, Copilot and
Opencode use paste mode because their hooks are not compatible with this context-injection path.
- Panel focus only affects which editor context is used. When the panel is focused, use the last
captured source context instead of reading context from the terminal buffer.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1d6ed81..66ea7c0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,7 +31,7 @@ If Plenary is not installed in a standard runtime path, set `PLENARY_PATH` when
## Making Changes
- Keep changes scoped to one logical behavior or documentation update.
-- Prefer backend-neutral names such as `agent`, `backend`, `session`, and `terminal`.
+- Prefer agent-neutral names such as `agent`, `session`, and `terminal`.
- Keep `codex` naming limited to the Codex preset/default command or examples that explicitly refer
to Codex.
- Do not add root-level re-export shim modules in `lua/agent_term/`; require the real module path.
@@ -41,19 +41,19 @@ If Plenary is not installed in a standard runtime path, set `PLENARY_PATH` when
- For config, validate the existing supported surfaces, but do not build defensive guards around
every invalid Lua shape unless there is a concrete user-facing reason.
-## Backend Changes
+## Agent Preset Changes
-Backend presets are best-effort defaults, not a guarantee of feature parity between tools. When
+Agent presets are best-effort defaults, not a guarantee of feature parity between tools. When
adding or changing a preset:
- Add or update the preset in `lua/agent_term/setup/config.lua`.
- Add enum and annotation updates when a new preset is user-selectable.
-- Document exact commands and caveats in `docs/backends.md`.
+- Document exact commands and caveats in `docs/agents.md`.
- Keep the README summary compact.
- Add config specs for command, context mode, and resume capability behavior.
-Do not infer hook context support from a backend having hooks generally. Hook mode is only for
-backends that support this context-injection path.
+Do not infer hook context support from an agent having hooks generally. Hook mode is only for
+agents that support this context-injection path.
## Testing
@@ -100,7 +100,7 @@ luacheck lua tests
- For UI behavior, cover stale windows, closed buffers, focus changes, and view reuse where relevant.
- For context behavior, cover panel-focused captured context, source-window context, hook
acknowledgement, and hook fallback where relevant.
-- For backend/config behavior, cover presets, custom commands, resume capability gating, and invalid
+- For agent/config behavior, cover presets, custom commands, resume capability gating, and invalid
supported config values.
This repo uses Plenary because the tests need Neovim-hosted integration coverage for windows,
@@ -127,7 +127,7 @@ PRs should include:
Use conventional commit style for commits where practical, for example:
-- `feat(config): add backend preset`
+- `feat(config): add agent preset`
- `fix(context): preserve source buffer context`
- `test(nvim): cover panel focus behavior`
-- `docs(readme): simplify backend summary`
+- `docs(readme): simplify agent summary`
diff --git a/README.md b/README.md
index 93fd90a..db16450 100644
--- a/README.md
+++ b/README.md
@@ -9,17 +9,17 @@
- agent-term.nvim keeps one persistent interactive terminal-agent session alive and lets you view it as either a centered float or a left, right, or bottom panel.
+ agent-term.nvim keeps persistent interactive terminal-agent sessions inside Neovim and lets you view the active agent as either a centered float or a left, right, or bottom panel.
- Float and panel are mutually exclusive views over the same terminal buffer, so switching layouts does not start another agent process.
+ Each configured agent gets its own persistent terminal session. Switching agents changes the active view without killing unrelated sessions.
Install ·
Config ·
- Backends ·
+ Agents ·
Context ·
Quick Reference ·
Advanced Docs
@@ -29,19 +29,18 @@
- One session Keeps a single terminal-agent process alive and reuses it across views. |
+ Per-agent sessions Keeps a persistent terminal process for each configured agent. |
Two layouts Switch between float and panel without spawning a second terminal. |
Lightweight context Send buffer, selection, and diagnostics metadata without dumping full contents. |
- Backend presets Start quickly with Codex, Gemini, Claude, Aider, Copilot, or Opencode. |
+ Agent presets Start quickly with Codex, Gemini, Claude, Aider, Copilot, or Opencode. |
Custom agents Run any compatible interactive terminal command. |
Automatic resume Optionally start supported presets through a picker or last-session command. |
-The default backend is Codex, but the plugin is backend-agnostic. Presets are best-effort defaults
-for common terminal agents; any compatible interactive command can be configured directly.
+The default agent is Codex. Presets are best-effort defaults for common terminal agents; any compatible interactive command can be configured directly.
## Why agent-term.nvim?
@@ -52,9 +51,9 @@ That means Codex, Claude, Gemini, Aider, Copilot, OpenCode, and other terminal a
own slash commands, menus, model switching, permission flows, hooks, highlighting/autocomplete,
and session behavior when supported by the agent TUI.
-The plugin adds a Neovim bridge around that native UI: one persistent terminal session, float/panel
-views, editor context capture, diagnostics/selection/buffer metadata, keymaps, startup/kill helpers,
-and optional native hook installation.
+The plugin adds a Neovim bridge around that native UI: persistent per-agent terminal sessions,
+float/panel views, editor context capture, diagnostics/selection/buffer metadata, keymaps,
+runtime agent switching, startup/kill helpers, and optional native hook installation.
## Requirements
@@ -84,46 +83,51 @@ The `keys` block above is a lazy.nvim example. Plugin-managed keymaps are disabl
## Minimal Configuration
-Use a preset when the default command is enough:
+Configure one or more named agents. If no persisted active agent exists, the first configured agent
+by name is used. The selected agent is remembered globally in Neovim state.
```lua
require("agent_term").setup({
- agent = "claude",
+ agents = {
+ "claude",
+ "codex",
+ gemini = {
+ preset = "gemini",
+ cmd = { "gemini" },
+ },
+ custom = {
+ cmd = { "my-agent" },
+ },
+ },
+ keymaps = false,
})
```
-Or override the command while inheriting preset defaults:
+String list entries are shorthand for preset-only agents. The example above creates named `claude`
+and `codex` agents from their presets, while `gemini` and `custom` use explicit configuration.
-```lua
-require("agent_term").setup({
- agent = {
- preset = "codex",
- cmd = { "codex", "--model", "gpt-5.4-mini" },
- },
-})
+Switch the active agent at runtime without editing config:
+
+```vim
+:AgentTermSwitch gemini
+:AgentTermSwitch codex
```
-Custom commands are supported too. Plain custom commands default to paste-based context and normal
-startup:
+`:AgentTermSwitch` with no arguments prints the current active agent. Completion includes configured
+agent names only. `:AgentTermSwitch! ` kills and recreates only the target agent session;
+normal switching never kills unrelated agents.
-```lua
-require("agent_term").setup({
- agent = {
- cmd = { "my-agent" },
- },
- context = {
- file_path = ".agent-term/context.json",
- target_view = "default", -- "default" | "float" | "panel"
- },
-})
-```
+
+
+
-`backend = "..."` is also accepted as an alias for `agent = "..."`.
+Per-agent `preset`, `cmd`, `auto_resume`, and `context` settings are applied when that agent creates
+its terminal session.
-## Backend Presets
+## Agent Presets
Presets are convenience defaults, not a guarantee of feature parity across tools. Exact commands and
-caveats are documented in [docs/backends.md](docs/backends.md).
+caveats are documented in [docs/agents.md](docs/agents.md).
| Preset | Command | Hook installer support | Auto-resume picker | Auto-resume last |
| --- | --- | --- | --- | --- |
@@ -134,9 +138,9 @@ caveats are documented in [docs/backends.md](docs/backends.md).
| `copilot` | `copilot` | paste | `copilot --resume` | `copilot --continue` |
| `opencode` | `opencode` | paste | unavailable | `opencode --continue` |
-`agent.auto_resume` accepts `"picker"`, `"last"`, `false`, or `nil`. The default is unset.
+`agents..auto_resume` accepts `"picker"`, `"last"`, `false`, or `nil`. The default is unset.
When a preset command is overridden, the selected auto-resume arguments are appended to
-`agent.cmd`.
+`agents..cmd`.
Supported enum values are available at `require("agent_term.enums").agent`.
@@ -166,17 +170,17 @@ Supported enum values are available at `require("agent_term.enums").agent`.
| Install native agent hooks |
:AgentTermInstallHooks |
- Native hooks |
+ Native hooks |
- | Switch backend preset |
- agent = "claude" |
- Backend presets |
+ Switch active agent |
+ :AgentTermSwitch claude |
+ Agent presets |
- | Override backend flags |
- agent = { preset = "...", cmd = { ... } } |
- Backend presets |
+ Override agent flags |
+ agents = { name = { preset = "...", cmd = { ... } } } |
+ Agent presets |
| Run checks locally |
@@ -195,7 +199,7 @@ Context commands send lightweight editor metadata, not full buffer contents.
### Automatic Updates (Hook Integration)
-When automatic hook updates are enabled (`context.hook.enabled = true`), `agent-term.nvim` updates
+When automatic hook updates are enabled (`agents..context.hook.enabled = true`), `agent-term.nvim` updates
the hook context file in the background on buffer switch, diagnostics change, or after leaving
visual/select mode. It uses a priority system to ensure the most relevant context is always
available:
@@ -214,15 +218,15 @@ The plugin manages repo-level state in the `.agent-term/` directory by default.
Manual context commands always paste context into the running terminal job.
-Optional hook integration writes the latest context payload to `context.file_path` (default:
+Optional hook integration writes the latest context payload to `agents..context.file_path` (default:
`.agent-term/context.json`) for native agent hooks to consume during prompt lifecycle events. Run
-`:AgentTermInstallHooks` to install supported native hooks for the configured agent.
+`:AgentTermInstallHooks` to install supported native hooks for the active agent.
Hook installation is explicit. Normal setup does not modify project or user agent config files.
Codex, Claude, and Gemini support native hook installation. Aider, Copilot, and Opencode stay
paste-only unless you explicitly configure a verified native hook integration.
-If the agent is not running, context commands open the configured `context.target_view`. The default
+If the active agent is not running, context commands open the configured `agents..context.target_view`. The default
target reuses an open panel when one exists, otherwise it opens a float.
## Common Commands
@@ -230,7 +234,8 @@ target reuses an open panel when one exists, otherwise it opens a float.
- `:AgentTermToggle`: open or hide the default floating view.
- `:AgentTermPanelToggle`: open or hide the panel view.
- `:AgentTermClose`: close agent windows while keeping the process alive.
-- `:AgentTermKill`: stop the process, close windows, delete the terminal buffer, and clear state.
+- `:AgentTermKill`: stop the active agent process, close its windows, delete its terminal buffer, and clear its state.
+- `:AgentTermSwitch [agent]`: show or switch the active agent. Use `!` to kill and recreate the target agent session.
- `:AgentTermSendBufferContext`: send lightweight metadata for the current buffer.
- `:AgentTermSendSelectionContext`: send lightweight metadata for the selected range.
- `:AgentTermSendDiagnosticsContext`: send compact diagnostics for the current buffer.
@@ -244,15 +249,15 @@ with `Panel`.
## Troubleshooting
- `Agent command not found`: check `:echo executable('your-agent-command')` or configure
- `agent.cmd`.
+ `agents..cmd`.
- No selection context: reselect text or run `:'<,'>AgentTermSendSelectionContext`.
-- `agent.auto_resume = "picker"` or `"last"` starts known presets with that auto-resume mode.
+- `agents..auto_resume = "picker"` or `"last"` starts known presets with that auto-resume mode.
Custom commands start normally because there is no preset behavior to infer.
-- To enable automatic native hook updates, install hooks and set `context.hook.enabled = true`.
+- To enable automatic native hook updates, install hooks and set `agents..context.hook.enabled = true`.
## Advanced Docs
-- [Backend presets](docs/backends.md): exact preset commands, auto-resume behavior, context modes,
- and backend caveats.
-- [Contribution guide](CONTRIBUTING.md): development setup, project layout, backend-change
+- [Agent presets](docs/agents.md): exact preset commands, auto-resume behavior, context modes,
+ and agent caveats.
+- [Contribution guide](CONTRIBUTING.md): development setup, project layout, agent-change
expectations, testing commands, coverage notes, and PR guidance.
diff --git a/doc/agent-term.txt b/doc/agent-term.txt
index 85ef1fb..e1dbdb9 100644
--- a/doc/agent-term.txt
+++ b/doc/agent-term.txt
@@ -1,9 +1,9 @@
*agent-term.nvim.txt* agent-term.nvim help
agent-term.nvim is a Neovim bridge for terminal-based coding agents.
-It keeps one persistent terminal-agent session and shows it in float/panel views.
+It keeps persistent terminal-agent sessions and shows the active agent in
+float/panel views.
-==============================================================================
CONTENTS *agent-term-contents*
1. Requirements .............................................. |agent-term-requirements|
@@ -13,134 +13,127 @@ CONTENTS *agent-term-contents
5. Hooks ..................................................... |agent-term-hooks|
6. Lua API ................................................... |agent-term-api|
-==============================================================================
REQUIREMENTS *agent-term-requirements*
- Neovim 0.12+
- An installed terminal coding agent (for example: codex, claude, gemini)
-==============================================================================
SETUP *agent-term-setup*
-With lazy.nvim >
+With lazy.nvim:
+>
{
"alsi-lawr/agent-term.nvim",
main = "agent_term",
opts = {},
}
<
-Minimal setup >
+
+Basic multi-agent setup:
+>
require("agent_term").setup({
- agent = "codex",
+ agents = {
+ "claude",
+ "codex",
+ gemini = {
+ preset = "gemini",
+ cmd = { "gemini" },
+ },
+ custom = {
+ cmd = { "my-agent" },
+ },
+ },
+ keymaps = false,
})
<
-==============================================================================
+
+String list entries are shorthand for preset-only agents. The setup above
+creates named `claude` and `codex` agents from their presets.
+
COMMANDS *agent-term-commands*
-General:
:AgentTermOpen Open/focus default (float) view
- :AgentTermClose Close all AgentTerm windows, keep process
+ :AgentTermClose Close active AgentTerm windows, keep process
:AgentTermToggle Toggle default (float) view
:AgentTermFocus Focus default (float) view
- :AgentTermKill Stop process and clear terminal state
+ :AgentTermKill Stop active agent process and clear its state
+ :AgentTermSwitch [agent] Show or switch the active agent
+ :AgentTermSwitch! {agent} Kill/recreate only the target agent session
-Float:
:AgentTermFloatOpen
:AgentTermFloatClose
:AgentTermFloatToggle
:AgentTermFloatFocus
-Panel:
:AgentTermPanelOpen
:AgentTermPanelClose
:AgentTermPanelToggle
:AgentTermPanelFocus
-Context:
:AgentTermSendBufferContext
:AgentTermSendSelectionContext
:AgentTermSendDiagnosticsContext
-Hooks and utility:
:AgentTermInstallHooks
:AgentTermIgnore
-==============================================================================
CONFIGURATION *agent-term-config*
Call `require("agent_term").setup(opts)`.
Top-level keys:
- `agent` Agent preset string or explicit table
- `backend` Alias of `agent`
- `float` Float window options
- `panel` Panel window options
- `context` Context and hook options
- `keymaps` Table of keymaps, or `false` to disable
-Agent presets:
- "codex", "gemini", "claude", "aider", "copilot", "opencode"
+ `agents` Named agent definitions
+ `active_agent` Optional default active agent when no persisted agent exists
+ `float` Floating window layout
+ `panel` Panel layout
+ `keymaps` Keymap overrides or `false`
-Example (preset with command override) >
- require("agent_term").setup({
- agent = {
- preset = "codex",
- cmd = { "codex", "--model", "gpt-5.4-mini" },
- auto_resume = "last",
- },
- })
-<
-Example (custom command + auto hook updates) >
- require("agent_term").setup({
- agent = {
- cmd = { "my-agent" },
- },
- context = {
- hook = { enabled = true },
- },
- })
-<
-`agent` keys:
- `preset` Preset name to inherit defaults from
- `cmd` Terminal command as a string list
+Agent keys:
+
+ `preset` "codex" | "gemini" | "claude" | "aider" | "copilot" | "opencode"
+ `cmd` Command list, for example `{ "codex" }`
`auto_resume` false | nil | "picker" | "last"
+ `context` Per-agent context and hook behavior
-`context` keys:
- `file_path` Path for hook context JSON file
+Context keys:
+
+ `file_path` Default: `.agent-term/context.json`
`target_view` "default" | "float" | "panel"
- `hook.enabled` Enable automatic hook file updates
- `include_file_path` Include file path in context payload
- `include_filetype` Include filetype in context payload
- `include_cursor` Include cursor location for buffer context
- `include_selection_range` Include line range for selection context
- `include_diagnostics` Include diagnostics context data
-
-==============================================================================
-HOOKS *agent-term-hooks*
+ `hook.enabled` Enable automatic hook context file updates
+ `include_file_path`
+ `include_filetype`
+ `include_cursor`
+ `include_selection_range`
+ `include_diagnostics`
-Manual context commands always paste to the terminal job.
+Each configured agent can have its own persistent terminal session. Normal
+`:AgentTermSwitch {agent}` changes the active agent without killing existing
+sessions. Bang switching recreates only the target agent session.
-If `context.hook.enabled = true`, agent-term.nvim also writes context payloads to
-`context.file_path` for native hook consumers.
+The active agent is remembered globally under `stdpath("state")`.
-Run `:AgentTermInstallHooks` to install supported native hooks:
- - codex
- - claude
- - gemini
+HOOKS *agent-term-hooks*
+
+Manual context commands always paste context into the active terminal job.
+
+If `agents..context.hook.enabled = true`, agent-term.nvim also writes
+context payloads to `agents..context.file_path` for supported native
+agent hooks.
-For unsupported agents, install command warns and no hook files are written.
+Run `:AgentTermInstallHooks` to install supported native hooks for the active
+agent. Codex, Claude, and Gemini have installers. Unsupported agents warn and
+continue using paste mode.
-==============================================================================
LUA API *agent-term-api*
-Main module: >
+>
local agent_term = require("agent_term")
-<
-Functions:
- `setup(opts)`
-Runtime operations are exposed through user commands and keymaps, not the root
-Lua module.
+ agent_term.setup(opts)
+ agent_term.get_active_agent()
+ agent_term.set_active_agent("gemini")
+ agent_term.set_active_agent("codex", { bang = true })
+<
-==============================================================================
-vim:tw=78:ts=8:noet:ft=help:norl:
+See README.md and docs/agents.md for detailed examples.
diff --git a/docs/backends.md b/docs/agents.md
similarity index 50%
rename from docs/backends.md
rename to docs/agents.md
index 6e604a6..5aacb27 100644
--- a/docs/backends.md
+++ b/docs/agents.md
@@ -1,33 +1,32 @@
-# Backend Presets
+# Agent Presets
-agent-term.nvim is backend-agnostic. Presets are best-effort defaults for common terminal agents;
-they do not guarantee identical capabilities across tools.
+agent-term.nvim is agent-agnostic. Presets are best-effort defaults for common terminal agents; they
+do not guarantee identical capabilities across tools.
-You can select a preset with either spelling:
+Configure agents by name:
```lua
require("agent_term").setup({
- agent = "codex",
-})
-
-require("agent_term").setup({
- backend = "codex",
-})
-```
-
-You can also start from a preset and override individual fields:
-
-```lua
-require("agent_term").setup({
- agent = {
- preset = "codex",
- cmd = { "codex", "--model", "gpt-5.4-mini" },
+ agents = {
+ "claude",
+ "codex",
+ gemini = {
+ preset = "gemini",
+ cmd = { "gemini" },
+ },
+ custom = {
+ cmd = { "my-agent" },
+ },
},
})
```
-Plain custom commands do not inherit preset auto-resume or hook-context behavior. For example,
-`agent = { cmd = { "my-agent" } }` starts normally and uses paste-based context.
+String list entries are shorthand for preset-only agents. The example above creates named `claude`
+and `codex` agents from their presets, while `gemini` and `custom` use explicit configuration.
+
+Switch agents at runtime with `:AgentTermSwitch `. Each configured agent can keep its own
+persistent terminal session. Normal switching does not kill existing sessions; `:AgentTermSwitch!
+` kills and recreates only the target agent session.
## Preset Table
@@ -40,26 +39,33 @@ Plain custom commands do not inherit preset auto-resume or hook-context behavior
| `copilot` | `{ "copilot" }` | paste | `{ "copilot", "--resume" }` | `{ "copilot", "--continue" }` |
| `opencode` | `{ "opencode" }` | paste | unavailable | `{ "opencode", "--continue" }` |
-`agent.auto_resume` accepts `"picker"`, `"last"`, `false`, or `nil` and defaults to unset. When a
-mode is configured for a preset, new terminal sessions start with the matching command shown above.
-Existing running sessions are reused unchanged. If `agent.cmd` is overridden, the preset's
-auto-resume arguments are appended to that command.
+`agents..auto_resume` accepts `"picker"`, `"last"`, `false`, or `nil` and defaults to unset.
+When a mode is configured for a preset, new terminal sessions start with the matching command shown
+above. Existing running sessions are reused unchanged. If `agents..cmd` is overridden, the
+preset's auto-resume arguments are appended to that command.
+
+Plain custom commands do not inherit preset auto-resume behavior. For example, `custom = { cmd = {
+"my-agent" } }` starts normally and uses paste-based context unless you configure hook behavior for
+that agent.
## Context Behavior
Manual context commands always send context text to the running terminal channel with
`nvim_chan_send`.
-Optional hook integration writes the latest context command payload to `context.file_path` (default:
-`.agent-term/context.json`) for native hook consumers.
+Optional hook integration writes the latest context command payload to
+`agents..context.file_path` (default: `.agent-term/context.json`) for native hook consumers.
+
+When `agents..context.hook.enabled = true`, `agent-term.nvim` automatically updates the
+context file in the background as you work:
-When `context.hook.enabled = true`, `agent-term.nvim` automatically updates the context file in the
-background as you work:
- **On Buffer Switch**: Updates context for the new file.
- **On Diagnostics Change**: Updates when LSP or linter diagnostics arrive.
- **On Mode Change**: Updates after you finish a visual/select mode selection.
-The automatic updates follow a priority order: **Diagnostics > Selection > File Metadata**. This ensures the agent always sees what is most relevant to your current cursor position and editor state.
+The automatic updates follow a priority order: **Diagnostics > Selection > File Metadata**. This
+ensures the agent always sees what is most relevant to your current cursor position and editor
+state.
Codex, Claude, and Gemini can use automatic hook updates after you install native hooks. Aider,
Copilot, and Opencode use paste mode unless a verified native context-injection hook installer is
@@ -67,20 +73,20 @@ added.
## Native Hook Installation
-Run `:AgentTermInstallHooks` after selecting a supported agent. Installation is explicit; normal
+Run `:AgentTermInstallHooks` with a supported active agent. Installation is explicit; normal
`setup()` never writes agent hook files.
-The install command enables `context.hook.enabled = true` for the current Neovim session after it
-writes the hook files. To persist automatic hook updates across restarts, install hooks once and
-configure:
+The install command enables `agents..context.hook.enabled = true` for the current Neovim
+session after it writes the hook files. To persist automatic hook updates across restarts, install
+hooks once and configure the active agent with hook updates enabled:
```lua
require("agent_term").setup({
- agent = {
- preset = "codex", -- or "claude"
- },
- context = {
- hook = { enabled = true },
+ agents = {
+ codex = {
+ preset = "codex",
+ context = { hook = { enabled = true } },
+ },
},
})
```
@@ -121,22 +127,25 @@ Installer behavior is idempotent: if the exact hook entry already exists, instal
files unchanged.
To remove installed hooks, delete the matching `UserPromptSubmit` or `BeforeModel` entry from
-`~/.codex/hooks.json`, `~/.claude/settings.json`, or `~/.gemini/settings.json`, then delete the generated
-`~/.codex/hooks/agent_term_context.py`, `~/.claude/hooks/agent_term_context.py`, or `~/.gemini/hooks/agent_term_context.py` script. You can
-also delete `.agent-term/context.json`; it will be recreated when hook-mode context is sent again.
+`~/.codex/hooks.json`, `~/.claude/settings.json`, or `~/.gemini/settings.json`, then delete the
+generated `~/.codex/hooks/agent_term_context.py`, `~/.claude/hooks/agent_term_context.py`, or
+`~/.gemini/hooks/agent_term_context.py` script. You can also delete `.agent-term/context.json`; it
+will be recreated when hook-mode context is sent again.
## Auto Resume
-Auto resume is a startup mode, not a separate command surface:
+Auto resume is a per-agent startup mode:
```lua
require("agent_term").setup({
- agent = {
- preset = "codex",
- auto_resume = "last", -- false | nil | "picker" | "last"
+ agents = {
+ codex = {
+ preset = "codex",
+ auto_resume = "last", -- false | nil | "picker" | "last"
+ },
},
})
```
-The mode uses the preset command table above. Custom commands start with `agent.cmd`, even when
-`auto_resume` is set, because there is no reliable backend-neutral auto-resume flag to infer.
+The mode uses the preset command table above. Custom commands start with `agents..cmd`, even
+when `auto_resume` is set, because there is no reliable agent-neutral auto-resume flag to infer.
diff --git a/docs/assets/agent-switching-demo.gif b/docs/assets/agent-switching-demo.gif
new file mode 100644
index 0000000..3f0bd7c
Binary files /dev/null and b/docs/assets/agent-switching-demo.gif differ
diff --git a/docs/assets/context-retrieval-demo.gif b/docs/assets/context-retrieval-demo.gif
index b656425..84e209f 100644
Binary files a/docs/assets/context-retrieval-demo.gif and b/docs/assets/context-retrieval-demo.gif differ
diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif
index 1eaaeda..8cb7b5f 100644
Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ
diff --git a/docs/vhs/agent-switching.tape b/docs/vhs/agent-switching.tape
new file mode 100644
index 0000000..18f6a12
--- /dev/null
+++ b/docs/vhs/agent-switching.tape
@@ -0,0 +1,115 @@
+Output docs/assets/agent-switching-demo.gif
+
+Set Shell "bash"
+Set FontSize 14
+Set Width 1200
+Set Height 720
+Set Padding 20
+Set Framerate 15
+Set PlaybackSpeed 1.0
+Set TypingSpeed 5ms
+Set Theme "Catppuccin Mocha"
+
+Hide
+Type `export AGENT_TERM_NVIM_REPO="$PWD"`
+Enter
+Type "rm -rf /tmp/agent-term-nvim-vhs-switch-demo && mkdir -p /tmp/agent-term-nvim-vhs-switch-demo"
+Enter
+Type "cd /tmp/agent-term-nvim-vhs-switch-demo"
+Enter
+Type `printf '%s\n' '# agent switching demo' '' 'This file is only here to make the editor state visible.' > README.md`
+Enter
+Sleep 1s
+
+# Pre-accept Codex trust prompt for this disposable demo directory.
+Type `codex --model gpt-5.4-mini -c 'model_reasoning_effort="low"'`
+Enter
+Sleep 3s
+Type "y"
+Enter
+Sleep 1.5s
+Type@90ms "/quit"
+Enter
+Sleep 1.5s
+
+# Pre-accept Gemini trust prompt for this disposable demo directory.
+Type "gemini"
+Enter
+Sleep 9s
+Enter
+Sleep 5s
+Type@90ms "/quit"
+Enter
+Sleep 500ms
+
+# Setup neovim for the demo.
+Type "cat > init.lua <<'EOF'"
+Enter
+Type `vim.opt.runtimepath:prepend(os.getenv("AGENT_TERM_NVIM_REPO"))`
+Enter
+Type `require("agent_term").setup({`
+Enter
+Type ` agents = {`
+Enter
+Type ` codex = {`
+Enter
+Type ` cmd = { "codex", "--model", "gpt-5.4-mini", "-c", [[model_reasoning_effort="low"]] },`
+Enter
+Type ` },`
+Enter
+Type ` gemini = { preset = "gemini" },`
+Enter
+Type ` },`
+Enter
+Type `})`
+Enter
+Type `vim.keymap.set("t", "", [[]], { silent = true })`
+Enter
+Type "EOF"
+Enter
+Type "clear"
+Enter
+Sleep 2s
+Show
+
+Type@90ms "nvim -u init.lua README.md"
+Enter
+Sleep 2s
+
+Type@90ms ":AgentTermPanelOpen"
+Enter
+Sleep 4s
+Escape
+Sleep 700ms
+
+Type@90ms ":AgentTermSwitch"
+Enter
+Sleep 1.2s
+
+Type@90ms ":AgentTermSwitch gemini"
+Enter
+Sleep 4s
+Escape
+Sleep 700ms
+
+Type@90ms ":AgentTermSwitch"
+Enter
+Sleep 1.2s
+
+Type@90ms ":AgentTermSwitch codex"
+Enter
+Sleep 4s
+Escape
+Sleep 700ms
+
+Type@90ms ":AgentTermSwitch"
+Enter
+Sleep 1.2s
+
+Type@90ms ":AgentTermClose"
+Enter
+Sleep 800ms
+
+Type@90ms ":qa!"
+Enter
+Sleep 500ms
diff --git a/docs/vhs/context.tape b/docs/vhs/context.tape
index 4498325..b99dd02 100644
--- a/docs/vhs/context.tape
+++ b/docs/vhs/context.tape
@@ -38,7 +38,7 @@ Type `vim.opt.runtimepath:prepend(os.getenv("AGENT_TERM_NVIM_REPO"))`
Enter
Type `require("agent_term").setup({`
Enter
-Type ` agent = "gemini",`
+Type ` agents = { "gemini" },`
Enter
Type `})`
Enter
@@ -88,7 +88,7 @@ Type@110ms "What editor context have you received? Mention the active file name
Enter
Sleep 1s
Hide
-Sleep 15s
+Sleep 20s
Show
Sleep 5s
@@ -112,7 +112,7 @@ Type@110ms "Do you currently have any editor context? If the active file context
Enter
Sleep 1s
Hide
-Sleep 15s
+Sleep 20s
Show
Sleep 5s
@@ -141,7 +141,7 @@ Type@110ms "What editor context can you see now? Include the active file and any
Enter
Sleep 1s
Hide
-Sleep 15s
+Sleep 20s
Show
Sleep 5s
diff --git a/docs/vhs/demo.tape b/docs/vhs/demo.tape
index 9baa876..5303744 100644
--- a/docs/vhs/demo.tape
+++ b/docs/vhs/demo.tape
@@ -28,9 +28,13 @@ Type `vim.opt.runtimepath:prepend(os.getenv("AGENT_TERM_NVIM_REPO"))`
Enter
Type `require("agent_term").setup({`
Enter
-Type ` agent = {`
+Type ` agents = {`
Enter
-Type ` cmd = { "codex", "--model", "gpt-5.4-mini", "-c", [[model_reasoning_effort="low"]] },`
+Type ` codex = {`
+Enter
+Type ` cmd = { "codex", "--model", "gpt-5.4-mini", "-c", [[model_reasoning_effort="low"]] },`
+Enter
+Type ` },`
Enter
Type ` },`
Enter
diff --git a/lua/agent_term/commands/registry.lua b/lua/agent_term/commands/registry.lua
index 8ceaa3b..bd223e7 100644
--- a/lua/agent_term/commands/registry.lua
+++ b/lua/agent_term/commands/registry.lua
@@ -1,13 +1,24 @@
+local config = require("agent_term.setup.runtime_config")
local dispatch = require("agent_term.runtime.dispatch")
local M = {}
+local function complete_agents()
+ return config.agent_names()
+end
+
local COMMAND_SPECS = {
{ "AgentTermOpen", "open", "Open/focus Agent Term", {} },
{ "AgentTermClose", "close", "Close all Agent Term views", {} },
{ "AgentTermToggle", "toggle", "Toggle Agent Term", {} },
{ "AgentTermFocus", "focus", "Focus Agent Term", {} },
{ "AgentTermKill", "kill", "Kill Agent Term terminal session", {} },
+ {
+ "AgentTermSwitch",
+ "switch_agent",
+ "Switch active Agent Term agent",
+ { nargs = "?", bang = true, complete = complete_agents },
+ },
{ "AgentTermFloatOpen", "float_open", "Open/focus Agent Term float", {} },
{ "AgentTermFloatClose", "float_close", "Close Agent Term float", {} },
diff --git a/lua/agent_term/context/builder.lua b/lua/agent_term/context/builder.lua
index cb18e95..57cea86 100644
--- a/lua/agent_term/context/builder.lua
+++ b/lua/agent_term/context/builder.lua
@@ -79,7 +79,7 @@ function M.buffer_message()
local lines = make_header("file")
push(lines, "type: buffer")
- local opts = config.options.context
+ local opts = config.context()
append_common_metadata(lines, bufnr, opts)
if opts.include_cursor then
local line, col = cursor_line()
@@ -109,7 +109,7 @@ function M.selection_message(command_opts)
local lines = make_header("selection")
push(lines, "type: selection")
- local context_opts = config.options.context
+ local context_opts = config.context()
append_common_metadata(lines, bufnr, context_opts)
if context_opts.include_selection_range then
push(lines, ("selection: lines %d-%d"):format(first, last))
@@ -129,7 +129,7 @@ function M.diagnostics_message()
local lines = make_header("diagnostics")
push(lines, "type: diagnostics")
- local opts = config.options.context
+ local opts = config.context()
append_common_metadata(lines, bufnr, opts)
if opts.include_diagnostics then
diff --git a/lua/agent_term/context/captured.lua b/lua/agent_term/context/captured.lua
index f64077c..842de36 100644
--- a/lua/agent_term/context/captured.lua
+++ b/lua/agent_term/context/captured.lua
@@ -8,7 +8,8 @@ local NO_CAPTURED_CONTEXT_ERROR =
"No captured editor context available yet. Focus a file buffer and retry."
local function is_editor_buffer(bufnr)
- return bufnr ~= state.buf and vim.bo[bufnr].buftype == ""
+ local session = state.session()
+ return (not session or bufnr ~= session.buf) and vim.bo[bufnr].buftype == ""
end
local function build_snapshot()
diff --git a/lua/agent_term/context/commands.lua b/lua/agent_term/context/commands.lua
index d2c1683..ac13814 100644
--- a/lua/agent_term/context/commands.lua
+++ b/lua/agent_term/context/commands.lua
@@ -8,7 +8,10 @@ local view_controller = require("agent_term.ui.controller")
local M = {}
local function is_panel_focused()
- return state.has_valid_panel_win() and vim.api.nvim_get_current_win() == state.panel_win
+ local session = state.session()
+ return state.has_valid_panel_win()
+ and session
+ and vim.api.nvim_get_current_win() == session.panel_win
end
---@param kind "buffer"|"selection"|"diagnostics"
@@ -27,7 +30,7 @@ local function send_context(kind, builder)
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 backend hook context: %s"):format(hook_failure))
+ notify.error(("Failed to write agent hook context: %s"):format(hook_failure))
return
end
notify.error("Agent session is not running.")
diff --git a/lua/agent_term/context/file.lua b/lua/agent_term/context/file.lua
index c61aa43..742e1b3 100644
--- a/lua/agent_term/context/file.lua
+++ b/lua/agent_term/context/file.lua
@@ -3,7 +3,7 @@ local config = require("agent_term.setup.runtime_config")
local M = {}
local function context_file_path()
- local context_opts = config.options.context
+ 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
diff --git a/lua/agent_term/context/submission.lua b/lua/agent_term/context/submission.lua
index 71e3d26..287b27f 100644
--- a/lua/agent_term/context/submission.lua
+++ b/lua/agent_term/context/submission.lua
@@ -5,7 +5,7 @@ local terminal = require("agent_term.runtime.session")
local M = {}
local function auto_hook_enabled()
- local context = config.options.context
+ local context = config.context()
local hook = type(context) == "table" and context.hook or nil
return type(hook) == "table" and hook.enabled == true
end
diff --git a/lua/agent_term/hooks/autocmds.lua b/lua/agent_term/hooks/autocmds.lua
index bb563de..0fa1d38 100644
--- a/lua/agent_term/hooks/autocmds.lua
+++ b/lua/agent_term/hooks/autocmds.lua
@@ -7,7 +7,7 @@ local M = {}
local augroup = vim.api.nvim_create_augroup("agent_term_hooks", { clear = true })
local function auto_hook_enabled()
- local context = config.options.context
+ local context = config.context()
local hook = type(context) == "table" and context.hook or nil
return type(hook) == "table" and hook.enabled == true
end
@@ -16,7 +16,7 @@ local function is_editor_buffer(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
- return bufnr ~= state.buf and vim.bo[bufnr].buftype == ""
+ return not state.has_valid_buf() or bufnr ~= state.session().buf and vim.bo[bufnr].buftype == ""
end
local function update_context()
@@ -29,7 +29,7 @@ local function update_context()
return
end
- local opts = config.options.context
+ local opts = config.context()
-- Priority: diagnostics > selection > file
if opts.include_diagnostics then
diff --git a/lua/agent_term/hooks/init.lua b/lua/agent_term/hooks/init.lua
index 0e04c1b..994de9f 100644
--- a/lua/agent_term/hooks/init.lua
+++ b/lua/agent_term/hooks/init.lua
@@ -11,7 +11,7 @@ local installers = {
}
local function command_name()
- local agent = config.options.agent
+ 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
@@ -20,7 +20,7 @@ local function command_name()
end
local function context_file_path()
- local context_opts = config.options.context
+ local context_opts = config.context()
if type(context_opts) == "table" and type(context_opts.file_path) == "string" then
return context_opts.file_path
end
@@ -28,7 +28,7 @@ local function context_file_path()
end
local function auto_hook_enabled()
- local context = config.options.context
+ local context = config.context()
local hook = type(context) == "table" and context.hook or nil
return type(hook) == "table" and hook.enabled == true
end
@@ -53,8 +53,9 @@ function M.install()
return false
end
- config.options.context.hook = config.options.context.hook or {}
- config.options.context.hook.enabled = true
+ local context = config.context()
+ context.hook = context.hook or {}
+ context.hook.enabled = true
local msg
if result and result.changed then
@@ -76,12 +77,10 @@ function M.detect()
local agent_name = command_name()
local installer = agent_name and installers[agent_name] or nil
- -- Only perform safety check/fallback if automatic hook updates are enabled.
if not auto_hook_enabled() then
return false
end
- -- If we don't know how to check this agent, assume it's a custom setup and leave it alone
if not installer or type(installer.is_installed) ~= "function" then
return false
end
@@ -89,8 +88,7 @@ function M.detect()
if not installer.is_installed({
context_file_path = context_file_path(),
}) then
- -- Fallback to paste mode if hooks are not actually installed on disk
- config.options.context.hook.enabled = false
+ config.context().hook.enabled = false
notify.warn(
(
"Automatic hook updates enabled for `%s` but native hooks were not found. "
diff --git a/lua/agent_term/init.lua b/lua/agent_term/init.lua
index fabde1a..94f7f25 100644
--- a/lua/agent_term/init.lua
+++ b/lua/agent_term/init.lua
@@ -3,6 +3,7 @@ local captured_context = require("agent_term.context.captured")
local hooks = require("agent_term.hooks")
local keymaps = require("agent_term.setup.applied_keymaps")
local config = require("agent_term.setup.runtime_config")
+local view_controller = require("agent_term.ui.controller")
local M = {}
@@ -16,4 +17,16 @@ function M.setup(opts)
keymaps.setup()
end
+---@return string|nil
+function M.get_active_agent()
+ return config.active_agent
+end
+
+---@param name string
+---@param opts? { bang?: boolean }
+---@return boolean
+function M.set_active_agent(name, opts)
+ return view_controller.switch_agent(name, opts)
+end
+
return M
diff --git a/lua/agent_term/runtime/dispatch.lua b/lua/agent_term/runtime/dispatch.lua
index 1a4090c..f359ba1 100644
--- a/lua/agent_term/runtime/dispatch.lua
+++ b/lua/agent_term/runtime/dispatch.lua
@@ -19,6 +19,9 @@ local handlers = {
view_controller.focus(enums.view.FLOAT)
end,
kill = terminal.kill,
+ switch_agent = function(opts)
+ view_controller.switch_agent(opts.args, { bang = opts.bang })
+ end,
float_open = function()
view_controller.open(enums.view.FLOAT)
end,
diff --git a/lua/agent_term/runtime/session.lua b/lua/agent_term/runtime/session.lua
index e08f495..4f831b2 100644
--- a/lua/agent_term/runtime/session.lua
+++ b/lua/agent_term/runtime/session.lua
@@ -33,21 +33,26 @@ local function delete_buf_if_valid(buf)
end
end
-local function clear_session_views()
- close_win_if_valid(state.float_win)
- close_win_if_valid(state.panel_win)
- state.reset_float_win()
- state.reset_panel_win()
+local function clear_session_views(agent_name)
+ local session = state.session(agent_name)
+ if not session then
+ return
+ end
+ close_win_if_valid(session.float_win)
+ close_win_if_valid(session.panel_win)
+ state.reset_float_win(agent_name)
+ state.reset_panel_win(agent_name)
end
-local function clear_exited_session(job_id)
- if state.job_id ~= job_id then
+local function clear_exited_session(agent_name, job_id)
+ local session = state.session(agent_name)
+ if not session or session.job_id ~= job_id then
return
end
- local buf = state.buf
- state.reset_terminal()
- clear_session_views()
+ local buf = session.buf
+ state.reset_terminal(agent_name)
+ clear_session_views(agent_name)
delete_buf_if_valid(buf)
end
@@ -55,28 +60,36 @@ function M.send(text)
if not state.has_running_job() then
return false
end
- vim.api.nvim_chan_send(state.job_id, text)
+ local session = state.session()
+ vim.api.nvim_chan_send(session.job_id, text)
return true
end
-function M.ensure_session(cmd)
- if state.has_valid_buf() and state.has_running_job() then
- return state.buf
+function M.ensure_session(cmd, agent_name)
+ agent_name = agent_name or config.active_agent
+ local session = state.session(agent_name)
+ if not session then
+ return nil
+ end
+
+ if state.has_valid_buf(agent_name) and state.has_running_job(agent_name) then
+ return session.buf
end
- if state.has_valid_buf() and not state.has_running_job() then
- delete_buf_if_valid(state.buf)
- state.reset_terminal()
+ if state.has_valid_buf(agent_name) and not state.has_running_job(agent_name) then
+ delete_buf_if_valid(session.buf)
+ state.reset_terminal(agent_name)
end
- local run_cmd = cmd or config.auto_resume_command() or config.options.agent.cmd
+ local agent = config.agent(agent_name)
+ local run_cmd = cmd or config.auto_resume_command(agent_name) or (agent and agent.cmd)
if not can_run_cmd(run_cmd) then
notify.error(("Agent command not found: %s"):format(command_name(run_cmd)))
return nil
end
local buf = vim.api.nvim_create_buf(false, true)
- state.buf = buf
+ session.buf = buf
local job_id
vim.api.nvim_buf_call(buf, function()
@@ -84,7 +97,7 @@ function M.ensure_session(cmd)
term = true,
on_exit = function(exited_job_id)
vim.schedule(function()
- clear_exited_session(exited_job_id)
+ clear_exited_session(agent_name, exited_job_id)
end)
end,
})
@@ -93,44 +106,58 @@ function M.ensure_session(cmd)
if not (job_id and job_id > 0) then
notify.error(("Failed to start agent command: %s"):format(table.concat(run_cmd, " ")))
delete_buf_if_valid(buf)
- state.reset_terminal()
+ state.reset_terminal(agent_name)
return nil
end
- state.job_id = job_id
+ session.job_id = job_id
vim.api.nvim_create_autocmd("BufWipeout", {
group = augroup,
buffer = buf,
once = true,
callback = function()
- state.reset_terminal()
- close_win_if_valid(state.float_win)
- close_win_if_valid(state.panel_win)
- state.reset_float_win()
- state.reset_panel_win()
+ state.reset_terminal(agent_name)
+ clear_session_views(agent_name)
end,
})
return buf
end
-function M.close_views()
- clear_session_views()
+function M.close_views(agent_name)
+ clear_session_views(agent_name or config.active_agent)
end
-function M.kill()
- M.close_views()
+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)
+ end
+ end)
+end
+
+function M.kill(agent_name)
+ agent_name = agent_name or config.active_agent
+ local session = state.session(agent_name)
+ if not session then
+ return
+ end
+
+ M.close_views(agent_name)
- if state.job_id and state.job_id > 0 then
- pcall(vim.fn.jobstop, state.job_id)
+ if session.job_id and session.job_id > 0 then
+ pcall(vim.fn.jobstop, session.job_id)
end
- if state.has_valid_buf() then
- delete_buf_if_valid(state.buf)
+ if state.has_valid_buf(agent_name) then
+ delete_buf_if_valid(session.buf)
end
- state.reset_terminal()
+ state.reset_terminal(agent_name)
end
return M
diff --git a/lua/agent_term/runtime/state.lua b/lua/agent_term/runtime/state.lua
index c543425..f188b52 100644
--- a/lua/agent_term/runtime/state.lua
+++ b/lua/agent_term/runtime/state.lua
@@ -1,42 +1,93 @@
+local config = require("agent_term.setup.runtime_config")
+
local M = {
- buf = nil,
- job_id = nil,
- float_win = nil,
- panel_win = nil,
+ sessions = {},
last_captured_context = nil,
}
-function M.has_valid_buf()
- return M.buf and vim.api.nvim_buf_is_valid(M.buf)
+local function active_agent_name(name)
+ return name or config.active_agent
+end
+
+function M.session(name)
+ name = active_agent_name(name)
+ if type(name) ~= "string" then
+ return nil
+ end
+ M.sessions[name] = M.sessions[name]
+ or {
+ buf = nil,
+ job_id = nil,
+ float_win = nil,
+ panel_win = nil,
+ }
+ return M.sessions[name]
+end
+
+function M.each_session(fn)
+ for name, session in pairs(M.sessions) do
+ fn(name, session)
+ end
+end
+
+function M.has_valid_buf(name)
+ local session = M.session(name)
+ return session and session.buf and vim.api.nvim_buf_is_valid(session.buf)
end
-function M.has_running_job()
- if not (M.job_id and M.job_id > 0) then
+function M.has_running_job(name)
+ local session = M.session(name)
+ if not (session and session.job_id and session.job_id > 0) then
return false
end
- return vim.fn.jobwait({ M.job_id }, 0)[1] == -1
+ return vim.fn.jobwait({ session.job_id }, 0)[1] == -1
end
-function M.has_valid_float_win()
- return M.float_win and vim.api.nvim_win_is_valid(M.float_win)
+function M.has_valid_float_win(name)
+ local session = M.session(name)
+ return session and session.float_win and vim.api.nvim_win_is_valid(session.float_win)
end
---@return boolean
-function M.has_valid_panel_win()
- return M.panel_win ~= nil and vim.api.nvim_win_is_valid(M.panel_win)
+function M.has_valid_panel_win(name)
+ local session = M.session(name)
+ return session ~= nil
+ and session.panel_win ~= nil
+ and vim.api.nvim_win_is_valid(session.panel_win)
+end
+
+function M.reset_terminal(name)
+ local session = M.session(name)
+ if not session then
+ return
+ end
+ session.buf = nil
+ session.job_id = nil
end
-function M.reset_terminal()
- M.buf = nil
- M.job_id = nil
+function M.reset_float_win(name)
+ local session = M.session(name)
+ if session then
+ session.float_win = nil
+ end
end
-function M.reset_float_win()
- M.float_win = nil
+function M.reset_panel_win(name)
+ local session = M.session(name)
+ if session then
+ session.panel_win = nil
+ end
end
-function M.reset_panel_win()
- M.panel_win = nil
+function M.current_view()
+ local active = M.session()
+ if active and active.float_win and vim.api.nvim_win_is_valid(active.float_win) then
+ return "float"
+ end
+ if active and active.panel_win and vim.api.nvim_win_is_valid(active.panel_win) then
+ return "panel"
+ end
+ return nil
end
return M
diff --git a/lua/agent_term/setup/config.lua b/lua/agent_term/setup/config.lua
index 18962ca..37159bd 100644
--- a/lua/agent_term/setup/config.lua
+++ b/lua/agent_term/setup/config.lua
@@ -58,8 +58,26 @@ local auto_resume_args = {
},
}
+local default_context = {
+ file_path = ".agent-term/context.json",
+ target_view = CONTEXT_TARGET_DEFAULT,
+ hook = {
+ enabled = false,
+ },
+ include_file_path = true,
+ include_filetype = true,
+ include_cursor = true,
+ include_selection_range = true,
+ include_diagnostics = true,
+}
+
M.defaults = {
- agent = vim.deepcopy(M.agent_presets.codex),
+ agents = {
+ codex = vim.tbl_deep_extend("force", vim.deepcopy(M.agent_presets.codex), {
+ context = vim.deepcopy(default_context),
+ }),
+ },
+ active_agent = nil,
float = {
width = 0.85,
height = 0.8,
@@ -70,36 +88,25 @@ M.defaults = {
width = 0.35,
height = 0.35,
},
- context = {
- file_path = ".agent-term/context.json",
- target_view = CONTEXT_TARGET_DEFAULT,
- hook = {
- enabled = false,
- },
- include_file_path = true,
- include_filetype = true,
- include_cursor = true,
- include_selection_range = true,
- include_diagnostics = true,
- },
keymaps = false,
}
local known_top_level = {
- agent = true,
- backend = true,
+ agents = true,
+ active_agent = true,
float = true,
panel = true,
- context = true,
keymaps = true,
}
+local known_agent = {
+ preset = true,
+ cmd = true,
+ auto_resume = true,
+ context = true,
+}
+
local known_nested = {
- agent = {
- preset = true,
- cmd = true,
- auto_resume = true,
- },
float = {
width = true,
height = true,
@@ -165,25 +172,6 @@ local function is_string_list(value)
return true
end
----@param opts agent_term.Config|nil
----@return any
-local function raw_agent_config(opts)
- if type(opts) ~= "table" then
- return nil
- end
- if opts.agent ~= nil then
- return opts.agent
- end
- return opts.backend
-end
-
----@param opts agent_term.Config|nil
----@return boolean
-local function is_plain_custom_agent(opts)
- local agent = raw_agent_config(opts)
- return type(agent) == "table" and agent.preset == nil and is_string_list(agent.cmd)
-end
-
---@param target table
---@param unknown string[]
---@param message string
@@ -220,16 +208,6 @@ local function is_empty_table(tbl)
return next(tbl) == nil
end
-local function merge_backend_alias(validated)
- if
- validated.agent == nil
- and (type(validated.backend) == "table" or type(validated.backend) == "string")
- then
- validated.agent = vim.deepcopy(validated.backend)
- end
- validated.backend = nil
-end
-
local function known_presets()
local names = {}
for name, _ in pairs(M.agent_presets) do
@@ -264,42 +242,47 @@ function M.auto_resume_args_for_preset(name, mode)
return vim.deepcopy(args)
end
----@param validated table
-local function normalize_agent_shortcuts(validated)
- if type(validated.agent) == "string" then
- local preset = preset_by_name(validated.agent)
- if preset then
- validated.agent = preset
+---@param agents table
+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
- notify.warn(
- ("Unknown agent preset `%s`. Supported presets: %s"):format(
- validated.agent,
- known_presets()
- )
- )
- validated.agent = nil
+ warn_invalid("agents." .. index, "a preset string")
+ agents[index] = nil
end
- return
end
- if type(validated.agent) ~= "table" or type(validated.agent.preset) ~= "string" then
- return
- end
-
- local preset = preset_by_name(validated.agent.preset)
- if not preset then
- notify.warn(
- ("Unknown agent preset `%s`. Supported presets: %s"):format(
- validated.agent.preset,
- known_presets()
- )
- )
- validated.agent.preset = nil
- return
+ 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
end
-
- validated.agent.preset = nil
- validated.agent = vim.tbl_deep_extend("force", preset, validated.agent)
end
---@param validated agent_term.Config
@@ -331,14 +314,30 @@ local function validate_nested_section(section_opts, section, known)
)
end
+---@param agents table
+local function validate_agent_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
+ end
+end
+
---@param validated agent_term.Config
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.context) == "table" and type(validated.context.hook) == "table" then
- validate_nested_section(validated.context.hook, "context.hook", known_context_hook)
+ if type(validated.agents) == "table" then
+ validate_agent_nested_keys(validated.agents)
end
end
@@ -370,13 +369,20 @@ end
---@param validated agent_term.Config
local function validate_section_shapes(validated)
- for _, section in ipairs({ "float", "panel", "context" }) do
+ if validated.agents ~= nil and type(validated.agents) ~= "table" then
+ warn_invalid("agents", "a table")
+ validated.agents = nil
+ end
+ if validated.active_agent ~= nil and not is_string(validated.active_agent) then
+ warn_invalid("active_agent", "a non-empty string")
+ validated.active_agent = nil
+ end
+ for _, section in ipairs({ "float", "panel" }) do
if validated[section] ~= nil and type(validated[section]) ~= "table" then
warn_invalid(section, "a table")
validated[section] = nil
end
end
-
if
validated.keymaps ~= nil
and validated.keymaps ~= false
@@ -387,22 +393,70 @@ local function validate_section_shapes(validated)
end
end
----@param validated agent_term.Config
-local function validate_agent_values(validated)
- if type(validated.agent) ~= "table" then
+---@param context table|nil
+---@param path string
+local function validate_context_values(context, path)
+ if type(context) ~= "table" then
return
end
+ strip_invalid_field(context, "file_path", path .. ".file_path", is_string, "a non-empty string")
+ strip_invalid_field(
+ context,
+ "target_view",
+ path .. ".target_view",
+ is_known_value(valid_context_targets),
+ "`default`, `float`, or `panel`"
+ )
+ 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
+end
- strip_invalid_field(validated.agent, "cmd", "agent.cmd", is_string_list, "a non-empty string[]")
- strip_invalid_field(validated.agent, "auto_resume", "agent.auto_resume", function(value)
+---@param agent table
+---@param path string
+local function validate_agent_values(agent, path)
+ strip_invalid_field(agent, "cmd", path .. ".cmd", is_string_list, "a non-empty string[]")
+ strip_invalid_field(agent, "auto_resume", path .. ".auto_resume", function(value)
return value == false or is_known_value(valid_auto_resume_modes)(value)
end, "`picker`, `last`, or false")
+ strip_invalid_field(agent, "context", path .. ".context", function(value)
+ return type(value) == "table"
+ end, "a table")
+ validate_context_values(agent.context, path .. ".context")
end
---@param validated agent_term.Config
local function validate_option_values(validated)
validate_section_shapes(validated)
- validate_agent_values(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
+ end
if type(validated.float) == "table" then
strip_invalid_field(validated.float, "width", "float.width", is_number, "a number")
@@ -420,47 +474,6 @@ local function validate_option_values(validated)
strip_invalid_field(validated.panel, "width", "panel.width", is_number, "a number")
strip_invalid_field(validated.panel, "height", "panel.height", is_number, "a number")
end
-
- if type(validated.context) == "table" then
- strip_invalid_field(
- validated.context,
- "file_path",
- "context.file_path",
- is_string,
- "a non-empty string"
- )
- strip_invalid_field(
- validated.context,
- "target_view",
- "context.target_view",
- is_known_value(valid_context_targets),
- "`default`, `float`, or `panel`"
- )
- strip_invalid_field(validated.context, "hook", "context.hook", function(value)
- return type(value) == "table"
- end, "a table")
- if type(validated.context.hook) == "table" then
- strip_invalid_field(
- validated.context.hook,
- "enabled",
- "context.hook.enabled",
- is_boolean,
- "a boolean"
- )
- if is_empty_table(validated.context.hook) then
- validated.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(validated.context, key, "context." .. key, is_boolean, "a boolean")
- end
- end
end
---@param validated agent_term.Config
@@ -477,14 +490,28 @@ local function validate_keymap_names(validated)
)
end
+---@param agents table
+---@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
+ end
+ table.sort(names)
+ return names
+end
+
---@param user_opts agent_term.Config
---@return agent_term.Config
function M.validate_schema(user_opts)
local validated = vim.deepcopy(user_opts)
- merge_backend_alias(validated)
- normalize_agent_shortcuts(validated)
validate_top_level_keys(validated)
validate_section_shapes(validated)
+ if type(validated.agents) == "table" then
+ normalize_agents(validated.agents)
+ end
validate_nested_keys(validated)
validate_keymap_names(validated)
validate_option_values(validated)
@@ -494,16 +521,18 @@ end
---@param user_opts? agent_term.Config
---@return agent_term.Config
function M.build_options(user_opts)
- local plain_custom_agent = is_plain_custom_agent(user_opts)
local validated = user_opts
if type(user_opts) == "table" then
validated = M.validate_schema(user_opts)
end
local defaults = vim.deepcopy(M.defaults)
- if plain_custom_agent then
- defaults.agent = {}
+ if type(validated) == "table" and type(validated.agents) == "table" then
+ defaults.agents = {}
end
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 {})
+ end
return options
end
diff --git a/lua/agent_term/setup/runtime_config.lua b/lua/agent_term/setup/runtime_config.lua
index b1eeb1a..e8b3e52 100644
--- a/lua/agent_term/setup/runtime_config.lua
+++ b/lua/agent_term/setup/runtime_config.lua
@@ -1,13 +1,13 @@
+local notify = require("agent_term.notify")
local setup_config = require("agent_term.setup.config")
local M = {}
---@class agent_term.Config
----@field agent? agent_term.AgentConfig|agent_term.SupportedAgent
----@field backend? agent_term.AgentConfig|agent_term.SupportedAgent
+---@field agents? table
+---@field active_agent? string
---@field float? agent_term.FloatConfig
---@field panel? agent_term.PanelConfig
----@field context? agent_term.ContextConfig
---@field keymaps? table|false
---@alias agent_term.SupportedAgent
@@ -22,6 +22,7 @@ local M = {}
---@field preset? agent_term.SupportedAgent
---@field cmd? string[]
---@field auto_resume? false|"picker"|"last"
+---@field context? agent_term.ContextConfig
---@class agent_term.FloatConfig
---@field width? number
@@ -47,17 +48,137 @@ local M = {}
---@field enabled? boolean
M.options = setup_config.build_options()
+M.active_agent = nil
+
+local function state_dir()
+ return vim.fn.stdpath("state") .. "/agent-term"
+end
+
+local function state_file()
+ return state_dir() .. "/active-agent"
+end
+
+local function read_persisted_agent()
+ local path = state_file()
+ if vim.fn.filereadable(path) ~= 1 then
+ return nil
+ end
+ local lines = vim.fn.readfile(path)
+ local name = lines[1]
+ if type(name) ~= "string" or name == "" then
+ return nil
+ end
+ return name
+end
+
+local function persist_agent(name)
+ vim.fn.mkdir(state_dir(), "p")
+ vim.fn.writefile({ name }, state_file())
+end
+
+---@return string[]
+function M.agent_names()
+ return setup_config.agent_names(M.options.agents)
+end
+
+---@return string|nil
+local function first_agent_name()
+ return M.agent_names()[1]
+end
+
+---@param name string|nil
+---@return boolean
+function M.has_agent(name)
+ return type(name) == "string" and type(M.options.agents[name]) == "table"
+end
+
+---@param name? string
+---@return agent_term.AgentConfig|nil
+function M.agent(name)
+ return M.options.agents[name or M.active_agent]
+end
+
+---@param name? string
+---@return agent_term.ContextConfig|nil
+function M.context(name)
+ local agent = M.agent(name)
+ return type(agent) == "table" and agent.context or nil
+end
+
+---@param cmd string[]|nil
+---@return string
+local function command_name(cmd)
+ if type(cmd) ~= "table" or #cmd == 0 then
+ return ""
+ end
+ return cmd[1] or ""
+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 exe = command_name(cmd)
+ if exe == "" then
+ return false, ""
+ end
+ if vim.fn.executable(exe) ~= 1 then
+ return false, exe
+ end
+ return true, exe
+end
+
+---@param name string
+---@return boolean
+function M.set_active_agent(name)
+ if not M.has_agent(name) then
+ notify.error(("Unknown agent: %s"):format(name))
+ return false
+ end
+ local ok, exe = M.can_run_agent(name)
+ if not ok then
+ notify.error(("Agent command not found: %s"):format(exe or name))
+ return false
+ end
+ M.active_agent = name
+ persist_agent(name)
+ return true
+end
+
+local function resolve_initial_active_agent()
+ local fallback = first_agent_name()
+ local persisted = read_persisted_agent()
+ if persisted ~= nil then
+ 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
+ if M.has_agent(M.options.active_agent) then
+ return M.options.active_agent
+ end
+ return fallback
+end
---@param user_opts? agent_term.Config
---@return agent_term.Config
function M.setup(user_opts)
M.options = setup_config.build_options(user_opts)
+ M.active_agent = resolve_initial_active_agent()
return M.options
end
+---@param name? string
---@return string[]|nil
-function M.auto_resume_command()
- local agent = M.options.agent
+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
return nil
end
diff --git a/lua/agent_term/ui/controller.lua b/lua/agent_term/ui/controller.lua
index 099f025..c073467 100644
--- a/lua/agent_term/ui/controller.lua
+++ b/lua/agent_term/ui/controller.lua
@@ -2,6 +2,7 @@ local config = require("agent_term.setup.runtime_config")
local captured_context = require("agent_term.context.captured")
local enums = require("agent_term.enums")
local float = require("agent_term.ui.float")
+local notify = require("agent_term.notify")
local panel = require("agent_term.ui.panel")
local state = require("agent_term.runtime.state")
local terminal = require("agent_term.runtime.session")
@@ -14,22 +15,23 @@ local function enter_insert_mode()
end
---@param view "float"|"panel"
----@param opts? { enter_insert?: boolean }
+---@param opts? { enter_insert?: boolean, agent_name?: string }
---@return boolean
function M.open(view, opts)
opts = opts or {}
+ local agent_name = opts.agent_name or config.active_agent
captured_context.capture_before_view_switch()
- local buf = terminal.ensure_session()
+ local buf = terminal.ensure_session(nil, agent_name)
if not buf then
return false
end
if view == enums.view.PANEL then
- float.close()
- panel.open(buf)
+ float.close(agent_name)
+ panel.open(buf, agent_name)
else
- panel.close()
- float.open(buf)
+ panel.close(agent_name)
+ float.open(buf, agent_name)
end
if opts.enter_insert ~= false then
@@ -43,10 +45,10 @@ end
---@return nil
function M.close(view)
if view == enums.view.PANEL then
- panel.close()
+ panel.close(config.active_agent)
return
end
- float.close()
+ float.close(config.active_agent)
end
---@param view "float"|"panel"
@@ -65,7 +67,8 @@ end
---@return nil
function M.focus(view)
captured_context.capture_before_view_switch()
- local focused = view == enums.view.PANEL and panel.focus() or float.focus()
+ local focused = view == enums.view.PANEL and panel.focus(config.active_agent)
+ or float.focus(config.active_agent)
if focused then
enter_insert_mode()
return
@@ -75,7 +78,8 @@ end
---@return "float"|"panel"
function M.resolve_context_view()
- local target = config.options.context.target_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
@@ -93,4 +97,39 @@ function M.ensure_started_for_context()
return M.open(M.resolve_context_view(), { enter_insert = false })
end
+---@param name? string
+---@param opts? { bang?: boolean }
+---@return boolean
+function M.switch_agent(name, opts)
+ opts = opts or {}
+ if type(name) ~= "string" or name == "" then
+ notify.info(("Active agent: %s"):format(config.active_agent or "none"))
+ return true
+ end
+
+ local previous = config.active_agent
+ local view = state.current_view()
+ if not config.set_active_agent(name) then
+ return false
+ end
+
+ if opts.bang then
+ terminal.kill(name)
+ 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 })
+ end
+
+ if opts.bang then
+ return terminal.ensure_session(nil, name) ~= nil
+ end
+
+ return true
+end
+
return M
diff --git a/lua/agent_term/ui/float.lua b/lua/agent_term/ui/float.lua
index 5c90cf1..46cfb21 100644
--- a/lua/agent_term/ui/float.lua
+++ b/lua/agent_term/ui/float.lua
@@ -34,22 +34,23 @@ local function dimensions()
}
end
-function M.open(buf)
- if state.has_valid_float_win() then
- vim.api.nvim_set_current_win(state.float_win)
- return state.float_win
+function M.open(buf, agent_name)
+ local session = state.session(agent_name)
+ if state.has_valid_float_win(agent_name) then
+ vim.api.nvim_set_current_win(session.float_win)
+ return session.float_win
end
local win = vim.api.nvim_open_win(buf, true, dimensions())
- state.float_win = win
+ session.float_win = win
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
pattern = tostring(win),
once = true,
callback = function()
- if not state.has_valid_float_win() then
- state.reset_float_win()
+ if not state.has_valid_float_win(agent_name) then
+ state.reset_float_win(agent_name)
end
end,
})
@@ -57,22 +58,24 @@ function M.open(buf)
return win
end
-function M.close()
- if not state.has_valid_float_win() then
- state.reset_float_win()
+function M.close(agent_name)
+ local session = state.session(agent_name)
+ if not state.has_valid_float_win(agent_name) then
+ state.reset_float_win(agent_name)
return
end
- vim.api.nvim_win_close(state.float_win, true)
- state.reset_float_win()
+ vim.api.nvim_win_close(session.float_win, true)
+ state.reset_float_win(agent_name)
end
-function M.focus()
- if not state.has_valid_float_win() then
+function M.focus(agent_name)
+ local session = state.session(agent_name)
+ if not state.has_valid_float_win(agent_name) then
return false
end
- vim.api.nvim_set_current_win(state.float_win)
+ vim.api.nvim_set_current_win(session.float_win)
return true
end
diff --git a/lua/agent_term/ui/panel.lua b/lua/agent_term/ui/panel.lua
index eaec645..37637f4 100644
--- a/lua/agent_term/ui/panel.lua
+++ b/lua/agent_term/ui/panel.lua
@@ -24,10 +24,11 @@ local function apply_window_options(win)
end
end
-function M.open(buf)
- if state.has_valid_panel_win() then
- vim.api.nvim_set_current_win(state.panel_win)
- return state.panel_win
+function M.open(buf, agent_name)
+ local session = state.session(agent_name)
+ if state.has_valid_panel_win(agent_name) then
+ vim.api.nvim_set_current_win(session.panel_win)
+ return session.panel_win
end
local pos = config.options.panel.position
@@ -48,15 +49,15 @@ function M.open(buf)
vim.api.nvim_win_set_buf(win, buf)
apply_window_options(win)
- state.panel_win = win
+ session.panel_win = win
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
pattern = tostring(win),
once = true,
callback = function()
- if not state.has_valid_panel_win() then
- state.reset_panel_win()
+ if not state.has_valid_panel_win(agent_name) then
+ state.reset_panel_win(agent_name)
end
end,
})
@@ -64,22 +65,24 @@ function M.open(buf)
return win
end
-function M.close()
- if not state.has_valid_panel_win() then
- state.reset_panel_win()
+function M.close(agent_name)
+ local session = state.session(agent_name)
+ if not state.has_valid_panel_win(agent_name) then
+ state.reset_panel_win(agent_name)
return
end
- vim.api.nvim_win_close(state.panel_win, true)
- state.reset_panel_win()
+ vim.api.nvim_win_close(session.panel_win, true)
+ state.reset_panel_win(agent_name)
end
-function M.focus()
- if not state.has_valid_panel_win() then
+function M.focus(agent_name)
+ local session = state.session(agent_name)
+ if not state.has_valid_panel_win(agent_name) then
return false
end
- vim.api.nvim_set_current_win(state.panel_win)
+ vim.api.nvim_set_current_win(session.panel_win)
return true
end
diff --git a/tests/spec/agent_switch_spec.lua b/tests/spec/agent_switch_spec.lua
new file mode 100644
index 0000000..b8f320a
--- /dev/null
+++ b/tests/spec/agent_switch_spec.lua
@@ -0,0 +1,210 @@
+local reload = require("tests.helpers.reload")
+
+describe("Given runtime agent switching", function()
+ local original_executable = vim.fn.executable
+ local original_jobstart = vim.fn.jobstart
+ local original_jobwait = vim.fn.jobwait
+ local original_jobstop = vim.fn.jobstop
+ local original_notify = vim.notify
+ local original_stdpath = vim.fn.stdpath
+ local temp_state
+ local notifications
+
+ local function state_file()
+ return vim.fn.stdpath("state") .. "/agent-term/active-agent"
+ end
+
+ before_each(function()
+ reload.clear_agent_term_modules()
+ temp_state = vim.fn.tempname()
+ vim.fn.mkdir(temp_state, "p")
+ vim.fn.stdpath = function(what)
+ if what == "state" then
+ return temp_state
+ end
+ return original_stdpath(what)
+ end
+ pcall(vim.fn.delete, state_file())
+ notifications = {}
+ vim.notify = function(msg, level)
+ notifications[#notifications + 1] = { msg = msg, level = level }
+ end
+ end)
+
+ after_each(function()
+ vim.fn.executable = original_executable
+ vim.fn.jobstart = original_jobstart
+ vim.fn.jobwait = original_jobwait
+ vim.fn.jobstop = original_jobstop
+ vim.notify = original_notify
+ pcall(vim.fn.delete, state_file())
+ vim.fn.stdpath = original_stdpath
+ if temp_state then
+ vim.fn.delete(temp_state, "rf")
+ end
+ reload.clear_agent_term_modules()
+ end)
+
+ local function setup_plugin()
+ local plugin = require("agent_term")
+ plugin.setup({
+ agents = {
+ codex = { cmd = { "codex" } },
+ gemini = { cmd = { "gemini" } },
+ },
+ })
+ return plugin
+ end
+
+ it("When AgentTermSwitch is called with no args Then it reports the current agent", function()
+ setup_plugin()
+
+ vim.cmd.AgentTermSwitch()
+
+ assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level)
+ assert.match("Active agent: codex", notifications[#notifications].msg)
+ end)
+
+ it("When AgentTermSwitch is called with an agent Then it switches and persists", function()
+ setup_plugin()
+ vim.fn.executable = function(exe)
+ return exe == "gemini" and 1 or 0
+ end
+
+ vim.cmd("AgentTermSwitch gemini")
+
+ local config = require("agent_term.setup.runtime_config")
+ assert.are.equal("gemini", config.active_agent)
+ assert.are.same({ "gemini" }, vim.fn.readfile(state_file()))
+ end)
+
+ it("When completing AgentTermSwitch Then only configured agent names are returned", function()
+ setup_plugin()
+
+ local completion = vim.fn.getcompletion("AgentTermSwitch ", "cmdline")
+
+ assert.are.same({ "codex", "gemini" }, completion)
+ end)
+
+ it(
+ "When switching while a panel is open Then the same view type is opened for the target agent",
+ function()
+ setup_plugin()
+ local starts = {}
+ vim.fn.executable = function(_)
+ return 1
+ end
+ vim.fn.jobstart = function(cmd, _)
+ starts[#starts + 1] = cmd
+ return 300 + #starts
+ end
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] >= 301 and { -1 } or { 0 }
+ end
+
+ vim.cmd.AgentTermPanelOpen()
+ local state = require("agent_term.runtime.state")
+ local codex_win = state.session("codex").panel_win
+ vim.cmd("AgentTermSwitch gemini")
+
+ assert.is_false(vim.api.nvim_win_is_valid(codex_win))
+ assert.is_true(vim.api.nvim_win_is_valid(state.session("gemini").panel_win))
+ assert.are.same({ "codex" }, starts[1])
+ assert.are.same({ "gemini" }, starts[2])
+ assert.are.equal(301, state.session("codex").job_id)
+ assert.are.equal(302, state.session("gemini").job_id)
+ end
+ )
+
+ it(
+ "When switching back to an existing agent session Then the existing session is preserved",
+ function()
+ setup_plugin()
+ local starts = {}
+ vim.fn.executable = function(_)
+ return 1
+ end
+ vim.fn.jobstart = function(cmd, _)
+ starts[#starts + 1] = cmd
+ return 400 + #starts
+ end
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] >= 401 and { -1 } or { 0 }
+ end
+
+ vim.cmd.AgentTermFloatOpen()
+ vim.cmd("AgentTermSwitch gemini")
+ vim.cmd("AgentTermSwitch codex")
+
+ local state = require("agent_term.runtime.state")
+ assert.are.equal(2, #starts)
+ assert.are.equal(401, state.session("codex").job_id)
+ assert.are.equal(402, state.session("gemini").job_id)
+ end
+ )
+
+ it("When AgentTermSwitch bang is used Then only the target agent session is recreated", function()
+ setup_plugin()
+ local starts = {}
+ local stopped = {}
+ vim.fn.executable = function(_)
+ return 1
+ end
+ vim.fn.jobstart = function(cmd, _)
+ starts[#starts + 1] = cmd
+ return 500 + #starts
+ end
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] >= 501 and { -1 } or { 0 }
+ end
+ vim.fn.jobstop = function(job_id)
+ stopped[#stopped + 1] = job_id
+ return 1
+ end
+
+ vim.cmd.AgentTermFloatOpen()
+ vim.cmd("AgentTermSwitch gemini")
+ vim.cmd("AgentTermSwitch! codex")
+
+ local state = require("agent_term.runtime.state")
+ assert.are.same({ 501 }, stopped)
+ assert.are.equal(503, state.session("codex").job_id)
+ assert.are.equal(502, state.session("gemini").job_id)
+ end)
+
+ it(
+ "When AgentTermSwitch bang is used without a view Then the target session is recreated",
+ function()
+ setup_plugin()
+ local starts = {}
+ local stopped = {}
+ vim.fn.executable = function(_)
+ return 1
+ end
+ vim.fn.jobstart = function(cmd, _)
+ starts[#starts + 1] = cmd
+ return 600 + #starts
+ end
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] >= 601 and { -1 } or { 0 }
+ end
+ vim.fn.jobstop = function(job_id)
+ stopped[#stopped + 1] = job_id
+ return 1
+ end
+
+ local terminal = require("agent_term.runtime.session")
+ terminal.ensure_session(nil, "codex")
+
+ vim.cmd("AgentTermSwitch! codex")
+
+ local state = require("agent_term.runtime.state")
+ assert.are.same({ "codex" }, starts[1])
+ assert.are.same({ "codex" }, starts[2])
+ assert.are.same({ 601 }, stopped)
+ assert.are.equal(602, state.session("codex").job_id)
+ assert.is_nil(state.session("codex").float_win)
+ assert.is_nil(state.session("codex").panel_win)
+ end
+ )
+end)
diff --git a/tests/spec/commands_spec.lua b/tests/spec/commands_spec.lua
index fe7f52e..7135af5 100644
--- a/tests/spec/commands_spec.lua
+++ b/tests/spec/commands_spec.lua
@@ -12,6 +12,7 @@ describe("Given Agent Term command registration", function()
"AgentTermToggle",
"AgentTermFocus",
"AgentTermKill",
+ "AgentTermSwitch",
"AgentTermFloatOpen",
"AgentTermFloatClose",
"AgentTermFloatToggle",
@@ -42,6 +43,7 @@ describe("Given Agent Term command registration", function()
assert.is_true(exists("AgentTermPanelToggle"))
assert.is_true(exists("AgentTermSendBufferContext"))
assert.is_true(exists("AgentTermInstallHooks"))
+ assert.is_true(exists("AgentTermSwitch"))
assert.is_true(exists("AgentTermIgnore"))
end)
end)
diff --git a/tests/spec/config_spec.lua b/tests/spec/config_spec.lua
index b2668e0..f6ad7ed 100644
--- a/tests/spec/config_spec.lua
+++ b/tests/spec/config_spec.lua
@@ -1,11 +1,30 @@
---@diagnostic disable: undefined-field
local reload = require("tests.helpers.reload")
-describe("Given configuration setup", function()
+local function state_file()
+ return vim.fn.stdpath("state") .. "/agent-term/active-agent"
+end
+
+local function clear_persisted_agent()
+ pcall(vim.fn.delete, state_file())
+end
+
+describe("Given multi-agent configuration", function()
local original_notify = vim.notify
+ local original_stdpath = vim.fn.stdpath
+ local temp_state
local notifications
before_each(function()
+ temp_state = vim.fn.tempname()
+ vim.fn.mkdir(temp_state, "p")
+ vim.fn.stdpath = function(what)
+ if what == "state" then
+ return temp_state
+ end
+ return original_stdpath(what)
+ end
+ clear_persisted_agent()
notifications = {}
vim.notify = function(msg, level)
notifications[#notifications + 1] = { msg = msg, level = level }
@@ -13,296 +32,231 @@ describe("Given configuration setup", function()
end)
after_each(function()
+ clear_persisted_agent()
+ vim.fn.stdpath = original_stdpath
+ if temp_state then
+ vim.fn.delete(temp_state, "rf")
+ end
vim.notify = original_notify
reload.clear_agent_term_modules()
end)
- it("When setup is called with no user options Then defaults are exposed unchanged", function()
+ it("When setup is called with no options Then codex is the default configured agent", function()
local config = require("agent_term.setup.runtime_config")
local opts = config.setup()
- assert.are.same({ "codex" }, opts.agent.cmd)
- assert.are.equal("codex", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
- assert.are.equal("right", opts.panel.position)
- assert.are.equal(".agent-term/context.json", opts.context.file_path)
- assert.are.equal("default", opts.context.target_view)
- assert.is_false(opts.context.hook.enabled)
+ assert.are.same({ "codex" }, opts.agents.codex.cmd)
+ assert.are.equal("codex", opts.agents.codex.preset)
+ assert.are.equal("codex", config.active_agent)
+ assert.is_false(opts.agents.codex.context.hook.enabled)
assert.is_false(opts.keymaps)
end)
- it("When unknown config keys are provided Then they are stripped and users are warned", function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = {
- cmd = { "gemini" },
- auto_resume = "last",
- bogus_cmd_key = true,
- },
- panel = {
- position = "bottom",
- },
- context = {
- target_view = "default",
- include_cursor = false,
- },
- experimental = { flag = true },
- })
+ it(
+ "When agents are configured Then presets, overrides, and per-agent context are expanded",
+ function()
+ local config = require("agent_term.setup.runtime_config")
+ local opts = config.setup({
+ agents = {
+ codex = {
+ preset = "codex",
+ cmd = { "codex", "--model", "gpt-5.4-mini" },
+ auto_resume = "last",
+ context = { hook = { enabled = true } },
+ },
+ gemini = {
+ preset = "gemini",
+ context = { include_cursor = false },
+ },
+ custom = {
+ cmd = { "my-agent" },
+ },
+ },
+ })
- assert.are.same({ "gemini" }, opts.agent.cmd)
- assert.are.equal("last", opts.agent.auto_resume)
- assert.are.equal("bottom", opts.panel.position)
- assert.are.equal("default", opts.context.target_view)
- assert.is_false(opts.context.include_cursor)
- assert.is_nil(opts.agent.bogus_cmd_key)
- assert.is_nil(opts.experimental)
- assert.are.equal(2, #notifications)
- assert.are.equal(
- "Unknown agent-term.nvim config keys ignored: experimental",
- notifications[1].msg
- )
- assert.are.equal(
- "Unknown agent-term.nvim config keys ignored in `agent`: bogus_cmd_key",
- notifications[2].msg
- )
- assert.are.equal(vim.log.levels.WARN, notifications[1].level)
- assert.are.equal(vim.log.levels.WARN, notifications[2].level)
- end)
+ assert.are.same({ "codex", "--model", "gpt-5.4-mini" }, opts.agents.codex.cmd)
+ assert.are.equal("last", opts.agents.codex.auto_resume)
+ assert.is_true(opts.agents.codex.context.hook.enabled)
+ assert.are.same({ "gemini" }, opts.agents.gemini.cmd)
+ assert.is_false(opts.agents.gemini.context.include_cursor)
+ assert.is_true(opts.agents.gemini.context.include_file_path)
+ assert.are.same({ "my-agent" }, opts.agents.custom.cmd)
+ assert.is_nil(opts.agents.custom.preset)
+ end
+ )
- it("When auto resume mode is configured Then it is preserved", function()
+ it("When agents include preset list entries Then they expand to named agents", function()
local config = require("agent_term.setup.runtime_config")
local opts = config.setup({
- agent = {
- cmd = { "some-agent" },
- auto_resume = "picker",
+ agents = {
+ "claude",
+ "codex",
+ gemini = {
+ preset = "gemini",
+ auto_resume = "last",
+ },
},
})
- assert.are.same({ "some-agent" }, opts.agent.cmd)
- assert.are.equal("picker", opts.agent.auto_resume)
+ assert.are.same({ "claude" }, opts.agents.claude.cmd)
+ assert.are.equal("claude", opts.agents.claude.preset)
+ assert.are.same({ "codex" }, opts.agents.codex.cmd)
+ assert.are.equal("codex", opts.agents.codex.preset)
+ assert.are.same({ "gemini" }, opts.agents.gemini.cmd)
+ assert.are.equal("last", opts.agents.gemini.auto_resume)
+ assert.is_nil(opts.agents[1])
+ assert.is_nil(opts.agents[2])
+ assert.are.same({ "claude", "codex", "gemini" }, config.agent_names())
end)
- it("When auto resume is explicitly false Then it is preserved as disabled", function()
+ it("When preset list entries duplicate named agents Then explicit named config wins", function()
local config = require("agent_term.setup.runtime_config")
local opts = config.setup({
- agent = {
- cmd = { "some-agent" },
- auto_resume = false,
+ agents = {
+ "gemini",
+ gemini = {
+ preset = "gemini",
+ cmd = { "gemini", "--yolo" },
+ },
},
})
- assert.are.same({ "some-agent" }, opts.agent.cmd)
- assert.is_false(opts.agent.auto_resume)
- end)
-
- it(
- "When agent is set to a preset string Then that preset is expanded to backend defaults",
- function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = "gemini",
- })
-
- assert.are.same({ "gemini" }, opts.agent.cmd)
- assert.are.equal("gemini", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
- end
- )
-
- it("When agent is set from enum constants Then the matching preset is used", function()
- local config = require("agent_term.setup.runtime_config")
- local enums = require("agent_term.enums")
- local opts = config.setup({
- agent = enums.agent.CLAUDE,
- })
-
- assert.are.same({ "claude" }, opts.agent.cmd)
- assert.are.equal("claude", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
+ assert.are.same({ "gemini", "--yolo" }, opts.agents.gemini.cmd)
+ assert.is_nil(opts.agents[1])
end)
it(
- "When agent preset is configured as a table Then preset defaults are merged with overrides",
+ "When no persisted agent exists Then the deterministic first configured agent is active",
function()
local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = {
- preset = "codex",
- cmd = { "codex", "--model", "gpt-5.4-mini" },
- auto_resume = "last",
+ config.setup({
+ agents = {
+ gemini = { preset = "gemini" },
+ codex = { preset = "codex" },
},
})
- assert.are.same({ "codex", "--model", "gpt-5.4-mini" }, opts.agent.cmd)
- assert.are.equal("codex", opts.agent.preset)
- assert.are.equal("last", opts.agent.auto_resume)
+ assert.are.equal("codex", config.active_agent)
end
)
- it("When backend is a preset string alias Then it maps to the same preset defaults", function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- backend = "aider",
- })
+ it("When a persisted active agent is valid Then it is loaded", function()
+ vim.fn.mkdir(vim.fn.fnamemodify(state_file(), ":h"), "p")
+ vim.fn.writefile({ "gemini" }, state_file())
- assert.are.same({ "aider" }, opts.agent.cmd)
- assert.are.equal("aider", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
- end)
-
- it("When agent is set to copilot Then paste context is used by default", function()
local config = require("agent_term.setup.runtime_config")
- local enums = require("agent_term.enums")
- local opts = config.setup({
- agent = enums.agent.COPILOT,
+ config.setup({
+ agents = {
+ codex = { preset = "codex" },
+ gemini = { preset = "gemini" },
+ },
})
- assert.are.same({ "copilot" }, opts.agent.cmd)
- assert.are.equal("copilot", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
- assert.is_false(opts.context.hook.enabled)
+ assert.are.equal("gemini", config.active_agent)
+ assert.are.same({}, notifications)
end)
- it("When agent is set to opencode Then paste context is used by default", function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = "opencode",
- })
-
- assert.are.same({ "opencode" }, opts.agent.cmd)
- assert.are.equal("opencode", opts.agent.preset)
- assert.is_nil(opts.agent.auto_resume)
- assert.is_false(opts.context.hook.enabled)
- end)
+ it("When a persisted active agent is invalid Then setup warns and falls back", function()
+ vim.fn.mkdir(vim.fn.fnamemodify(state_file(), ":h"), "p")
+ vim.fn.writefile({ "missing" }, state_file())
- it("When agent cmd is custom without preset Then neutral backend defaults are used", function()
local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = {
- cmd = { "gemini" },
+ config.setup({
+ agents = {
+ codex = { preset = "codex" },
+ gemini = { preset = "gemini" },
},
})
- assert.are.same({ "gemini" }, opts.agent.cmd)
- assert.is_nil(opts.agent.auto_resume)
- assert.is_nil(opts.agent.preset)
+ assert.are.equal("codex", config.active_agent)
+ assert.are.equal(1, #notifications)
+ assert.match("Persisted active agent `missing` is no longer configured", notifications[1].msg)
end)
- it("When unknown agent config keys are provided Then they are ignored", function()
+ it("When unknown config keys are provided Then they are stripped and warned", function()
local config = require("agent_term.setup.runtime_config")
local opts = config.setup({
- agent = {
- cmd = { "custom-agent" },
- auto_resume = "last",
- transport = "hook",
+ agents = {
+ codex = {
+ preset = "codex",
+ transport = "hook",
+ },
},
+ experimental = true,
})
- assert.are.same({ "custom-agent" }, opts.agent.cmd)
- assert.are.equal("last", opts.agent.auto_resume)
- assert.is_false(opts.context.hook.enabled)
- assert.are.equal(1, #notifications)
+ assert.is_nil(opts.experimental)
+ assert.is_nil(opts.agents.codex.transport)
+ assert.are.equal(2, #notifications)
assert.match(
- "Unknown agent%-term%.nvim config keys ignored in `agent`: transport",
+ "Unknown agent%-term%.nvim config keys ignored: experimental",
notifications[1].msg
)
+ assert.match(
+ "Unknown agent%-term%.nvim config keys ignored in `agents.codex`: transport",
+ notifications[2].msg
+ )
end)
- it("When an unknown preset is used Then a warning is shown and defaults are kept", function()
+ it("When invalid agent values are provided Then defaults remain safe", function()
local config = require("agent_term.setup.runtime_config")
local opts = config.setup({
- agent = "not-real",
+ agents = {
+ codex = {
+ preset = "codex",
+ cmd = "codex",
+ auto_resume = "yes",
+ context = { include_cursor = "yes" },
+ },
+ },
+ panel = { position = "middle" },
})
- assert.are.same({ "codex" }, opts.agent.cmd)
- assert.are.equal(1, #notifications)
- assert.match("Unknown agent preset", notifications[1].msg)
- assert.are.equal(vim.log.levels.WARN, notifications[1].level)
+ assert.are.same({ "codex" }, opts.agents.codex.cmd)
+ assert.is_nil(opts.agents.codex.auto_resume)
+ assert.is_true(opts.agents.codex.context.include_cursor)
+ assert.are.equal("right", opts.panel.position)
+ assert.is_true(#notifications >= 4)
end)
- it("When unknown keymaps are configured Then they are stripped and warned", function()
+ it("When active agent is switched Then it validates and persists the selection", function()
local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- keymaps = {
- float_toggle = "ct",
- unknown_action = "xx",
+ config.setup({
+ agents = {
+ codex = { cmd = { "codex" } },
+ gemini = { cmd = { "gemini" } },
},
})
+ local original_executable = vim.fn.executable
+ vim.fn.executable = function(exe)
+ return exe == "gemini" and 1 or 0
+ end
- assert.are.equal("ct", opts.keymaps.float_toggle)
- assert.is_nil(opts.keymaps.unknown_action)
- assert.are.equal(1, #notifications)
- assert.are.equal(
- "Unknown agent-term.nvim keymaps ignored: unknown_action",
- notifications[1].msg
- )
- assert.are.equal(vim.log.levels.WARN, notifications[1].level)
- end)
-
- it("When keymaps is explicitly disabled Then setup preserves keymaps = false", function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- keymaps = false,
- })
+ assert.is_true(config.set_active_agent("gemini"))
+ assert.are.equal("gemini", config.active_agent)
+ assert.are.same({ "gemini" }, vim.fn.readfile(state_file()))
- assert.is_false(opts.keymaps)
- assert.are.same({}, notifications)
+ vim.fn.executable = original_executable
end)
- it(
- "When only part of a nested table is overridden Then defaults remain for unspecified keys",
- function()
- local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- context = {
- include_file_path = false,
- },
- panel = {
- width = 80,
- },
- })
-
- assert.is_false(opts.context.include_file_path)
- assert.is_true(opts.context.include_filetype)
- assert.is_true(opts.context.include_cursor)
- assert.are.equal(80, opts.panel.width)
- assert.are.equal(0.35, opts.panel.height)
- assert.are.equal("rounded", opts.float.border)
- end
- )
-
- it("When invalid config values are provided Then safe defaults are used with warnings", function()
+ it("When target executable is missing Then switch fails and is not persisted", function()
local config = require("agent_term.setup.runtime_config")
- local opts = config.setup({
- agent = {
- cmd = "codex",
- auto_resume = "yes",
- context = {
- mode = "silent",
- },
- },
- float = {
- width = "80",
- },
- panel = {
- position = "middle",
- height = "large",
- },
- context = {
- file_path = {},
- target_view = "side",
- include_cursor = "yes",
+ config.setup({
+ agents = {
+ codex = { cmd = { "codex" } },
+ gemini = { cmd = { "gemini" } },
},
})
+ local original_executable = vim.fn.executable
+ vim.fn.executable = function(_)
+ return 0
+ end
- assert.are.same({ "codex" }, opts.agent.cmd)
- assert.is_nil(opts.agent.auto_resume)
- assert.are.equal(".agent-term/context.json", opts.context.file_path)
- assert.are.equal(0.85, opts.float.width)
- assert.are.equal("right", opts.panel.position)
- assert.are.equal(0.35, opts.panel.height)
- assert.are.equal("default", opts.context.target_view)
- assert.is_true(opts.context.include_cursor)
- assert.is_true(#notifications >= 8)
+ assert.is_false(config.set_active_agent("gemini"))
+ assert.are.equal("codex", config.active_agent)
+ assert.are.equal(0, vim.fn.filereadable(state_file()))
+ assert.match("Agent command not found: gemini", notifications[#notifications].msg)
+
+ vim.fn.executable = original_executable
end)
end)
diff --git a/tests/spec/context_spec.lua b/tests/spec/context_spec.lua
index bfea62a..79d43b8 100644
--- a/tests/spec/context_spec.lua
+++ b/tests/spec/context_spec.lua
@@ -1,6 +1,18 @@
---@diagnostic disable: need-check-nil
local reload = require("tests.helpers.reload")
+local function setup_with_context(context_opts)
+ local config = require("agent_term.setup.runtime_config")
+ config.setup({
+ agents = {
+ codex = {
+ cmd = { "codex" },
+ context = context_opts or {},
+ },
+ },
+ })
+end
+
describe("Given context message builders", function()
local bufnr
@@ -18,13 +30,10 @@ describe("Given context message builders", function()
end)
it("When building buffer context Then ambient metadata and visible range are included", function()
- local config = require("agent_term.setup.runtime_config")
- config.setup({
- context = {
- include_file_path = true,
- include_filetype = true,
- include_cursor = true,
- },
+ setup_with_context({
+ include_file_path = true,
+ include_filetype = true,
+ include_cursor = true,
})
vim.api.nvim_buf_set_name(bufnr, "/tmp/context-buffer.lua")
@@ -46,13 +55,10 @@ describe("Given context message builders", function()
it(
"When selection context is invoked with a command range Then the explicit range wins",
function()
- local config = require("agent_term.setup.runtime_config")
- config.setup({
- context = {
- include_file_path = false,
- include_filetype = false,
- include_selection_range = true,
- },
+ setup_with_context({
+ include_file_path = false,
+ include_filetype = false,
+ include_selection_range = true,
})
local context = require("agent_term.context.builder")
@@ -69,8 +75,7 @@ describe("Given context message builders", function()
it(
"When selection context has no command range Then visual marks are used, else a clear error is returned",
function()
- local config = require("agent_term.setup.runtime_config")
- config.setup()
+ setup_with_context()
local context = require("agent_term.context.builder")
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
@@ -103,13 +108,10 @@ describe("Given context message builders", function()
it(
"When diagnostics exist Then diagnostics context includes formatted diagnostics from current buffer",
function()
- local config = require("agent_term.setup.runtime_config")
- config.setup({
- context = {
- include_file_path = false,
- include_filetype = false,
- include_diagnostics = true,
- },
+ setup_with_context({
+ include_file_path = false,
+ include_filetype = false,
+ include_diagnostics = true,
})
local ns = vim.api.nvim_create_namespace("agent_term-tests-context-diag")
@@ -143,8 +145,7 @@ describe("Given context message builders", function()
it(
"When no diagnostics are present Then diagnostics context returns a practical no-op error",
function()
- local config = require("agent_term.setup.runtime_config")
- config.setup()
+ setup_with_context()
local context = require("agent_term.context.builder")
local message, err = context.diagnostics_message()
diff --git a/tests/spec/hooks_spec.lua b/tests/spec/hooks_spec.lua
index 0990579..3a7fb6e 100644
--- a/tests/spec/hooks_spec.lua
+++ b/tests/spec/hooks_spec.lua
@@ -42,7 +42,7 @@ describe("Given native agent hook installation", function()
it("When installing Codex hooks Then global hook config and script are written", function()
local plugin = require("agent_term")
local hooks = require("agent_term.hooks")
- plugin.setup({ agent = "codex" })
+ plugin.setup({ agents = { codex = { preset = "codex" } } })
assert.is_true(hooks.install())
@@ -51,7 +51,7 @@ describe("Given native agent hook installation", function()
assert.are.equal("command", handler.type)
assert.is_not_nil(handler.command:match("%.codex/hooks/agent_term_context%.py"))
assert.is_not_nil(handler.command:match("%$PWD/.agent%-term/context%.json"))
- assert.is_true(require("agent_term.setup.runtime_config").options.context.hook.enabled)
+ assert.is_true(require("agent_term.setup.runtime_config").context().hook.enabled)
assert.are.equal(1, vim.fn.filereadable(path(".codex/hooks/agent_term_context.py")))
end)
@@ -60,7 +60,7 @@ describe("Given native agent hook installation", function()
function()
local plugin = require("agent_term")
local hooks = require("agent_term.hooks")
- plugin.setup({ agent = "codex" })
+ plugin.setup({ agents = { codex = { preset = "codex" } } })
hooks.install()
vim.fn.mkdir(path(".agent-term"), "p")
vim.fn.writefile({
@@ -88,7 +88,7 @@ describe("Given native agent hook installation", function()
"When AgentTermInstallHooks runs for Claude Then it dispatches to the Claude installer",
function()
local plugin = require("agent_term")
- plugin.setup({ agent = "claude" })
+ plugin.setup({ agents = { claude = { preset = "claude" } } })
vim.cmd.AgentTermInstallHooks()
@@ -105,7 +105,7 @@ describe("Given native agent hook installation", function()
it("When global hook entries already exist Then install leaves config unchanged", function()
local plugin = require("agent_term")
local hooks = require("agent_term.hooks")
- plugin.setup({ agent = "codex" })
+ plugin.setup({ agents = { codex = { preset = "codex" } } })
assert.is_true(hooks.install())
local hooks_path = path(".codex/hooks.json")
@@ -121,7 +121,7 @@ describe("Given native agent hook installation", function()
"When AgentTermInstallHooks runs for Gemini Then it dispatches to the Gemini installer",
function()
local plugin = require("agent_term")
- plugin.setup({ agent = "gemini" })
+ plugin.setup({ agents = { gemini = { preset = "gemini" } } })
vim.cmd.AgentTermInstallHooks()
@@ -138,7 +138,7 @@ describe("Given native agent hook installation", function()
function()
local plugin = require("agent_term")
local hooks = require("agent_term.hooks")
- plugin.setup({ agent = "aider" })
+ plugin.setup({ agents = { aider = { preset = "aider" } } })
assert.is_false(hooks.install())
diff --git a/tests/spec/init_context_injection_spec.lua b/tests/spec/init_context_injection_spec.lua
index dfd2862..6721006 100644
--- a/tests/spec/init_context_injection_spec.lua
+++ b/tests/spec/init_context_injection_spec.lua
@@ -1,7 +1,7 @@
---@diagnostic disable: undefined-field
local reload = require("tests.helpers.reload")
-describe("Given panel silent context injection", function()
+describe("Given panel context injection with active agents", function()
local context_commands
local enums
local state
@@ -10,10 +10,7 @@ describe("Given panel silent context injection", function()
local source_buf
local terminal_buf
local sent_messages
- local ensure_session_arg
local notifications
- local original_cwd
- local temp_dir
local function close_if_valid(win)
if win and vim.api.nvim_win_is_valid(win) then
@@ -21,11 +18,21 @@ describe("Given panel silent context injection", function()
end
end
+ local function setup_plugin(context_opts)
+ plugin.setup({
+ agents = {
+ codex = {
+ cmd = { "codex" },
+ context = context_opts or {},
+ },
+ },
+ })
+ end
+
before_each(function()
reload.clear_agent_term_modules()
notifications = {}
sent_messages = {}
- ensure_session_arg = "__unset__"
package.loaded["agent_term.notify"] = {
info = function(msg)
notifications[#notifications + 1] = { level = "info", msg = msg }
@@ -37,10 +44,6 @@ describe("Given panel silent context injection", function()
notifications[#notifications + 1] = { level = "error", msg = msg }
end,
}
- original_cwd = vim.fn.getcwd()
- temp_dir = vim.fn.tempname()
- vim.fn.mkdir(temp_dir, "p")
- vim.cmd.cd(vim.fn.fnameescape(temp_dir))
source_buf = vim.api.nvim_create_buf(true, false)
terminal_buf = vim.api.nvim_create_buf(false, true)
@@ -52,11 +55,11 @@ describe("Given panel silent context injection", function()
vim.api.nvim_buf_set_mark(source_buf, ">", 2, 0, {})
package.loaded["agent_term.runtime.session"] = {
- ensure_session = function(cmd)
- ensure_session_arg = cmd
+ ensure_session = function(_, agent_name)
local s = require("agent_term.runtime.state")
- s.buf = terminal_buf
- s.job_id = 111
+ local entry = s.session(agent_name)
+ entry.buf = terminal_buf
+ entry.job_id = 111
return terminal_buf
end,
send = function(text)
@@ -64,18 +67,10 @@ describe("Given panel silent context injection", function()
return true
end,
close_views = function() end,
+ close_all_views_except = function() end,
kill = function() end,
}
- package.loaded["agent_term.hooks.installers.codex"] = {
- is_installed = function()
- return true
- end,
- install = function()
- return true, { files = {}, changed = false }
- end,
- }
-
plugin = require("agent_term")
context_commands = require("agent_term.context.commands")
enums = require("agent_term.enums")
@@ -84,14 +79,10 @@ describe("Given panel silent context injection", function()
end)
after_each(function()
- close_if_valid(state and state.float_win)
- close_if_valid(state and state.panel_win)
- if original_cwd then
- vim.cmd.cd(vim.fn.fnameescape(original_cwd))
- end
- if temp_dir then
- vim.fn.delete(temp_dir, "rf")
- end
+ state.each_session(function(_, session)
+ close_if_valid(session.float_win)
+ close_if_valid(session.panel_win)
+ end)
if source_buf and vim.api.nvim_buf_is_valid(source_buf) then
pcall(vim.api.nvim_buf_delete, source_buf, { force = true })
end
@@ -101,72 +92,35 @@ describe("Given panel silent context injection", function()
reload.clear_agent_term_modules()
end)
- it("When panel opens Then it captures buffer context before current buffer changes", function()
- plugin.setup({ context = { target_view = "panel" } })
+ it("When panel opens Then it captures source context before focus changes", function()
+ setup_plugin({ target_view = "panel" })
view_controller.open(enums.view.PANEL)
- assert.is_nil(ensure_session_arg)
assert.are.equal(terminal_buf, vim.api.nvim_get_current_buf())
assert.is_not_nil(state.last_captured_context)
assert.is_not_nil(state.last_captured_context.buffer:match("file: /tmp/panel%-source%.lua"))
- end)
-
- it("When panel opens Then it captures selection context before focus changes", function()
- plugin.setup({ context = { target_view = "panel" } })
- view_controller.open(enums.view.PANEL)
-
- assert.is_not_nil(state.last_captured_context)
assert.is_not_nil(state.last_captured_context.selection:match("selection: lines 2%-4"))
end)
- it("When sending context from panel Then submission uses last captured context", function()
- plugin.setup({
- context = { target_view = "panel" },
- })
- view_controller.open(enums.view.PANEL)
- vim.api.nvim_buf_set_name(terminal_buf, "/tmp/panel-terminal.txt")
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
- assert.is_nil(sent_messages[1]:match("panel%-terminal"))
- end)
-
- it("When buffer context starts a default float Then source buffer context is sent", function()
- plugin.setup({})
- state.buf = nil
- state.job_id = nil
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
- assert.is_nil(sent_messages[1]:match("type: diagnostics"))
- end)
-
it(
- "When selection context starts a default float Then source selection context is sent",
+ "When sending context from a focused panel Then the last captured source context is sent",
function()
- plugin.setup({})
- state.buf = nil
- state.job_id = nil
+ setup_plugin({ target_view = "panel" })
+ view_controller.open(enums.view.PANEL)
+ vim.api.nvim_buf_set_name(terminal_buf, "/tmp/panel-terminal.txt")
- context_commands.send_selection_context({})
+ context_commands.send_buffer_context()
assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("type: selection"))
- assert.is_not_nil(sent_messages[1]:match("selection: lines 2%-4"))
assert.is_not_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
+ assert.is_nil(sent_messages[1]:match("panel%-terminal"))
end
)
it(
- "When a panel is open but source window is focused Then current buffer context is sent",
+ "When source window is focused while panel exists Then current source context is sent",
function()
- plugin.setup({
- context = { target_view = "panel" },
- })
+ setup_plugin({ target_view = "panel" })
local source_win = vim.api.nvim_get_current_win()
view_controller.open(enums.view.PANEL)
@@ -186,187 +140,10 @@ describe("Given panel silent context injection", function()
end
)
- it("When normal navigation returns to panel Then the latest source buffer is captured", function()
- plugin.setup({
- context = { target_view = "panel" },
- })
- local source_win = vim.api.nvim_get_current_win()
- view_controller.open(enums.view.PANEL)
-
- local latest_buf = vim.api.nvim_create_buf(true, false)
- vim.api.nvim_buf_set_name(latest_buf, "/tmp/latest-source.lua")
- vim.api.nvim_buf_set_lines(latest_buf, 0, -1, false, { "latest" })
- vim.api.nvim_set_current_win(source_win)
- vim.api.nvim_set_current_buf(latest_buf)
- vim.api.nvim_set_current_win(state.panel_win)
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/latest%-source%.lua"))
- assert.is_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
-
- pcall(vim.api.nvim_buf_delete, latest_buf, { force = true })
- end)
-
- it("When no context has been captured Then panel submission is handled cleanly", function()
- plugin.setup({ context = { target_view = "panel" } })
- view_controller.open(enums.view.PANEL)
- state.last_captured_context = nil
-
- context_commands.send_buffer_context()
-
- assert.are.equal(0, #sent_messages)
- assert.are.equal("info", notifications[#notifications].level)
- assert.is_not_nil(
- notifications[#notifications].msg:match("No captured editor context available")
- )
- end)
-
- it(
- "When diagnostics are sent from a focused panel Then captured source diagnostics are sent",
- function()
- local ns = vim.api.nvim_create_namespace("agent_term-panel-diag")
- vim.diagnostic.set(ns, source_buf, {
- {
- lnum = 1,
- col = 2,
- severity = vim.diagnostic.severity.ERROR,
- source = "lua_ls",
- message = "bad call",
- },
- })
- plugin.setup({
- context = { target_view = "panel" },
- })
- view_controller.open(enums.view.PANEL)
-
- context_commands.send_diagnostics_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("type: diagnostics"))
- assert.is_not_nil(sent_messages[1]:match("lua_ls: bad call"))
- end
- )
-
- it("When diagnostics context starts a default float Then source diagnostics are sent", function()
- local ns = vim.api.nvim_create_namespace("agent_term-float-diag")
- vim.diagnostic.set(ns, source_buf, {
- {
- lnum = 2,
- col = 0,
- severity = vim.diagnostic.severity.WARN,
- source = "stylua",
- message = "format",
- },
- })
- plugin.setup({})
- state.buf = nil
- state.job_id = nil
-
- context_commands.send_diagnostics_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("type: diagnostics"))
- assert.is_not_nil(sent_messages[1]:match("stylua: format"))
- end)
-
- it("When diagnostics context opens a target panel Then source diagnostics are sent", function()
- local ns = vim.api.nvim_create_namespace("agent_term-target-panel-diag")
- vim.diagnostic.set(ns, source_buf, {
- {
- lnum = 3,
- col = 1,
- severity = vim.diagnostic.severity.INFO,
- source = "test",
- message = "note",
- },
- })
- plugin.setup({
- context = { target_view = "panel" },
- })
- state.buf = nil
- state.job_id = nil
-
- context_commands.send_diagnostics_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("type: diagnostics"))
- assert.is_not_nil(sent_messages[1]:match("test: note"))
- end)
-
- it(
- "When automatic hook updates are enabled Then manual buffer context is still pasted",
- function()
- plugin.setup({
- context = {
- target_view = "panel",
- hook = {
- enabled = true,
- },
- },
- })
- view_controller.open(enums.view.PANEL)
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
- end
- )
-
- it("When automatic hook updates start a default float Then source context is pasted", function()
- plugin.setup({
- context = {
- hook = {
- enabled = true,
- },
- },
- })
- state.buf = nil
- state.job_id = nil
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
- end)
-
- it("When automatic hook updates with panel open still paste current source context", function()
- plugin.setup({
- context = {
- target_view = "panel",
- hook = {
- enabled = true,
- },
- },
- })
- local source_win = vim.api.nvim_get_current_win()
- view_controller.open(enums.view.PANEL)
-
- local other_buf = vim.api.nvim_create_buf(true, false)
- vim.api.nvim_buf_set_name(other_buf, "/tmp/hook-source.lua")
- vim.api.nvim_buf_set_lines(other_buf, 0, -1, false, { "hooked" })
- vim.api.nvim_set_current_win(source_win)
- vim.api.nvim_set_current_buf(other_buf)
-
- context_commands.send_buffer_context()
-
- assert.are.equal(1, #sent_messages)
- assert.is_not_nil(sent_messages[1]:match("file: /tmp/hook%-source%.lua"))
- assert.is_nil(sent_messages[1]:match("file: /tmp/panel%-source%.lua"))
-
- pcall(vim.api.nvim_buf_delete, other_buf, { force = true })
- end)
-
- it("When automatic hook updates are enabled Then panel selection context is pasted", function()
- plugin.setup({
- context = {
- target_view = "panel",
- hook = {
- enabled = true,
- },
- },
+ it("When per-agent hook updates are enabled Then manual commands still paste context", function()
+ setup_plugin({
+ target_view = "panel",
+ hook = { enabled = true },
})
view_controller.open(enums.view.PANEL)
diff --git a/tests/spec/runtime_session_spec.lua b/tests/spec/runtime_session_spec.lua
index 32bf5f4..de18c78 100644
--- a/tests/spec/runtime_session_spec.lua
+++ b/tests/spec/runtime_session_spec.lua
@@ -1,6 +1,6 @@
local reload = require("tests.helpers.reload")
-describe("Given Agent Term runtime session management", function()
+describe("Given Agent Term per-agent runtime sessions", function()
local config
local state
local session
@@ -10,24 +10,26 @@ describe("Given Agent Term runtime session management", function()
local original_jobwait = vim.fn.jobwait
local original_jobstop = vim.fn.jobstop
local original_chan_send = vim.api.nvim_chan_send
-
local notifications
- local function reset_runtime_state()
- if state then
- if state.float_win and vim.api.nvim_win_is_valid(state.float_win) then
- pcall(vim.api.nvim_win_close, state.float_win, true)
+ local function clear_state()
+ if not state then
+ return
+ end
+ state.each_session(function(name, entry)
+ if entry.float_win and vim.api.nvim_win_is_valid(entry.float_win) then
+ pcall(vim.api.nvim_win_close, entry.float_win, true)
end
- if state.panel_win and vim.api.nvim_win_is_valid(state.panel_win) then
- pcall(vim.api.nvim_win_close, state.panel_win, true)
+ if entry.panel_win and vim.api.nvim_win_is_valid(entry.panel_win) then
+ pcall(vim.api.nvim_win_close, entry.panel_win, true)
end
- if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
- pcall(vim.api.nvim_buf_delete, state.buf, { force = true })
+ if entry.buf and vim.api.nvim_buf_is_valid(entry.buf) then
+ pcall(vim.api.nvim_buf_delete, entry.buf, { force = true })
end
- state.reset_float_win()
- state.reset_panel_win()
- state.reset_terminal()
- end
+ state.reset_float_win(name)
+ state.reset_panel_win(name)
+ state.reset_terminal(name)
+ end)
end
before_each(function()
@@ -44,10 +46,12 @@ describe("Given Agent Term runtime session management", function()
notifications[#notifications + 1] = { level = "error", msg = msg }
end,
}
-
config = require("agent_term.setup.runtime_config")
config.setup({
- agent = "codex",
+ agents = {
+ codex = { preset = "codex" },
+ gemini = { preset = "gemini" },
+ },
})
state = require("agent_term.runtime.state")
session = require("agent_term.runtime.session")
@@ -59,30 +63,25 @@ describe("Given Agent Term runtime session management", function()
vim.fn.jobwait = original_jobwait
vim.fn.jobstop = original_jobstop
vim.api.nvim_chan_send = original_chan_send
-
- reset_runtime_state()
+ clear_state()
reload.clear_agent_term_modules()
end)
- it(
- "When the configured Agent Term executable is missing Then session start fails with a user-visible error",
- function()
- vim.fn.executable = function(_)
- return 0
- end
+ it("When an executable is missing Then session start fails with a user-visible error", function()
+ vim.fn.executable = function(_)
+ return 0
+ end
- local buf = session.ensure_session({ "missing-agent" })
+ local buf = session.ensure_session({ "missing-agent" }, "codex")
- assert.is_nil(buf)
- assert.is_nil(state.buf)
- assert.is_nil(state.job_id)
- assert.are.equal(1, #notifications)
- assert.are.equal("error", notifications[1].level)
- assert.match("Agent command not found: missing%-agent", notifications[1].msg)
- end
- )
+ assert.is_nil(buf)
+ assert.is_nil(state.session("codex").buf)
+ assert.is_nil(state.session("codex").job_id)
+ assert.are.equal("error", notifications[#notifications].level)
+ assert.match("Agent command not found: missing%-agent", notifications[#notifications].msg)
+ end)
- it("When jobstart fails Then terminal state is cleaned up and the failure is reported", function()
+ it("When jobstart fails Then only the target agent state is cleaned up", function()
vim.fn.executable = function(_)
return 1
end
@@ -90,174 +89,103 @@ describe("Given Agent Term runtime session management", function()
return 0
end
- local buf = session.ensure_session({ "codex" })
+ local buf = session.ensure_session({ "codex" }, "codex")
assert.is_nil(buf)
- assert.is_nil(state.buf)
- assert.is_nil(state.job_id)
- assert.are.equal("error", notifications[#notifications].level)
+ assert.is_nil(state.session("codex").buf)
+ assert.is_nil(state.session("codex").job_id)
assert.match("Failed to start agent command: codex", notifications[#notifications].msg)
end)
- it(
- "When a session starts successfully Then send works for running jobs and kill clears process and buffer state",
- function()
- local sent
- local stopped_job
- local started_cmd
-
- vim.fn.executable = function(_)
- return 1
- end
- vim.fn.jobstart = function(cmd, opts)
- started_cmd = cmd
- assert.is_true(opts.term)
- return 77
- end
- vim.fn.jobwait = function(ids, _)
- if ids[1] == 77 then
- return { -1 }
- end
- return { 0 }
- end
- vim.api.nvim_chan_send = function(job_id, text)
- sent = { job_id = job_id, text = text }
- end
- vim.fn.jobstop = function(job_id)
- stopped_job = job_id
- return 1
- end
-
- local buf = session.ensure_session({ "codex", "--version" })
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "codex", "--version" }, started_cmd)
- assert.are.equal(buf, state.buf)
- assert.are.equal(77, state.job_id)
-
- assert.is_true(session.send("hello\n"))
- assert.are.same({ job_id = 77, text = "hello\n" }, sent)
-
- session.kill()
- assert.are.equal(77, stopped_job)
- assert.is_nil(state.buf)
- assert.is_nil(state.job_id)
- end
- )
-
- it("When auto resume is false Then the configured interactive command starts", function()
- local started_cmd
- config.setup({
- agent = {
- preset = "gemini",
- auto_resume = false,
- },
- })
+ it("When different agents start Then each keeps an independent session", function()
+ local starts = {}
vim.fn.executable = function(_)
return 1
end
vim.fn.jobstart = function(cmd, _)
- started_cmd = cmd
- return 88
+ starts[#starts + 1] = cmd
+ return 70 + #starts
+ end
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] >= 71 and ids[1] <= 72 and { -1 } or { 0 }
end
- local buf = session.ensure_session()
+ local codex_buf = session.ensure_session(nil, "codex")
+ local gemini_buf = session.ensure_session(nil, "gemini")
+ local codex_again = session.ensure_session(nil, "codex")
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "gemini" }, started_cmd)
+ assert.are.equal(codex_buf, codex_again)
+ assert.are_not.equal(codex_buf, gemini_buf)
+ assert.are.equal(71, state.session("codex").job_id)
+ assert.are.equal(72, state.session("gemini").job_id)
+ assert.are.same({ "codex" }, starts[1])
+ assert.are.same({ "gemini" }, starts[2])
end)
- it("When auto resume uses picker mode Then startup uses the preset picker command", function()
- local started_cmd
- config.setup({
- agent = {
- preset = "codex",
- auto_resume = "picker",
- },
- })
- vim.fn.executable = function(_)
- return 1
+ it("When sending text Then it targets the active agent session", function()
+ local sent
+ config.active_agent = "gemini"
+ state.session("gemini").job_id = 99
+ vim.fn.jobwait = function(ids, _)
+ return ids[1] == 99 and { -1 } or { 0 }
end
- vim.fn.jobstart = function(cmd, _)
- started_cmd = cmd
- return 89
+ vim.api.nvim_chan_send = function(job_id, text)
+ sent = { job_id = job_id, text = text }
end
- local buf = session.ensure_session()
-
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "codex", "resume" }, started_cmd)
+ assert.is_true(session.send("hello\n"))
+ assert.are.same({ job_id = 99, text = "hello\n" }, sent)
end)
- it("When auto resume uses last mode Then startup appends preset args to the command", function()
- local started_cmd
- config.setup({
- agent = {
- preset = "codex",
- cmd = { "codex", "--model", "gpt-5.4-mini" },
- auto_resume = "last",
- },
- })
- vim.fn.executable = function(_)
+ it("When killing an agent Then unrelated agent sessions survive", function()
+ local stopped
+ state.session("codex").job_id = 101
+ state.session("gemini").job_id = 202
+ vim.fn.jobstop = function(job_id)
+ stopped = job_id
return 1
end
- vim.fn.jobstart = function(cmd, _)
- started_cmd = cmd
- return 90
- end
- local buf = session.ensure_session()
+ session.kill("gemini")
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "codex", "--model", "gpt-5.4-mini", "resume", "--last" }, started_cmd)
+ assert.are.equal(202, stopped)
+ assert.are.equal(101, state.session("codex").job_id)
+ assert.is_nil(state.session("gemini").job_id)
end)
- it(
- "When auto resume is configured for a custom command Then startup uses the custom command",
- function()
- local started_cmd
- config.setup({
- agent = {
- cmd = { "my-agent" },
+ it("When auto-resume is configured per agent Then startup uses that agent's mode", function()
+ local starts = {}
+ config.setup({
+ agents = {
+ codex = {
+ preset = "codex",
+ cmd = { "codex", "--model", "gpt-5.4-mini" },
auto_resume = "last",
},
- })
- vim.fn.executable = function(_)
- return 1
- end
- vim.fn.jobstart = function(cmd, _)
- started_cmd = cmd
- return 91
- end
-
- local buf = session.ensure_session()
-
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "my-agent" }, started_cmd)
- end
- )
-
- it(
- "When auto resume mode is unavailable for a preset Then startup uses the normal command",
- function()
- local started_cmd
- config.setup({
- agent = {
- preset = "opencode",
+ gemini = {
+ preset = "gemini",
auto_resume = "picker",
},
- })
- vim.fn.executable = function(_)
- return 1
- end
- vim.fn.jobstart = function(cmd, _)
- started_cmd = cmd
- return 92
- end
+ custom = {
+ cmd = { "my-agent" },
+ auto_resume = "last",
+ },
+ },
+ })
+ vim.fn.executable = function(_)
+ return 1
+ end
+ vim.fn.jobstart = function(cmd, _)
+ starts[#starts + 1] = cmd
+ return 80 + #starts
+ end
- local buf = session.ensure_session()
+ session.ensure_session(nil, "codex")
+ session.ensure_session(nil, "gemini")
+ session.ensure_session(nil, "custom")
- assert.is_true(vim.api.nvim_buf_is_valid(buf))
- assert.are.same({ "opencode" }, started_cmd)
- end
- )
+ assert.are.same({ "codex", "--model", "gpt-5.4-mini", "resume", "--last" }, starts[1])
+ assert.are.same({ "gemini", "-r" }, starts[2])
+ assert.are.same({ "my-agent" }, starts[3])
+ end)
end)
diff --git a/tests/spec/ui_spec.lua b/tests/spec/ui_spec.lua
index 27101d6..6e6cf18 100644
--- a/tests/spec/ui_spec.lua
+++ b/tests/spec/ui_spec.lua
@@ -21,17 +21,24 @@ describe("Given Agent Term UI views", function()
before_each(function()
reload.clear_agent_term_modules()
config = require("agent_term.setup.runtime_config")
- config.setup()
+ config.setup({
+ agents = {
+ codex = { preset = "codex" },
+ gemini = { preset = "gemini" },
+ },
+ })
state = require("agent_term.runtime.state")
float = require("agent_term.ui.float")
panel = require("agent_term.ui.panel")
end)
after_each(function()
- close_if_valid(state.float_win)
- close_if_valid(state.panel_win)
- state.reset_float_win()
- state.reset_panel_win()
+ state.each_session(function(name, session)
+ close_if_valid(session.float_win)
+ close_if_valid(session.panel_win)
+ state.reset_float_win(name)
+ state.reset_panel_win(name)
+ end)
for _, bufnr in ipairs(bufs_to_delete) do
if vim.api.nvim_buf_is_valid(bufnr) then
@@ -42,32 +49,31 @@ describe("Given Agent Term UI views", function()
reload.clear_agent_term_modules()
end)
- it(
- "When opening and closing the float Then it reuses valid windows and recovers from stale ones",
- function()
- local bufnr = track_buf(vim.api.nvim_create_buf(false, true))
+ it("When opening and closing the float Then state is tracked per agent", function()
+ local codex_buf = track_buf(vim.api.nvim_create_buf(false, true))
+ local gemini_buf = track_buf(vim.api.nvim_create_buf(false, true))
- local first = float.open(bufnr)
- assert.is_true(vim.api.nvim_win_is_valid(first))
- assert.are.equal(first, state.float_win)
+ local first = float.open(codex_buf, "codex")
+ assert.is_true(vim.api.nvim_win_is_valid(first))
+ assert.are.equal(first, state.session("codex").float_win)
- local second = float.open(bufnr)
- assert.are.equal(first, second)
- assert.are.equal(second, vim.api.nvim_get_current_win())
+ local second = float.open(codex_buf, "codex")
+ assert.are.equal(first, second)
- vim.api.nvim_win_close(first, true)
- local recovered = float.open(bufnr)
- assert.is_true(vim.api.nvim_win_is_valid(recovered))
- assert.are_not.equal(first, recovered)
- assert.are.equal(recovered, state.float_win)
+ local gemini = float.open(gemini_buf, "gemini")
+ assert.are.equal(gemini, state.session("gemini").float_win)
+ assert.are_not.equal(first, gemini)
- float.close()
- assert.is_nil(state.float_win)
- end
- )
+ float.close("codex")
+ assert.is_nil(state.session("codex").float_win)
+ assert.are.equal(gemini, state.session("gemini").float_win)
+ end)
- it("When opening and closing the panel Then focus and lifecycle state stay consistent", function()
+ it("When opening and closing the panel Then focus and lifecycle state stay per agent", function()
config.setup({
+ agents = {
+ codex = { preset = "codex" },
+ },
panel = {
position = "bottom",
height = 0.3,
@@ -75,16 +81,16 @@ describe("Given Agent Term UI views", function()
})
local bufnr = track_buf(vim.api.nvim_create_buf(false, true))
- local win = panel.open(bufnr)
+ local win = panel.open(bufnr, "codex")
assert.is_true(vim.api.nvim_win_is_valid(win))
- assert.are.equal(win, state.panel_win)
+ assert.are.equal(win, state.session("codex").panel_win)
assert.are.equal(bufnr, vim.api.nvim_win_get_buf(win))
- assert.is_true(panel.focus())
+ assert.is_true(panel.focus("codex"))
assert.are.equal(win, vim.api.nvim_get_current_win())
- panel.close()
- assert.is_nil(state.panel_win)
- assert.is_false(panel.focus())
+ panel.close("codex")
+ assert.is_nil(state.session("codex").panel_win)
+ assert.is_false(panel.focus("codex"))
end)
end)