- Lua 100%
- 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. |
||
|---|---|---|
| config | ||
| doc | ||
| lua | ||
| plugin | ||
| tests | ||
| .luacheckrc | ||
| .stylua.toml | ||
| README.md | ||
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
- NeoVim >= 0.9
- Optional: telescope.nvim for the enhanced picker
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.selectwhen Telescope is not available. - Statusline: Exposes a
status()function that returns a string suitable for embedding invim.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 namedrunner://<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.