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,
        },
})