Skip to content

Repository files navigation

agents.nvim

Launch claude, codex, or pi in new terminal tabs, with a live tab indicator for whether the agent is working or waiting for input.

Installation

LazyVim

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.

Other plugin managers

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.

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.

Status indicator

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 to agent_start/turn_start/agent_end/turn_end.
  • codex — has no launch-time hook flag, so this is opt-in (below).

Configuration

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 }.

Requirements

  • neovim 0.9+
  • the agent CLIs themselves (claude, codex, pi) on PATH
  • 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

Layout

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

Commands

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.

Verify the plugin loads

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!"

Notes

  • Specs run inside a real nvim, so the full vim API 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 touches vim at load time fails to require while one that touches it only inside functions passes without testing anything.
  • tests/launch_spec.lua uses a stub claude on a temp PATH entry — no real agent is ever launched by the suite.
  • doc/tags is generated and gitignored.

About

Agent tabs for Neovim

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages