Per Project Async Tasks in Nvim
As someone who tries to never leave the terminal, an ongoing gripe for me is starting up background tasks—say a Docker Compose server—without using up all of my screen space. For this, I found Overseer.nvim to be a really useful tool. But, as with the issue with many of the big and powerful nvim plugins, there are a lot of features that I don’t care for at all, which can make it hard to integrate into a workflow that I want.
So I spent the weekend doing a pretty extensive configuration of how to get Overseer to do what I want, and thought it might be useful to share.
The Goals
- Ability to run tasks in the background and display the output in a floating terminal
- Have saved tasks and runnable scripts per repo
- Be able to run a one-off task, then save it to run again
The first goal is completed simply by having Overseer installed, which you can do by including this in your config
call plug#begin()
Plug 'stevearc/dressing.nvim'
Plug 'nvim-telescope/telescope.nvim'
Plug 'stevearc/overseer.nvim'
call plug#end()
lua require('overseer').setup({})
For the 2nd and the third, I made a file called .overseer.lua in my root repo and outlined some task templates that I want to store:
-- .overseer.lua
return {
{
name = "Tidy Refs",
builder = function(_)
local cmd_str = table.concat({
"npm install -g bibtex-tidy",
"bibtex-tidy -m --curly --numeric --align=13 --duplicates=key no-escape --sort-fields --remove-empty-fields --no-remove-dupe-fields sort=-year,key --wrap=80 References/_references.bib",
}, " && ")
return {
cmd = { "sh", "-c", cmd_str },
components = { "default" },
name = 'Tidy Refs'
}
end,
},
{
name = "Build Site",
builder = function(_)
local cmd_str = table.concat({
"pip install matplotlib numpy catppuccin beautifulsoup4 pandas Requests lxml",
[[R -e 'install.packages(c("jsonlite","patchwork", "xgboost", "remotes"))']],
[[R -e 'remotes::install_github("albert-ying/catppuccin")']],
"quarto preview"
}, " && ")
return {
cwd = "Website",
cmd = { "sh", "-c", cmd_str },
components = { "default" },
name = 'Build Website'
}
end,
},
}
Now the question becomes, how can I get overseer to read from this file?
Integrating Overseer
Luckily for me, overseer.nvim has a good system for importing tasks from files,
so it’s a simple function and autocommand to read the file and load the
templates on nvim start.
function load_project_overseer_templates()
local file = vim.fn.getcwd() .. "/.overseer.lua"
local ok, templates = pcall(dofile, file)
if ok and type(templates) == "table" then
for _, tpl in ipairs(templates) do
require("overseer").register_template(tpl)
end
print("[Overseer] Loaded project templates from .overseer.lua")
end
end
vim.api.nvim_create_autocmd("VimEnter", {
callback = load_project_overseer_templates,
})
The harder thing is to be able to save tasks dynamically. I want to do this because I really like the OverseerBuild command, so being able to use that and then save them is really useful.
After a lot of trial and error, I was able to make an action inside Overseer that means when I hit S on a task in the task list.
require('overseer').setup({
actions = {
["Save as template"] = {
desc = "Save minimal task template with name, cwd, and cmd to .overseer.lua",
condition = function(task)
return task.serialize ~= nil
end,
run = function(task)
-- grab the task information
local def = task:serialize()
-- Minimal fields: name, cwd, cmd only
local minimal = {
name = def.name,
cwd = def.cwd,
cmd = def.cmd,
}
-- function to extract the info from the serialisation
local function serialize(tbl, indent)
indent = indent or 0
local pad = string.rep(" ", indent)
local chunks = {"{\n"}
for k, v in pairs(tbl) do
if v ~= nil then
local key = type(k) == "string" and string.format("%s", k) or tostring(k)
local value
if type(v) == "string" then
value = string.format("%q", v)
elseif type(v) == "table" then
if vim.tbl_islist(v) then
local parts = {}
for _, item in ipairs(v) do
table.insert(parts, string.format("%q", item))
end
value = "{ " .. table.concat(parts, ", ") .. " }"
else
value = serialize(v, indent + 1)
end
else
value = tostring(v)
end
table.insert(chunks, string.format("%s %s = %s,\n", pad, key, value))
end
end
table.insert(chunks, pad .. "}")
return table.concat(chunks)
end
--- format the definition into a good format
local block = string.format([[
{
name = %q,
builder = function()
return %s
end,
},
]], minimal.name or "Task", serialize(minimal))
local path = vim.fn.getcwd() .. "/.overseer.lua"
-- Read existing file or create new
local lines = {}
local file = io.open(path, "r")
if file then
for line in file:lines() do
table.insert(lines, line)
end
file:close()
else
lines = { "return {", block, "}" }
end
-- Find closing brace to insert before
local insert_index = nil
for i = #lines, 1, -1 do
if vim.trim(lines[i]) == "}" then
insert_index = i
break
end
end
if not insert_index then
vim.notify("No closing } found in .overseer.lua", vim.log.levels.ERROR)
return
end
table.insert(lines, insert_index, block)
file = io.open(path, "w")
if not file then
vim.notify("Failed to write to .overseer.lua", vim.log.levels.ERROR)
return
end
file:write(table.concat(lines, "\n") .. "\n")
file:close()
vim.notify("✅ Task saved (name, cwd, cmd only) to .overseer.lua", vim.log.levels.INFO)
()
load_project_overseer_templates
end,
},
})