Learn Neovim - Integrated Terminal & Task Management (toggleterm.nvim)
Series/Learn Neovim/Episode 19
Episode 19 of 28

Learn Neovim - Integrated Terminal & Task Management (toggleterm.nvim)

In this episode we build an integrated terminal inside Neovim using toggleterm.nvim, run LazyGit inside a floating window, and set up a task runner to run tests and builds without having to leave the editor.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After discussing Git integration inside Neovim through gitsigns.nvim and diffview.nvim in episode 18, in this episode we will perfect the daily workflow with a capability that has long been the advantage of traditional IDEs: integrated terminal and task management. We will use toggleterm.nvim to open a terminal directly inside the editor, run the LazyGit TUI inside a floating window, and create a task runner to run tests and builds without leaving Neovim.

Why is this topic so important in real work? Think about the work pattern of a DevOps engineer managing microservices: they edit configuration, run tests, watch log output, then fix errors — all in a fast, repetitive cycle. Every time you have to Alt+Tab out of the editor to run a command in a separate terminal, you lose context and momentum. Conversely, when the terminal and editor are in the same place, the edit → run → debug cycle becomes one smooth motion. This is what is literally called an integrated development environment: an environment where all work tools are connected.

It should be noted that Neovim already has a built-in terminal (:terminal), but it still feels "raw" for daily use: no quick toggle, no floating mode, and limited inter-buffer integration. This is where toggleterm.nvim comes in as the enhancer. Let's begin.

Main Discussion

The Concept: Why Is the Built-in :terminal Insufficient?

Before getting into the configuration, it is important to understand why. Neovim has supported a built-in terminal via :terminal since version 0.2, but it has several limitations:

  1. No quick toggle — you have to remember the terminal buffer number then reopen it manually.
  2. The terminal "sticks" in the layout — if you open a terminal in a split, it gets saved into the tab and permanently occupies workspace space.
  3. Inconsistent keyboard control — to send escape to the terminal, you must press Ctrl+\ Ctrl+n first, which feels awkward for users used to the Ctrl+\ flow.

toggleterm.nvim solves all of this by providing a single abstraction: a toggleable terminal. When not in use, the terminal is completely out of sight; when needed, it appears with a single key press. This is exactly like the integrated terminal in VS Code, but with full Neovim-style control.

Installing toggleterm.nvim with lazy.nvim

Since we already built the lazy.nvim plugin manager foundation in episode 9, installing this plugin takes just one spec:

lua/plugins/terminal.lua
return {
  {
    "akinsho/toggleterm.nvim",
    version = "*",
    keys = {
      { "\\", desc = "Toggle terminal (bottom split)" },
      { "<C-\\>", desc = "Toggle terminal (floating)" },
      { "<C-t>", "<cmd>ToggleTerm direction=float<CR>", desc = "Floating terminal" },
    },
    config = function()
      require("toggleterm").setup({
        size = 12,
        open_mapping = [[<C-\>]],
        direction = "horizontal",
        close_on_exit = true,
        shading_factor = 2,
        float_opts = {
          border = "curved",
          width = 0.9,
          height = 0.85,
          winblend = 3,
        },
      })
    end,
  },
}

Several important options to understand:

OptionValueFunction
size12Default terminal height in rows when using horizontal/vertical mode
open_mapping<C-\>The main key to toggle the terminal
direction"horizontal"Default direction the terminal appears (can be vertical or float)
close_on_exittrueAutomatically close the window when the terminal process finishes (e.g. running exit)
shading_factor2How much to darken the terminal background so it stands out from the editor
float_opts.border"curved"Floating window border style (options: single, double, rounded, curved)

Note

Notice the keys writing in the spec above. In lazy.nvim, a keys entry in the { "\\", desc = "..." } format is treated as a keymap that is not yet mapped, so lazy.nvim will install the plugin first the moment \ is pressed — this is the essence of lazy-loading by key that we will learn about in episode 23 later. The open_mapping line inside setup() serves double duty as the actual keymap for Ctrl+\.

Basic Workflow: Floating Terminal & Bottom Split

Once the spec above is loaded, you have three ways to open the terminal:

  1. Ctrl+\ — toggles the horizontal terminal at the bottom (default).
  2. \ — an alternative toggle that also triggers plugin lazy-loading.
  3. Ctrl+t — opens the floating terminal directly.

Imagine this scenario: you are working on a bug in handler.go, and want to quickly run go build ./... without closing that file. You just press Ctrl+\, type the command, see the result, then press Ctrl+\ again to hide it. The file being edited stays in place, context is not lost, and the whole process takes only two key presses.

A terminal in toggleterm.nvim is actually a regular buffer. That means you can navigate it like any other buffer: open a log file from terminal output with gf, copy its text with visual mode, even open more than one terminal with different names:

Membuat terminal bernama (contoh untuk docker compose)
local Terminal = require("toggleterm.terminal").Terminal
local dockerlogs = Terminal:new({ cmd = "docker compose logs -f", hidden = true })
 
vim.keymap.set("n", "<leader>tl", function()
  dockerlogs:toggle()
end, { desc = "Toggle Docker compose logs" })

Tip

A terminology distinction: Ctrl+\ (without n) sends a toggle signal to Neovim to open/close the terminal, while Ctrl+\ Ctrl+n is the combination to exit terminal mode and return to normal mode inside Neovim. Many beginners confuse the two — we will discuss this further in the common pitfalls section.

LazyGit Integration: Git TUI Inside a Floating Window

In episode 18 we discussed Git integration inside the buffer with gitsigns and diffview. But for complex Git operations — like interactive rebase, managing branches, or crafting clean commits — a text-based user interface (TUI) like LazyGit is far more productive. LazyGit is a terminal Git application showing status, log, stash, and branches on one screen, complete with keyboard shortcuts.

The natural integration: run LazyGit inside a large toggleterm floating terminal. It looks like opening a GUI Git app, but running inside Neovim:

lua/plugins/terminal.lua (tambahan)
local Terminal = require("toggleterm.terminal").Terminal
 
local lazygit = Terminal:new({
  cmd = "lazygit",
  dir = "git_dir",
  direction = "float",
  float_opts = {
    border = "rounded",
    width = 0.95,
    height = 0.95,
  },
  on_open = function(term)
    vim.cmd("startinsert!")
  end,
  on_close = function()
    vim.cmd("normal! <C-w>=")
  end,
})
 
vim.keymap.set("n", "<leader>gg", function()
  lazygit:toggle()
end, { desc = "Open LazyGit (floating)" })
Instalasi LazyGit (Linux/macOS)
brew install lazygit
# atau
sudo pacman -S lazygit
# atau via binary release dari GitHub releases

Important

The dir = "git_dir" code in the example above is a marker so LazyGit automatically detects the Git repository root from the active buffer. If you do not add dir, the terminal will open at Neovim's working directory when run (:pwd), which may not be the location of the repo you are working on — a classic source of confusion we already recognized in episode 11 when discussing file locations.

With this setup, your Git workflow becomes: press <leader>gg → LazyGit appears filling the screen → see changes in the staging panel, craft a commit, push → press <leader>gg again to close it → return to code. You never need to leave Neovim for Git matters.

Task Runner & Make: Running Tests/Builds into Quickfix

Now we get to the part most relevant to DevOps work: running tasks like builds or tests without leaving the editor. The concept is simple — execute a shell command whose output is captured and displayed inside the quickfix list or terminal — but the impact is large: you can immediately navigate errors with :cnext / :cprev because error lines are parsed and linked to file locations.

Approach 1: A Simple Task Runner with vim.fn.system (Synchronous)

For quick tasks with concise output, we can use vim.fn.system, which runs the command synchronously:

lua/config/tasks.lua
local function run(cmd)
  vim.cmd("copen")
  vim.fn.setqflist({}, " ", { title = cmd })
  local output = vim.fn.system(cmd)
  local lines = vim.split(output, "\n", { trimempty = false })
  vim.fn.setqflist({}, " ", { lines = lines })
  vim.cmd("wincmd p")
end
 
vim.keymap.set("n", "<leader>tt", function()
  run("go test ./...")
end, { desc = "Run Go tests" })
 
vim.keymap.set("n", "<leader>tb", function()
  run("go build ./...")
end, { desc = "Run Go build" })

After the command finishes, the results appear in quickfix, and with setqflist Neovim automatically tries to resolve lines that look like file:line:message into file jumps. Press Enter in quickfix to jump straight to the error location.

Approach 2: Asynchronous Tasks with Plenary job

vim.fn.system is blocking: the editor freezes while the command runs. For test suites taking tens of seconds, that is not a good experience. The solution is to run tasks asynchronously using plenary.job, which has become a standard dependency in the Neovim plugin ecosystem:

lua/config/tasks.lua (asinkron)
local Job = require("plenary.job")
 
local function run_async(cmd, args)
  vim.cmd("copen")
  vim.fn.setqflist({}, " ", { title = cmd })
  Job:new({
    command = cmd,
    args = args,
    on_exit = function(job, code)
      local result = job:result()
      vim.schedule(function()
        vim.fn.setqflist({}, " ", { lines = result })
        vim.api.nvim_echo({ { "Task selesai dengan exit code " .. code, "DiagnosticInfo" } }, false, {})
      end)
    end,
  }):start()
end
 
vim.keymap.set("n", "<leader>tt", function()
  run_async("go", { "test", "./..." })
end, { desc = "Run Go tests (async)" })

Tip

Note the vim.schedule call. Jobs run outside Neovim's main event loop (which is what makes them asynchronous), so Neovim APIs must not be called directly from inside the on_exit callback. vim.schedule delays execution until it returns to the main loop — this is a mandatory pattern you must master when writing code that interacts with external processes.

Approach 3: Integration with make

Finally, Neovim already has first-class support for make (:make), which runs make in the directory and automatically fills the quickfix list from gcc/go/tsc output. You can map <leader>tm for it:

lua
vim.keymap.set("n", "<leader>tm", "<cmd>make<CR>", { desc = "Run make" })
vim.keymap.set("n", "<leader>te", "<cmd>copen<CR>", { desc = "Open quickfix" })
vim.keymap.set("n", "<leader>tn", "<cmd>cnext<CR>", { desc = "Next error" })
vim.keymap.set("n", "<leader>tp", "<cmd>cprev<CR>", { desc = "Previous error" })

One of :make's strengths is makeprg and errorformat support. If your project uses a Makefile with various targets, :make test or :make lint will work immediately without extra plugins — just adjust errorformat so the error parser understands your tool's output format.

Keymap Summary Table

KeymapModeAction
Ctrl+\InsertToggle the horizontal terminal (default)
\NormalToggle the terminal (lazy-loaded)
Ctrl+tNormal/InsertToggle the floating terminal
<leader>ggNormalOpen LazyGit in a floating window
<leader>ttNormalRun go test ./... into quickfix
<leader>tbNormalRun go build ./... into quickfix
<leader>tmNormalRun :make
<leader>tn / <leader>tpNormalNavigate to next/previous quickfix error

Common Pitfalls

MistakeSymptomSolution
Confusing Ctrl+\ vs Ctrl+\ Ctrl+nThe terminal refuses to return to normal mode, or toggling instead closes the terminalRemember: Ctrl+\ alone = toggle; Ctrl+\ Ctrl+n = exit to normal mode inside the terminal buffer
Terminal keybinding collides with other mappingsPress Ctrl+\ but the terminal does not appearCheck :verbose imap <C-\> — maybe another plugin (e.g. LazyVim extras) has already mapped Ctrl+\
Floating window too small / too bigFloating terminal uncomfortable to useAdjust float_opts.width/height, or enable wrap and a higher winblend
Blocking task freezes the editorUI stalls while tests runReplace vim.fn.system with plenary.job or the asynchronous vim.system
LazyGit opens in the wrong directoryThe repo shown is not the project repoSet dir = "git_dir" on Terminal:new
Quickfix does not link errorsOutput appears but cannot jump to filesMake sure the output format matches errorformat; sometimes a special :set errorformat per tool is needed

Caution

If you use LazyGit, never press Ctrl+\ Ctrl+n inside the LazyGit window expecting to return to the editor — because LazyGit itself also uses Ctrl+\ as a shortcut to certain panels. It is usually safer to toggle the floating window with the <leader>gg keymap you created, not the global Ctrl+\.

Closing

In episode 19 we built three core capabilities that make Neovim feel like a modern IDE: an integrated terminal with toggleterm.nvim in various modes (horizontal, floating), LazyGit integration inside a floating window for a fast Git workflow, and a task runner for running tests and builds with output going straight into the quickfix list. We also discussed the fundamental difference between synchronous and asynchronous execution, complete with the vim.schedule pattern you must understand.

With these capabilities, the edit → test → debug cycle no longer forces you to switch applications. Neovim has become a single workspace.

However, a fast editor is not complete without smart editing movements. In episode 20, we will cover Code Editing Productivity Boosters — plugins that automate closing brackets and tags, surround text manipulation, quick comments, and the which-key shortcut helper popup. Stay motivated!