No description
Find a file
Lennart Steffen 4e8cec8372 fix(job): fix deepcopy crash on running job entries and timer edge case
- get_running_jobs now builds new tables with explicit fields instead of
  calling vim.deepcopy on entries with uv handle userdata, preventing crash.
- schedule_status_clear no longer returns early for interval <= 0: a
  zero auto_clear_status, which stands for 'clear immediately', also
  results in a timer that fires right away to clear finished entries.
2026-07-16 10:11:10 +02:00
config Use Neovim startup directory instead of project root detection 2026-07-16 00:43:11 +02:00
doc Use Neovim startup directory instead of project root detection 2026-07-16 00:43:11 +02:00
lua fix(job): fix deepcopy crash on running job entries and timer edge case 2026-07-16 10:11:10 +02:00
plugin fix: register stored keymaps and autocmds when plugin loads 2026-07-11 19:19:42 +02:00
tests Use Neovim startup directory instead of project root detection 2026-07-16 00:43:11 +02:00
.luacheckrc Initial Commit 2026-07-06 20:21:57 +02:00
.stylua.toml Initial Commit 2026-07-06 20:21:57 +02:00
README.md Use Neovim startup directory instead of project root detection 2026-07-16 00:43:11 +02:00

nvim-runner

Run shell commands in the background from Neovim. Save commands per session launch directory, run them without blocking, and check output later.

Warning

This project was written entirely by an AI agent and has not been thoroughly reviewed for correctness, security, or edge cases. Use at your own risk. Bug reports and pull requests are welcome.

Installation

Requirements

lazy.nvim

{
  'https://git.ultrakalteseis.de/lsteffen/nvim-runner',
  opts = {},
}

opts is automatically passed to require('nvim-runner').setup(). If you define a custom config function, call setup(opts) manually:

{
  'https://git.ultrakalteseis.de/lsteffen/nvim-runner',
  opts = { auto_clear_status = 15 },
  config = function(_, opts)
    require('nvim-runner').setup(opts)
  end,
}

packer.nvim

use {
  'https://git.ultrakalteseis.de/lsteffen/nvim-runner',
  config = function()
    require('nvim-runner').setup({})
  end,
}

Manual (vim-plug)

Plug 'https://git.ultrakalteseis.de/lsteffen/nvim-runner'

A setup() call is optional — defaults are used when not called. To customize:

require('nvim-runner').setup({
  auto_clear_status = 10,
  output_line_limit = 5000,
  keymaps = {},  -- pass an empty table to disable all keymaps
})

Usage

Add a command

:RunnerAdd

Prompts for a name and a shell command string. The command is saved per session launch directory.

List and run

:RunnerList

Opens a picker of saved commands. Select one to run it in the background. Uses Telescope if installed, otherwise falls back to vim.ui.select.

In the Telescope picker, <C-d> deletes the selected command.

View output

:RunnerOutput [name]

Opens a floating buffer with the captured output from the last run of a command. Without a name, prompts to select from commands that have output.

Stop a running job

:RunnerStop [name]

Stops a running job. Without a name, prompts to select from running jobs.

Remove a command

:RunnerRemove

Select a saved command to permanently delete it.

Hook a command to an event

:RunnerHook <name> <event> [pattern]

Hook a command to run automatically when a Neovim event fires. For example, to run a build command when saving C files:

:RunnerHook build BufWritePost *.c

You can hook the same command to multiple events:

:RunnerHook build BufWritePost *.c
:RunnerHook build BufWritePost *.h

Without arguments, prompts to select a command and enter event details.

Singleflight semantics: The same command never runs in parallel. If triggered while already running, it will be re-run once after the current execution finishes.

Unhook a command from an event

:RunnerUnhook <name> <event> [pattern]

Remove an event hook from a command. Without arguments, prompts to select from commands that have event hooks.

Edit storage file

:RunnerEdit

Open the project's storage file for manual editing. Useful for adding commands directly as JSON or reviewing the configuration.

Bind a key to a command

:RunnerBind [name] [key]

Bind a key to a saved command so it can be triggered directly. With arguments, bind key to run name. Without arguments, prompts to select a command and enter a key sequence.

Unbind a key from a command

:RunnerUnbind [name]

Remove the keybinding from a command. With argument, unbinds name. Without argument, prompts to select a command to unbind.

Statusline

Add the runner status to your statusline:

vim.o.statusline = "%#Normal# %f %=%{%v:lua.require'nvim-runner'.status()%}"

When idle, the status function returns an empty string (invisible). When a job is running, it shows  running: <name>. After completion, it briefly shows  finished: <name> before auto-clearing.

Telescope extension

If telescope.nvim is loaded, nvim-runner registers itself as an extension automatically:

:Telescope nvim_runner

Or load it explicitly in your config:

require("telescope").load_extension("nvim_runner")

Configuration

require('nvim-runner').setup({
  auto_clear_status = 10,       -- seconds before finished status clears from statusline
  output_line_limit = 5000,     -- max lines of output kept in memory per command
})
Option Default Description
auto_clear_status 10 Seconds before a finished job is removed from the statusline
output_line_limit 5000 Maximum lines of output kept in memory per command (set to 0 for unlimited)
keymaps (see below) Table mapping action names to key bindings. Pass {} to disable all keymaps.

Default keymaps:

Action Default Description
list <leader>xx Open command picker (:RunnerList)
add <leader>xa Add a new command (:RunnerAdd)
remove <leader>xr Remove a command (:RunnerRemove)
output <leader>xo Show command output (:RunnerOutput)
stop <leader>xs Stop a running job (:RunnerStop)

How it works

┌─────────────┐   JSON file    ┌──────────────┐
│  Commands   │ ◄────────────► │  nvim-runner │
│  per startup │   stdpath/data │  (Lua)        │
│  directory   │                │              │
└─────────────┘                └──────┬───────┘
                                      │ vim.uv.spawn()
                                      ▼
                              ┌──────────────┐
                              │  Shell cmd    │
                              │  (background) │
                              └──────┬───────┘
                                     │ stdout/stderr
                                     ▼
                              ┌──────────────┐
                              │  Output       │
                              │  (in memory)  │
                              └──────────────┘

Architecture

  • Storage: Commands are persisted as JSON in stdpath("data")/nvim-runner/<hash>.json. Each session launch directory gets its own file, isolated from others.
  • Job management: Uses vim.uv.spawn() with pipe-based stdout/stderr collection. Output is collected in memory. No external dependencies required.
  • Picker: Auto-detects Telescope; falls back to Neovim's built-in vim.ui.select when Telescope is not available.
  • Statusline: Exposes a status() function that returns a string suitable for embedding in vim.o.statusline. Returns empty when idle, so it's invisible until work happens.
  • Output viewing: Finished output is opened in a floating scratch buffer via nvim_open_win(), with the buffer named runner://<name>.
  • Event binding: Commands can be hooked to Neovim events (e.g., BufWritePost). Uses singleflight semantics to prevent parallel execution.

Session directory

The directory from which Neovim was launched is used as the base directory for commands. This directory is hashed (32-bit string hash) to produce a unique, safe storage filename. All commands run with this directory as the working directory.

Project structure

nvim-runner/
├── plugin/nvim-runner.lua           # Registers Ex commands, guards against double-load
├── lua/nvim-runner/
│   ├── init.lua                     # Entry point, setup(), status() API
│   ├── storage.lua                  # Per-project JSON persistence, root detection
│   ├── job.lua                      # Job lifecycle, output collection, status tracking
│   ├── ui.lua                       # Add/remove commands, output buffer, job stop UI
│   ├── picker.lua                   # Telescope picker + vim.ui.select fallback
│   └── autocmd.lua                  # Event binding management
├── lua/telescope/_extensions/
│   └── nvim_runner.lua              # Telescope extension registration
├── tests/
│   ├── minimal_init.lua             # Isolated test harness setup
│   └── spec/
│       ├── storage_spec.lua         # Persistence, isolation, edge cases
│       ├── job_spec.lua             # Start/stop, output, status, exit codes
│       └── autocmd_spec.lua         # Event binding tests
└── doc/nvim-runner.txt              # Vimdoc help file

Commands

Command Description
:RunnerAdd Add a new saved command (prompts for name and command)
:RunnerRemove Select a saved command to delete
:RunnerList Open picker of saved commands; select one to run
:RunnerOutput [name] View output from a finished command in a floating buffer
:RunnerStop [name] Stop a running job
:RunnerBind [name] [key] Bind a key to a saved command for direct execution
:RunnerUnbind [name] Remove the keybinding from a command
:RunnerHook <name> <event> [pattern] Hook a command to a Neovim event
:RunnerUnhook <name> <event> [pattern] Unhook a command from an event
:RunnerEdit Open the project's storage file for editing

Development

Testing

# Install plenary.nvim for test dependencies
git clone --depth=1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/packer/opt/plenary.nvim

# Run storage tests
nvim --headless -c "PlenaryBustedFile tests/spec/storage_spec.lua" -c "qa!"

# Run job tests
nvim --headless -c "PlenaryBustedFile tests/spec/job_spec.lua" -c "qa!"

# Run autocmd tests
nvim --headless -c "PlenaryBustedFile tests/spec/autocmd_spec.lua" -c "qa!"

Tests use plenary.nvim (busted-style assertions) and run in isolated NeoVim subprocesses.