Per Project Async Tasks in Nvim

  • By: Alfie Chadwick Date: June 24, 2025 Bud
    Seeds:
  • 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,
            },
    })