nvim-flow is a pure lua Neovim workflow runner for file-based commands defined in .flow.yml.
Create a .flow.yml next to your project files:
demo.py:
cmd: python "{{filepath}}" --name mikeOpen the file in Neovim and run :FlowRun or :FlowDebug — nvim-flow resolves the command for the current file and executes it in a split. In the default buffer mode, output is rendered in a normal Neovim buffer so narrow splits do not hard-wrap PTY output:
I wanted a workflow that matches how I actually work in Neovim: simple YAML config, fast command resolution, and quick run/debug feedback without extra runtime dependencies.
| Feature | nvim-flow | overseer.nvim | code_runner.nvim | zuzu.nvim |
|---|---|---|---|---|
| Config format | YAML (.flow.yml) |
Lua / VS Code tasks.json |
Lua / JSON | Lua |
| Per-file command args | ✅ native in YAML | ❌ filetype-level only | ||
| Recursive config merge | ✅ dir → $HOME |
❌ | ❌ | ❌ |
nvim-dap integration |
✅ built-in :FlowDebug |
✅ via preLaunchTask |
❌ | ❌ |
| Quickfix integration | ✅ Python traceback | ✅ generic output parsing | ❌ | ✅ diagnostics |
| Command preview | ✅ floating window | ❌ | ❌ | ❌ |
| Wrapped output buffer | ✅ built-in buffer mode | ❌ no documented plain-buffer output | ❌ terminal-style modes only | |
| Jump to config source | ✅ :FlowEdit |
❌ | ❌ | ❌ |
| Match resolution | basename / glob / ext / folder / repo | manual task selection | filetype-based | filetype + dir depth |
| Multi-step workflows | ❌ | ✅ | ❌ | ❌ |
| VS Code tasks compat | ❌ | ✅ | ✅ JSON import | ❌ |
| Dependencies | none (pure Lua) | none (pure Lua) | none (pure Lua) | none (pure Lua) |
| Setup complexity | low — one YAML file | high — Lua templates + ECS components | low — Lua table | medium — Lua profiles + hooks |
Why nvim-flow? If you run the same file with different arguments across projects and want those profiles stored in a simple, versionable YAML file next to your code — nvim-flow is the lightest path. overseer.nvim is the better choice for complex multi-step build pipelines or VS Code compatibility. code_runner.nvim works well if filetype-level granularity is sufficient. zuzu.nvim offers advanced profile resolution but has a steeper learning curve.
- First-class
nvim-dapintegration through:FlowDebug - Run the entry under the cursor straight from a
.flow.ymlbuffer (:FlowRunHere) - Flow source jump (
:FlowEdit) to open the matched.flow.ymldefinition - File lock support (
:FlowToggleLock) - Command preview in a floating window (
:FlowPreview) - Python traceback -> quickfix parser (
:FlowQuickfix) - YAML config (
.flow.yml) - Recursive flow discovery + merge (file dir ->
$HOME, closer wins) - Optional
matcharrays for reusable command definitions - Configurable keymaps through
setup()
return {
{ "mikeboiko/nvim-flow" },
}return {
{
"mikeboiko/nvim-flow",
event = { "BufReadPost", "BufNewFile" },
cmd = { "FlowRun", "FlowRunHere", "FlowDebug", "FlowEdit", "FlowToggleLock", "FlowPreview", "FlowQuickfix" },
opts = {
config_file = ".flow.yml",
terminal_height = 15,
terminal_position = "top",
output_mode = "buffer",
edit_open_command = "tabedit",
stop_at_home = true,
show_command = true,
keymaps = {
run = "<CR>",
debug = "<leader>fd",
edit = "<leader>fe",
toggle_lock = "<leader>fl",
preview = "<leader>fp",
quickfix = "<leader>fq",
},
},
},
}setup() is only needed when you want to override defaults.
If you skip setup, nvim-flow still works with built-in defaults.
require("nvim-flow").setup({
config_file = ".flow.yml",
terminal_height = 15,
terminal_position = "top", -- "top" | "bottom"
output_mode = "buffer", -- "terminal" | "buffer"
edit_open_command = "tabedit", -- e.g. "tabedit" | "edit" | "split" | "vsplit"
stop_at_home = true,
show_command = true,
keymaps = {
run = nil,
debug = nil,
edit = nil, -- suggested: "<leader>fe"
toggle_lock = nil,
preview = nil,
quickfix = nil,
},
})config_file(string, default:".flow.yml")- Filename to search while walking directories upward.
terminal_height(number, default:15)- Height of the terminal split used by
FlowRun.
- Height of the terminal split used by
terminal_position("top" | "bottom", default:"top")- Where the terminal split opens.
output_mode("terminal" | "buffer", default:"buffer")"buffer"— stream command output into a normal scratch buffer with soft-wrap enabled. The buffer name reflects job state:flow://<source_key> [running],flow://<source_key> [done], orflow://<source_key> [failed:N]. Press<C-c>while focused on the flow buffer to interrupt the running job. Avoids hard-wrap caused by narrow terminal PTY width."terminal"— run commands in a PTY-backed terminal buffer.
edit_open_command(string, default:"tabedit")- Vim command used by
FlowEditto open the matched.flow.ymllocation (for example:tabedit,edit,split,vsplit).
- Vim command used by
stop_at_home(boolean, default:true)- Stop recursive config search at
$HOMEinstead of/.
- Stop recursive config search at
show_command(boolean, default:true)- Print resolved command before execution output.
keymaps(table, default: allnil)- Optional mappings for:
rundebugedittoggle_lockpreviewquickfix
- Set any key to
nilto leave it unmapped.
- Optional mappings for:
:FlowRun- run the resolved flow command in the configured split output mode:FlowRunHere- run the flow entry under the cursor directly from a.flow.ymlbuffer:FlowDebug- resolve the same flow command and launch a matchingnvim-dapdebug session:FlowEdit- open the matched.flow.ymlfile and jump to the resolved command line:FlowToggleLock[ {filepath}]- toggle lock (or set lock to explicit path):FlowSet {filepath}- compatibility alias for setting lock directly:FlowPreview- show resolved command for current (or locked) file:FlowQuickfix- parse the last flow output as Python traceback and fill quickfix
FlowEdit follows the same resolution pipeline as FlowRun / FlowPreview, then opens the corresponding .flow.yml and jumps to the resolved command line.
By default it opens in a new tab (edit_open_command = "tabedit"). Change edit_open_command if you prefer edit, split, or vsplit.
Sometimes you want to launch a flow without opening its source file. From inside a .flow.yml buffer, place the cursor anywhere in an entry's block (its key, match, cmd, or comments) and run :FlowRunHere. nvim-flow runs the entry the cursor sits in, bypassing match resolution entirely. It reads the live buffer, so unsaved edits are honored.
The run keymap is context-aware: inside a .flow.yml buffer it runs the entry under the cursor (like :FlowRunHere), and everywhere else it runs the flow resolved for the current file (like :FlowRun). A single mapping — for example run = "<CR>" — therefore covers both.
Entries that use no file-scoped template variables ({{filepath}}, {{filename}}, {{ext}}) run as-is — ideal for named, project-level tasks. When an entry does use a file-scoped variable, nvim-flow resolves a single source file with this precedence:
- the locked file, if one is set (
:FlowSet/:FlowToggleLock) - otherwise the entry's
match/key path patterns are globbed under the repo root, requiring exactly one match
If a file-scoped variable is used but zero or multiple files match (and no lock is set), the run is aborted with a message — narrow the match, or set a lock. Project variables ({{dir}}, {{repo}}, {{folder}}) resolve from the resolved source file, or from the .flow.yml's own location when no source file is needed.
compare-prosafe-pou:
match: ['**/compare/prosafe/pou.py']
cmd: |
uv run yok compare prosafe pou /mnt/nas /mnt/hp --controller SCS0130 --detailRunning :FlowRunHere anywhere in this block executes the command directly. Because it uses no file-scoped variables, no source file is resolved.
FlowDebug uses the same command resolution pipeline as FlowRun, then parses the command to create a debug configuration for nvim-dap and calls dap.continue().
Supported command families include python / python3, uv run ... (including module mode), and node.
Example:
py:
cmd: python "{{filepath}}" --env devRunning :FlowDebug on a Python buffer resolves this flow, builds the debug launch config, and starts the debugger.
default:
cmd: '{{filepath}}'
py:
cmd: python "{{filepath}}"
main.py:
cmd: python "{{filepath}}" --mode=devpython-group:
match: [py, pyw, 'test_*.py', 'scripts/']
cmd: python "{{filepath}}"If match is omitted, the top-level key is used as before.
Resolution order:
- basename (e.g.,
main.py) matchentries- folder name
- repo name
- extension (
.pythenpy) default
Example definitions for each priority type:
main.py:
cmd: echo "1 basename"
python-group:
match: [py, 'test_*.py']
cmd: echo "2 match"
tests:
cmd: echo "3 folder"
my-repo:
cmd: echo "4 repo"
.py:
cmd: echo "5 extension-dot"
py:
cmd: echo "5 extension"
default:
cmd: echo "6 default"Mini winner scenario:
- For
/work/my-repo/tests/main.py,main.py(basename) wins. - If the basename entry is removed,
matchentries are checked before folder/repo/extension/default.
If multiple match entries apply, nvim-flow uses deterministic precedence: nearest config file first, then YAML declaration order within that file.
When running from /a/b/c/file.py, nvim-flow searches for .flow.yml in:
/a/b/c/.flow.yml/a/b/.flow.yml/a/.flow.yml- ... up to
$HOME/.flow.yml(ifstop_at_home = true)
All found configs are merged. Closer files override farther files.
nvim-flow expands these variables in cmd:
{{filepath}}{{dir}}{{filename}}{{ext}}{{repo}}{{folder}}
- Default runner: terminal split (
runner: vimor omitted) - Terminal split opens at the top by default; set
terminal_position = "bottom"to open below. - With
show_command = true, the separator line is sized to the command width (capped by terminal width in terminal mode; display width in buffer mode). output_mode = "terminal"uses Neovim's terminal/PTY path. Long lines follow the PTY width, so narrow splits hard-wrap output just like any other terminal pane.output_mode = "buffer"uses a normal scratch buffer for display, preventing hard-wrap on long lines. The buffer has soft-wrap and linebreak enabled. Commands run in a PTY-backed job with the split width/height so terminal-aware programs andstty sizestill see terminal dimensions, and output streams into the buffer as it arrives. New output is followed automatically like terminal mode; auto-follow pauses if you scroll up and resumes when the cursor returns to the last line. The buffer name tracks state asflow://<source_key> [running],flow://<source_key> [done], orflow://<source_key> [failed:N]. Press<C-c>while focused on the flow buffer to interrupt the running job. To answer an interactive prompt (e.g. ay/nquestion), pressi/awhile focused on the running flow buffer — nvim-flow asks for a line and forwards it to the job's stdin. Terminal cursor-control sequences are stripped from the rendered text, so progress-style redraws degrade to plain text instead of leaking raw escape codes. Full carriage-return/progress-line emulation is not part of this mode.- Debug runner:
runner: debugor:FlowDebug(requiresnvim-dap)
If your commands print wide tables, long paths, or text-heavy logs, buffer mode is usually the better fit. It keeps the same split UX while rendering output like a normal wrapped buffer instead of a fixed-width terminal.
Flow output buffers are tagged so external cleanup/session logic can recognize and protect them:
b:nvim_flow_terminal = 1marks both terminal- and buffer-mode flow buffers.b:nvim_flow_job_idholds the running job id in buffer mode while a command is in flight, and is cleared on exit.require("nvim-flow.runner").is_buffer_job_running(bufnr)reports whether a buffer-mode job is still alive. Unlike terminal buffers, buffer-mode output lives in a normalnofilebuffer, so Neovim's native "job still running" protection does not apply — use this helper before force-closing flow buffers (e.g. in a "close all" mapping) to avoid killing a running command.require("nvim-flow.runner").send_buffer_input(bufnr, text)forwardstextverbatim to a running buffer-mode job's stdin (include a trailing\nto submit a line);prompt_buffer_input(bufnr)asks for a line viavim.ui.inputand sends it. These power the built-ini/amappings and let you script interactive responses.
FlowQuickfix parses the last FlowRun terminal output and extracts Python traceback lines:
File "/path/file.py", line 42, in ...
Then it populates and opens the quickfix list.
This plugin uses plenary's busted harness.
Run tests:
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/nvim-flow { minimal_init = 'tests/minimal_init.lua' }" \
-c "qa"This repo includes a VHS tape at vhs/nvim-flow-demo.tape that records a demo using ./vhs/demo.py and .flow.yml.
The demo shows:
:FlowRun— execute the command in the configured split output mode- Set a breakpoint with
<space>db, then:FlowDebug(nvim-dap)
Run it with:
./vhs/record-demo-gif.shThe script records vhs/nvim-flow-demo.gif, publishes it to vhs.charm.sh, rewrites the README demo embed URL, and stages the README update.
lefthook runs that script before commit on the main branch when staged changes include Lua files (**/*.lua). On other branches it is skipped, so feature-branch commits stay fast and do not rewrite the demo embed.
This project was inspired by ideas from vim-flow, with substantial changes for this codebase and workflow:
https://github.com/jonmorehouse/vim-flow
