What is Neovim Terminal Integration?
Neovim Terminal Integration is a built-in feature that embeds fully functional terminal emulators inside Neovim buffers. Unlike traditional terminal multiplexers like tmux or screen, Neovim's terminal runs directly inside your editor session, sharing the same process space. This means you can interact with shell sessions, REPLs, and long-running processes while retaining all of Neovim's editing capabilities — yanking text, navigating with motions, and using registers — directly within the terminal buffer.
The terminal is implemented using libvterm, a C library that emulates a VT100/xterm terminal. When you spawn a terminal in Neovim, you get a special buffer type (buftype=terminal) that behaves like a hybrid between a standard Vim buffer and a real terminal. Keystrokes are forwarded to the underlying shell process, while Neovim's normal mode commands remain accessible after a dedicated keybinding.
Why Neovim Terminal Integration Matters
For developers, the constant context-switching between editor and terminal is one of the biggest productivity killers. Every time you leave your editor to run tests, check logs, or execute git commands, you lose flow state. Neovim's terminal integration solves this by:
- Eliminating context switches — Stay inside Neovim while running any shell command
- Enabling true editor-shell interoperability — Copy command output with standard Vim motions, paste editor content into terminal prompts, and navigate terminal history with familiar keybindings
- Supporting REPL-driven development — Run interactive interpreters (Python, Node, IPython, etc.) and send code directly from source files to the REPL
- Replacing tmux for many workflows — Manage multiple terminal sessions using Neovim's native window and tab system
- Providing job control APIs — Programmatically spawn, communicate with, and control terminal jobs from Lua or Vimscript
Opening Your First Terminal
The most direct way to open a terminal is with the :terminal command (or its shorthand :term). Here are the essential commands:
" Open a new terminal in a split window
:terminal
" Open terminal with a specific shell command
:term bash
" Open terminal and run a command immediately
:term python -m http.server 8000
" Open terminal in a horizontal split (below current window)
:below term
" Open terminal in a vertical split (right of current window)
:rightbelow vertical term
" Open terminal as a full-width split at the very bottom
:botright term
" Specify the split size
:20split | terminal
When the terminal opens, it starts in Terminal-Job mode, where all keystrokes are sent directly to the shell. This is different from Normal mode — you cannot navigate the buffer with hjkl initially. To enter Terminal-Normal mode (where you can move around and use Vim commands), press:
<Ctrl-\><Ctrl-n>
This key sequence is the universal escape hatch from terminal input mode. Once in Terminal-Normal mode, you can scroll through output, yank text, search, and perform any standard Vim operation.
Terminal Modes Explained
Neovim's terminal has two distinct modes:
1. Terminal-Job Mode (Insert-like)
In this mode, every keystroke is forwarded to the running shell process. The status line typically shows -- TERMINAL --. You type commands, interact with REPLs, and control the child process exactly as you would in a standalone terminal. Standard Vim keybindings like j or dd will be sent as literal characters to the shell.
2. Terminal-Normal Mode
Entered via <Ctrl-\><Ctrl-n> or by clicking in the terminal with the mouse (if mouse support is enabled). In this mode, you can:
- Navigate with
hjkl,w,b, etc. - Yank text into registers with
ymotions - Search with
/and? - Use visual selection on terminal output
- Resize the terminal window with standard window commands
- Return to Terminal-Job mode by pressing
i,a, or any key that would normally enter Insert mode
" In Terminal-Normal mode, these work as expected:
gg " Go to top of terminal buffer
G " Go to bottom (latest output)
/error " Search for 'error' in output
yy " Yank current line
yip " Yank current paragraph of output
Ctrl-w w " Switch to next window
Customizing the Terminal Escape Key
The default escape sequence <Ctrl-\><Ctrl-n> can be awkward. You can map a more convenient key in your terminal buffers:
-- In your init.lua
vim.api.nvim_create_autocmd('TermOpen', {
group = vim.api.nvim_create_augroup('term_custom', { clear = true }),
callback = function()
local opts = { buffer = vim.api.nvim_get_current_buf(), noremap = true, silent = true }
vim.keymap.set('t', '<C-h>', [[<C-\><C-n><C-w>h]], opts)
vim.keymap.set('t', '<C-j>', [[<C-\><C-n><C-w>j]], opts)
vim.keymap.set('t', '<C-k>', [[<C-\><C-n><C-w>k]], opts)
vim.keymap.set('t', '<C-l>', [[<C-\><C-n><C-w>l]], opts)
-- Use Esc to get into Terminal-Normal mode
vim.keymap.set('t', '<Esc>', [[<C-\><C-n>]], opts)
end,
})
With this configuration, pressing <Esc> in a terminal enters Terminal-Normal mode, and <C-h/j/k/l> navigates between windows without needing to manually type the escape sequence. The TermOpen autocmd fires each time a terminal buffer is created, ensuring these mappings are always applied.
Navigating and Managing Terminal Windows
Neovim treats terminal buffers as first-class windows. You can organize them alongside file buffers in any layout:
" Open a terminal for running tests on the right
:vs | term make test
" Open a REPL below your source code
:below 15split | term python3
" Switch to terminal by buffer name
:buffer !bash
" List all terminal buffers
:ls!
" Close a terminal (kills the job if it's still running)
:q
" Force-close even with a running job
:q!
When you have multiple terminals open, you can quickly jump between them using :bnext, :bprev, or by typing the buffer name. Terminal buffers are named after the command they're running — !bash, !python3, etc. — preceded by an exclamation mark.
Sending Text to the Terminal Programmatically
One of the most powerful features is the ability to send text from any Neovim buffer directly into a terminal. This enables REPL-driven workflows where you evaluate code without leaving your source file. The function vim.api.nvim_chan_send writes data to a terminal's job channel:
-- Get the channel ID of a terminal buffer
local buf = 0 -- current buffer, or use a specific buffer number
local chan = vim.api.nvim_buf_get_option(buf, 'channel')
-- Send text followed by Enter
vim.api.nvim_chan_send(chan, 'print("Hello from Neovim!")\n')
Here's a practical mapping that sends the current visual selection to a Python REPL running in the adjacent terminal:
-- In init.lua
vim.keymap.set('v', '<leader>sp', function()
-- Get visual selection
local _, start_line, start_col, _ = unpack(vim.fn.getpos("'<"))
local _, end_line, end_col, _ = unpack(vim.fn.getpos("'>"))
local lines = vim.fn.getline(start_line, end_line)
if #lines == 0 then return end
-- Adjust last line to selection end column
lines[#lines] = string.sub(lines[#lines], 1, end_col)
-- Adjust first line to selection start column
lines[1] = string.sub(lines[1], start_col)
local text = table.concat(lines, '\n') .. '\n'
-- Find the terminal buffer (e.g., one running 'python3')
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_option(buf, 'buftype') == 'terminal' then
local name = vim.api.nvim_buf_get_name(buf)
if name:find('python') then
local chan = vim.api.nvim_buf_get_option(buf, 'channel')
vim.api.nvim_chan_send(chan, text)
return
end
end
end
print("No Python terminal found")
end, { noremap = true, silent = true, desc = 'Send selection to Python REPL' })
This mapping searches all open buffers for a terminal running Python, then sends the visually selected code block to it. The selection coordinates are extracted from the '< and '> marks, and the text is reconstructed with proper line breaks.
Using jobstart() for Advanced Terminal Control
While :terminal is convenient, the vim.fn.jobstart() function provides granular control over terminal processes. You can attach callbacks for stdout, stderr, and exit events, allowing you to build custom test runners, build watchers, and language-specific tooling:
-- Spawn a terminal job with full callback control
local function run_tests_in_float()
local buf = vim.api.nvim_create_buf(false, true) -- scratch buffer
-- Make it a small floating window
local width = math.floor(vim.o.columns * 0.6)
local height = math.floor(vim.o.lines * 0.4)
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
local win_opts = {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = 'rounded',
}
local win = vim.api.nvim_open_win(buf, true, win_opts)
-- Start the job attached to this buffer
local job_id = vim.fn.jobstart('pytest -v', {
pty = true, -- Allocate a pseudo-terminal
term_rows = height - 2,
term_cols = width - 2,
on_stdout = function(_, data, _)
-- Optional: parse output for quickfix list population
if data then
for _, line in ipairs(data) do
if line:find('FAILED') or line:find('ERROR') then
-- Could populate quickfix here
end
end
end
end,
on_stderr = function(_, data, _)
-- Handle error output
end,
on_exit = function(_, exit_code, _)
if exit_code == 0 then
vim.schedule(function()
vim.api.nvim_win_set_buf(win, buf)
vim.api.nvim_buf_set_lines(buf, -1, -1, false, {'✓ All tests passed!'})
end)
else
vim.schedule(function()
vim.api.nvim_buf_set_lines(buf, -1, -1, false, {'✗ Tests failed with exit code: ' .. exit_code})
end)
end
end,
})
vim.api.nvim_buf_set_option(buf, 'buftype', 'terminal')
vim.fn.termopen('pytest -v') -- Alternative: use termopen for simpler integration
end
vim.keymap.set('n', '<leader>tf', run_tests_in_float, { desc = 'Run tests in floating terminal' })
The pty: true option allocates a pseudo-terminal, which is essential for programs that use terminal control sequences (colored output, progress bars, ncurses interfaces). The term_rows and term_cols parameters set the terminal size that the program sees, independent of the actual window dimensions.
Persistent Terminal Sessions with Buffer Management
By default, closing a terminal window kills the underlying job. To keep processes running in the background, you can hide the terminal buffer without destroying it:
-- Hide terminal without killing the job
:set hidden " Enable hidden buffers (usually on by default)
:bd! " Delete buffer but keep job running if buffer is hidden
-- Alternative: use 'bufhidden' option
:set bufhidden=hide
For long-running servers or watchers, create a dedicated terminal buffer with autocommands that prevent accidental closure:
-- Create a persistent dev server terminal
vim.api.nvim_create_autocmd('TermOpen', {
group = vim.api.nvim_create_augroup('persistent_term', { clear = true }),
pattern = '!dev-server',
callback = function()
local buf = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_option(buf, 'bufhidden', 'hide')
vim.keymap.set('n', '<leader>ds', function()
vim.cmd('buffer !dev-server')
end, { desc = 'Show dev server terminal' })
end,
})
-- Start it
:below 10split | term npm run dev
Configuring Terminal Appearance and Behavior
Terminal buffers support most standard Vim options, plus terminal-specific settings:
" In your init.vim or via Lua
set termguicolors " Enable true color in terminals
set scrollback=10000 " Lines of scrollback to keep
" Terminal-specific settings via autocmd
autocmd TermOpen * setlocal nonumber " Hide line numbers
autocmd TermOpen * setlocal norelativenumber
autocmd TermOpen * setlocal signcolumn=no " Hide sign column
autocmd TermOpen * setlocal nocursorline " No cursor line highlight
autocmd TermOpen * startinsert " Start in insert mode automatically
The Lua equivalent using the nvim_create_autocmd API:
vim.api.nvim_create_autocmd('TermOpen', {
group = vim.api.nvim_create_augroup('term_settings', { clear = true }),
callback = function()
local opts = { buffer = vim.api.nvim_get_current_buf() }
vim.bo[opts.buffer].number = false
vim.bo[opts.buffer].relativenumber = false
vim.bo[opts.buffer].signcolumn = 'no'
vim.bo[opts.buffer].cursorline = false
-- Start in Terminal-Job mode so you can type immediately
vim.cmd('startinsert')
end,
})
Copying and Pasting Between Terminal and Files
The terminal buffer is a regular Neovim buffer for editing purposes once you're in Terminal-Normal mode. This means:
- Use
yandpto copy/paste terminal output to other buffers - Use
+or*register for system clipboard integration - Visual selections work exactly as in any other buffer
" In Terminal-Normal mode (<C-\><C-n>):
V " Start line-wise visual selection
jjj " Select multiple lines of output
"+y " Yank to system clipboard
<C-w>w " Switch to code buffer
"+p " Paste from system clipboard
For clipboard integration, ensure Neovim is compiled with clipboard support and your system has the appropriate provider (win32yank on Windows, xclip/wl-clipboard on Linux, pbcopy on macOS).
Building a Terminal Toggler
A common pattern is a floating terminal window that toggles open/closed with a single keybinding, preserving the running shell session:
-- Floating terminal toggle in Lua
local function toggle_terminal()
local term_buf = nil
-- Search for existing terminal buffers
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_option(buf, 'buftype') == 'terminal' then
local name = vim.api.nvim_buf_get_name(buf)
if name:find('toggleterm') then
term_buf = buf
break
end
end
end
if term_buf then
-- Check if it's visible in any window
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == term_buf then
-- Hide it
vim.api.nvim_win_hide(win)
return
end
end
-- Show it again
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.5)
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
vim.api.nvim_open_win(term_buf, true, {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
border = 'rounded',
})
else
-- Create new terminal
vim.cmd('below split | terminal')
local buf = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_name(buf, 'toggleterm')
end
end
vim.keymap.set('n', '<leader>tt', toggle_terminal, { desc = 'Toggle floating terminal' })
vim.keymap.set('t', '<leader>tt', [[<C-\><C-n>:lua toggle_terminal()<CR>]], { desc = 'Toggle from terminal' })
This creates a smart toggler: the first press opens a terminal, subsequent presses hide/show the same terminal buffer, preserving all output and the running shell state.
Running Shell Commands and Capturing Output
For non-interactive commands where you just want to see the results, use :!cmd for simple cases or jobstart without a pty for structured output capture:
-- Run a shell command and capture its output into a new buffer
local function run_cmd_and_show(cmd)
local output_lines = {}
local job_id = vim.fn.jobstart(cmd, {
pty = false, -- No pty needed for simple commands
on_stdout = function(_, data, _)
if data then
for _, line in ipairs(data) do
if line ~= '' then
table.insert(output_lines, line)
end
end
end
end,
on_exit = function(_, exit_code, _)
vim.schedule(function()
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, output_lines)
vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_set_current_buf(buf)
-- Add a header
vim.api.nvim_buf_set_lines(buf, 0, 0, false, {
'== Output of: ' .. cmd .. ' (exit code: ' .. exit_code .. ') ==',
'',
})
end)
end,
})
end
vim.keymap.set('n', '<leader>rc', function()
local cmd = vim.fn.input('Run command: ')
if cmd ~= '' then
run_cmd_and_show(cmd)
end
end, { desc = 'Run shell command in new buffer' })
Integration with LSP and Build Systems
Terminal integration shines when combined with Neovim's LSP client. You can spawn terminals that react to LSP diagnostics, run project builds, and feed errors back into the quickfix list:
-- Run 'make' and parse errors into quickfix
vim.keymap.set('n', '<leader>bm', function()
local cwd = vim.fn.getcwd()
local job_id = vim.fn.jobstart('make', {
pty = true,
cwd = cwd,
on_exit = function(_, exit_code, _)
vim.schedule(function()
if exit_code == 0 then
vim.notify('Build succeeded', vim.log.levels.INFO)
else
-- Load quickfix from make output
vim.cmd('cgetfile build.log')
vim.cmd('copen')
vim.notify('Build failed with errors', vim.log.levels.ERROR)
end
end)
end,
})
-- Show the build terminal
vim.cmd('below 10split | terminal make')
end, { desc = 'Build project and show errors' })
Best Practices for Neovim Terminal Integration
1. Use Dedicated Terminal Namespace Mappings
Create a separate set of keymaps that only apply in terminal mode (:map t). This prevents conflicts between shell interactions and editor commands:
-- Terminal-mode-only mappings (t: prefix)
vim.keymap.set('t', '<C-w>', [[<C-\><C-n><C-w>]], { desc = 'Window navigation prefix' })
vim.keymap.set('t', '<C-z>', [[<C-\><C-n>:suspend<CR>]], { desc = 'Suspend Neovim' })
vim.keymap.set('t', '<PageUp>', [[<C-\><C-n><PageUp>]], { desc = 'Scroll up in terminal' })
vim.keymap.set('t', '<PageDown>', [[<C-\><C-n><PageDown>]], { desc = 'Scroll down in terminal' })
2. Prefer Floating Windows for Ephemeral Commands
Use floating terminal windows for quick, disposable commands (running tests, checking git status, one-off scripts). Use persistent splits for long-running processes (servers, REPLs, watchers). This keeps your workspace uncluttered.
3. Leverage Buffer Autocommands
Attach behavior to terminal buffers with TermOpen and buffer-local autocommands rather than global settings. Each terminal can have its own personality — a Python REPL might auto-indent pasted code, while a build watcher might auto-scroll to the bottom on new output.
4. Use Named Terminal Buffers
Assign meaningful names to terminal buffers so you can reliably target them from scripts:
:term npm run dev
:file dev-server " Rename buffer for easy reference
5. Integrate with Your Plugin Ecosystem
Many plugins provide enhanced terminal experiences. Consider exploring toggleterm.nvim for advanced window management, nvim-treesitter for syntax highlighting inside terminal output, and nvim-dap for debugger terminal integration.
6. Handle Terminal Exit Gracefully
Always attach an on_exit callback when spawning jobs programmatically. This prevents zombie buffers and ensures cleanup:
on_exit = function(_, exit_code, _)
vim.schedule(function()
if exit_code ~= 0 then
-- Preserve buffer for error inspection
vim.api.nvim_buf_set_option(buf, 'bufhidden', 'hide')
else
-- Auto-close successful command buffers
vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
end
end)
end
7. Use TermOpen to Set Terminal-Specific Options
Always disable line numbers, relative numbers, and sign columns in terminals. These take up valuable horizontal space and serve no purpose in terminal buffers.
Debugging Terminal Issues
Common terminal problems and their solutions:
- Colors look wrong — Ensure
set termguicolorsis enabled. Some programs needTERM=xterm-256colororCOLORTERM=truecolorin the environment. - Resize doesn't propagate — The terminal job receives
SIGWINCHwhen the window resizes, but some programs ignore it. Restart the command or use:resizebefore starting the job. - Cannot exit insert mode — You're in Terminal-Job mode. Press
<Ctrl-\><Ctrl-n>, not<Esc>(unless you've remapped it). - Terminal buffer persists after job exits — Use
:bwipeout!to completely remove it, or setbufhidden=wipeon exit callback.
Conclusion
Neovim's terminal integration transforms the editor from a text-manipulation tool into a complete development environment. By embedding real terminal emulators as first-class buffers, Neovim eliminates the friction between editing and execution that plagues traditional workflows. Whether you're sending code snippets to a REPL, running test suites in floating windows, or managing persistent build watchers, the terminal API provides the building blocks for a deeply integrated developer experience.
The key to mastering Neovim terminals is understanding the two-mode paradigm (Terminal-Job and Terminal-Normal) and leveraging the programmatic APIs — jobstart(), nvim_chan_send(), and buffer autocommands — to build workflows tailored to your specific stack. Start with simple :terminal commands, gradually adopt floating terminal togglers, and eventually craft custom REPL integrations that make your editor feel like an extension of your thought process rather than a separate tool.
With the techniques covered here, you have everything needed to turn Neovim into a seamless, terminal-native development powerhouse.