Per Project Async Tasks in Nvim

Author

Alfie Chadwick

Published

June 24, 2025

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