Launch claude, codex, or pi in new terminal tabs, with a live tab indicator for whether the agent is working or waiting for input.
Save this as ~/.config/nvim/lua/plugins/agents.lua:
return {
{
"MattFlower/agents.nvim",
cmd = { "AgentClaude", "AgentCodex", "AgentPi", "Agents" },
opts = {
-- icons = { waiting = "○", exited = "✕", unknown = "?" },
-- poll_ms = 300,
-- codex has no launch-time hook flag, so tab status stays "unknown" for
-- it unless this is on. Enabling it merges the status hook into
-- $CODEX_HOME/hooks.json (append-only; existing entries preserved).
-- codex_hooks = true,
},
keys = {
{ "<leader>Ac", "<cmd>AgentClaude<cr>", desc = "Claude in a new tab" },
{ "<leader>Ax", "<cmd>AgentCodex<cr>", desc = "Codex in a new tab" },
{ "<leader>Ap", "<cmd>AgentPi<cr>", desc = "Pi in a new tab" },
{ "<leader>Al", "<cmd>Agents<cr>", desc = "List live agent sessions" },
-- Continue the most recent session. Each CLI spells this differently —
-- claude and pi take --continue natively, codex has no top-level flag
-- and is rewritten to `resume --last` — so the plugin normalizes it.
{ "<leader>AC", "<cmd>AgentClaude --continue<cr>", desc = "Claude, continue last session" },
{ "<leader>AX", "<cmd>AgentCodex --continue<cr>", desc = "Codex, continue last session" },
{ "<leader>AP", "<cmd>AgentPi --continue<cr>", desc = "Pi, continue last session" },
-- Resume a specific session: opens the CLI's own picker. Pass an id
-- instead (:AgentPi --resume <id>) to jump straight to one.
{ "<leader>Ar", "<cmd>AgentClaude --resume<cr>", desc = "Claude, resume (picker)" },
{ "<leader>AR", "<cmd>AgentCodex --resume<cr>", desc = "Codex, resume (picker)" },
},
},
{
"folke/which-key.nvim",
opts = {
spec = {
{ "<leader>A", group = "Agents", icon = { icon = "", color = "green" } },
},
},
},
}cmd and keys keep it lazy — nothing loads until you first run a command or
press a mapping. opts is what makes lazy call require("agents").setup(); the
Lua module is named agents like the repo, so no main override is needed.
The mappings put everything under <leader>A: lowercase starts a fresh session,
uppercase continues the last one, and <leader>Al lists what is live. Codex is
Ax rather than Ac because Claude has the c.
The second spec is optional and only names the <leader>A group in which-key,
which LazyVim already ships. Drop it if you use a different prompt.
There is nothing LazyVim-specific in the plugin itself. Any manager works as
long as it puts the repo on runtimepath; call require("agents").setup()
yourself if your manager does not do it for you, and define your own mappings
against the commands in Usage.
| Command | Effect |
|---|---|
:AgentClaude [args] |
open claude in a new tab |
:AgentCodex [args] |
open codex in a new tab |
:AgentPi [args] |
open pi in a new tab |
:Agents |
list live sessions (bufnr, agent, status, started_at) |
The CLIs are assumed to be on PATH; a missing executable produces an error
notification and no tab. Args pass through to the CLI, with per-CLI
normalization for continue/resume:
| You type | claude runs | codex runs | pi runs |
|---|---|---|---|
--continue |
--continue |
resume --last |
--continue |
--resume ID |
--resume ID |
resume ID |
--session ID |
--resume |
picker | picker | picker |
-r ID |
-r ID |
resume ID |
--session ID |
-r |
picker | picker | picker |
-c KEY=VALUE |
continue (native -c) |
-c KEY=VALUE |
unchanged |
<Tab> completion on the commands suggests --continue, --resume, -c,
-r. Codex's top-level -c is --config <key=value>, so it is passed
through verbatim rather than treated as a continue token. Example:
:AgentCodex --resume 019... resumes a specific session.
Each agent tab's buffer is renamed <icon> <agent> as its state changes:
- braille spinner (
⠋ ⠙ ⠹ …) — agent is working ○— agent finished a turn, waiting for input✕— agent exited?— unknown (before the first hook report; always, for codex without hooks enabled)
How the plugin knows: each harness gets a session hook that writes one word to a per-session status file, which a single ~300 ms timer polls.
- claude — a generated settings file with SessionStart/UserPromptSubmit/
PreToolUse/PostToolUse/Stop/SessionEnd hooks, passed as
--settings. - pi — a bundled extension passed as
--extension, reacting toagent_start/turn_start/agent_end/turn_end. - codex — has no launch-time hook flag, so this is opt-in (below).
require("agents").setup({
codex_hooks = true, -- merge agents.nvim hooks into codex's
-- $CODEX_HOME/hooks.json (~/.codex/hooks.json)
poll_ms = 300, -- status file poll interval
claude_vim_mode = true, -- after Escape in Claude Vim mode, send the next
-- leader key to LazyVim instead of Claude
start_insert = true, -- enter terminal-insert on open and on re-entry,
-- so typing goes straight to the agent
icons = { -- optional, deep-merged over the defaults
working_frames = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" },
waiting = "○",
exited = "✕",
unknown = "?",
},
})With claude_vim_mode = true, pressing Escape in a Claude terminal arms a
buffer-local leader mapping. The next leader leaves terminal-input mode and is
replayed to Neovim, so LazyVim's normal leader mappings work. Claude insert
commands (i, a, o, c, and their common variants) disarm interception,
so spaces in prompts are still sent to Claude. If Claude changes modes through
another route, press Escape once to resynchronize the mapping.
With codex_hooks = true the merge runs at setup time (and is re-applied
idempotently at each codex launch): entries with the same command are
skipped, your existing entries are preserved, and an invalid/unwritable
hooks.json only produces a warning — codex still launches, with an
unknown status. Expect codex's one-time trust prompt for the hook command
the first time it runs.
For statusline/tabline integrations, require("agents").open(agent, fargs)
launches an agent programmatically and require("agents").sessions() returns
every live session as { bufnr, agent, status, started_at }.
- neovim 0.9+
- the agent CLIs themselves (
claude,codex,pi) onPATH - for development: plenary.nvim (spec harness, found in the usual
plugin-manager locations or clone repo-locally:
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim .tests/plenary.nvim), stylua, luacheck
lua/agents/init.lua -- module root: setup(), open(), sessions()
lua/agents/cli.lua -- per-CLI definitions + --continue/--resume normalization
lua/agents/hooks.lua -- claude --settings / pi --extension / codex hooks.json merge
lua/agents/keymaps.lua -- Claude Vim-mode leader handoff to Neovim
lua/agents/launch.lua -- open(): tabnew + termopen + session tracking
lua/agents/registry.lua -- live sessions, keyed by terminal buffer
lua/agents/status.lua -- poll timer + buffer-name icon updates
plugin/agents.lua -- plugin entry point, load guard
bin/status-hook.sh -- hook script claude and codex run (writes working/waiting)
extensions/pi_status.ts -- pi extension (--extension), writes working/waiting
doc/agents.txt -- vim help file (run just helptags to tag it)
tests/agents_spec.lua -- module API + harness specs
tests/cli_spec.lua -- arg parsing + normalization table
tests/launch_spec.lua -- open() integration tests against a stub CLI
tests/status_hook_spec.lua -- hook argv, codex merge, status-hook.sh behavior
tests/minimal_init.lua -- nvim bootstrap for the specs
stylua.toml -- formatter config
.luacheckrc -- linter config
| Purpose | just | raw |
|---|---|---|
| run tests | just test |
see justfile — headless nvim + PlenaryBustedDirectory |
| format | just fmt |
stylua lua plugin tests |
| check formatting | just fmt-check |
stylua --check lua plugin tests |
| lint | just lint |
luacheck lua plugin tests |
| all checks | just check |
— |
| regen help tags | just helptags |
nvim --headless -c "helptags doc" -c "qa!" |
just check runs fmt-check + lint + test in sequence.
From the repo root. This exits nonzero on failure:
nvim --headless -u NONE --cmd "set rtp+=$PWD" \
-c "runtime plugin/agents.lua" \
-c "lua if vim.g.loaded_agents_nvim ~= 1 then vim.cmd.cquit(1) end" \
-c "qa!"- Specs run inside a real nvim, so the full
vimAPI is available to both the plugin and the tests. This is deliberate: the plugin's job is tab and terminal management, and under plain system Lua a module that touchesvimat load time fails to require while one that touches it only inside functions passes without testing anything. tests/launch_spec.luauses a stubclaudeon a temp PATH entry — no real agent is ever launched by the suite.doc/tagsis generated and gitignored.