Vim Terminal Integration: Complete Guide
For decades, Vim users faced a familiar ritual: suspend the editor with Ctrl-Z, run a shell command, then return with fg. Modern Vim — and its popular fork Neovim — have eliminated this friction by embedding a real terminal emulator directly inside the editor. Terminal integration lets you run shells, build tools, REPLs, and long-running processes without ever leaving your editing context. This guide walks through everything from the built-in :terminal command to advanced plugin workflows and tmux pairings.
What Vim Terminal Integration Actually Is
Terminal integration refers to Vim's ability to spawn a pseudo-terminal (PTY) inside a buffer. Unlike shelling out with :!, which blocks the UI and discards output, a terminal buffer runs asynchronously, captures every byte of output, and lets you interact with the process as if you were in a standalone terminal. The buffer is a first-class citizen: it can be split, tabbed, hidden, and scripted like any other buffer.
Neovim took this further by decoupling the terminal into a dedicated API (jobstart, chansend, termopen), making programmatic control far more robust. Vim 8+ ships a comparable feature set via term_start() and the :terminal ex command.
Why It Matters
- Context preservation: Your window layout, marks, registers, and undo tree stay intact while you run commands.
- Asynchronous workflows: Long-running test suites, dev servers, and watchers run in the background without freezing the editor.
- REPL-driven development: Send code snippets to a Python, Node, or Clojure REPL living in a split pane.
- Scriptability: Terminal buffers can be driven by Vimscript or Lua, enabling custom tooling on top of any CLI.
- Reduced context switching: One window, one mental model — no alt-tabbing between terminal and editor.
Using the Built-in Terminal
Opening a Terminal Buffer
The simplest entry point is the :terminal command. In Vim, it splits the current window horizontally and starts your $SHELL. In Neovim, the behavior is identical but the underlying API is richer.
" Open a horizontal split terminal
:terminal
" Open in a vertical split
:vertical terminal
" Open in a new tab
:tab terminal
" Run a specific command instead of a shell
:terminal npm test
:terminal python3 -i
Once the terminal starts, you are in Terminal-mode. The buffer behaves like a real terminal: keystrokes go to the underlying process, not to Vim. To return to Normal mode and manipulate the buffer as text, press Ctrl-\ Ctrl-n (Neovim and Vim 8.1+). This is the universal escape hatch and worth memorizing immediately.
Terminal-Mode Mappings
By default, leaving Terminal-mode requires the somewhat awkward Ctrl-\ Ctrl-n sequence. Most users remap it to something ergonomic. Add this to your vimrc or init.lua:
" Vimscript: escape terminal mode with Esc or Ctrl-[
tnoremap <Esc> <C-\><C-n>
tnoremap <C-[> <C-\><C-n>
" Quickly switch between terminal and normal window
tnoremap <C-h> <C-\><C-n><C-w>h
tnoremap <C-j> <C-\><C-n><C-w>j
tnoremap <C-k> <C-\><C-n><C-w>k
tnoremap <C-l> <C-\><C-n><C-w>l
For Neovim users writing Lua, the equivalent configuration looks like this:
-- init.lua
vim.keymap.set('t', '<Esc>', '<C-\\><C-n>')
vim.keymap.set('t', '<C-h>', '<C-\\><C-n><C-w>h')
vim.keymap.set('t', '<C-j>', '<C-\\><C-n><C-w>j')
vim.keymap.set('t', '<C-k>', '<C-\\><C-n><C-w>k')
vim.keymap.set('t', '<C-l>', '<C-\\><C-n><C-w>l')
Window-Local Terminal Settings
Terminal buffers benefit from a few window-local options. A common pattern is to set these automatically whenever a terminal buffer is created:
" Auto-enter terminal mode when the buffer is shown
augroup TerminalSetup
autocmd!
autocmd TermOpen * startinsert
autocmd TermOpen * setlocal nonumber norelativenumber signcolumn=no
autocmd BufEnter term://* startinsert
augroup END
The startinsert command drops you straight into Terminal-mode, so you can begin typing shell commands immediately. Hiding line numbers keeps the terminal looking like a real shell rather than a text buffer.
Sending Commands to Terminal Buffers
Programmatic Control with jobsend / chansend
One of the most powerful aspects of terminal integration is the ability to send text into a running process programmatically. This is the foundation of REPL workflows and custom build runners.
" Vim 8+: open a hidden terminal running a Python REPL
let s:repl_buf = term_start("python3", {"hidden": 1})
" Send a line of code to it
call term_sendkeys(s:repl_buf, "print(2 + 2)\n")
" Neovim equivalent using the channel API
let s:job_id = jobstart("python3", {"term": v:true})
call chansend(s:job_id, "print(2 + 2)\n")
In Neovim with Lua, the same operation is cleaner:
local job_id = vim.fn.jobstart('python3', { term = true })
vim.fn.chansend(job_id, 'print(2 + 2)\n')
Sending the Current Line or Selection
A practical mapping sends the current line to the most recent terminal buffer, which is ideal for exploratory data analysis or scripting:
" Neovim: send current line to the last terminal buffer
function! SendLineToTerminal()
let line = getline('.') . "\n"
call chansend(b:terminal_job_id, line)
endfunction
nnoremap <leader>s :call SendLineToTerminal()<CR>
For visual selections, extend the function to read the selected range:
function! SendSelectionToTerminal() range
let lines = getline(a:firstline, a:lastline)
let joined = join(lines, "\n") . "\n"
call chansend(b:terminal_job_id, joined)
endfunction
vnoremap <leader>s :call SendSelectionToTerminal()<CR>
Popular Terminal Plugins
vim-floaterm
vim-floaterm provides floating-window terminals that appear and disappear on demand. It is excellent for one-off commands like running a linter or checking git status without disrupting your layout.
" Plugin manager entry (vim-plug)
Plug 'voldikss/vim-floaterm'
" Basic usage
let g:floaterm_keymap_toggle = '<F1>'
let g:floaterm_keymap_next = '<F2>'
let g:floaterm_keymap_prev = '<F3>'
let g:floaterm_keymap_new = '<F4>'
" Open a floating terminal
nnoremap <leader>t :FloatermNew<CR>
" Run a command in a floating window, then close
nnoremap <leader>g :FloatermNew --autoClose=1 lazygit<CR>
nnoremap <leader>r :FloatermNew --autoClose=1 ranger<CR>
toggleterm.nvim (Neovim only)
toggleterm.nvim is a Lua-native plugin that supports multiple persistent terminals, floating windows, horizontal and vertical splits, and custom shading. It is the de facto choice for Neovim users.
-- Install with packer.nvim
use {'akinsho/toggleterm.nvim', tag = '*'}
-- Configuration
require("toggleterm").setup{
open_mapping = [[<c-\>]],
direction = 'float',
float_opts = {
border = 'curved',
width = 120,
height = 30,
winblend = 3,
},
shade_terminals = true,
shading_factor = -30,
start_in_insert = true,
persist_size = true,
close_on_exit = true,
}
-- Create a dedicated lazygit terminal
local Terminal = require('toggleterm.terminal').Terminal
local lazygit = Terminal:new({
cmd = 'lazygit',
hidden = true,
direction = 'float',
float_opts = { border = 'double' },
})
function _lazygit_toggle()
lazygit:toggle()
end
vim.api.nvim_set_keymap('n', '<leader>g', '<cmd>lua _lazygit_toggle()<CR>', {noremap = true, silent = true})
neoterm
neoterm focuses on a single persistent REPL-style terminal that you send commands to from anywhere in your editor. It is particularly popular with Ruby and Elixir developers.
Plug 'kassio/neoterm'
let g:neoterm_default_mod = 'vertical'
let g:neoterm_size = 60
let g:neoterm_autoscroll = 1
" Send current line
nnoremap <leader>xl :TREPLSendLine<CR>
" Send visual selection
vnoremap <leader>xv :TREPLSendSelection<CR>
" Run a command in the neoterm
nnoremap <leader>xc :T <C-R><C-W><CR>
" Toggle the neoterm window
nnoremap <leader>xx :Ttoggle<CR>
Integrating with tmux
Even with native terminals, many developers pair Vim with tmux for session persistence and multiplexing. The vim-tmux-navigator plugin creates seamless navigation between Vim splits and tmux panes using the same Ctrl-h/j/k/l keys.
# ~/.tmux.conf
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
| grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|n?vim?x?)(diff)?$'"
bind-key -n 'C-h' if-shell "$is_vim" 'send-keys C-h' 'select-pane -L'
bind-key -n 'C-j' if-shell "$is_vim" 'send-keys C-j' 'select-pane -D'
bind-key -n 'C-k' if-shell "$is_vim" 'send-keys C-k' 'select-pane -U'
bind-key -n 'C-l' if-shell "$is_vim" 'send-keys C-l' 'select-pane -R'
# Vim-style pane resizing
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
" ~/.vimrc or init.vim
Plug 'christoomey/vim-tmux-navigator'
" Optional: enable seamless navigation in terminal mode too
let g:tmux_navigator_no_mappings = 1
nnoremap <silent> <C-h> :TmuxNavigateLeft<CR>
nnoremap <silent> <C-j> :TmuxNavigateDown<CR>
nnoremap <silent> <C-k> :TmuxNavigateUp<CR>
nnoremap <silent> <C-l> :TmuxNavigateRight<CR>
For sending text from Vim to a tmux pane — useful when you want a REPL outside the editor entirely — the vim-slime plugin is the standard tool:
Plug 'jpalardy/vim-slime'
let g:slime_target = 'tmux'
let g:slime_default_config = {"socket_name": "default", "target_pane": "{last}"}
let g:slime_dont_ask_default = 1
" Send current paragraph
xmap <C-c><C-c> <Plug>SlimeRegionSend
nmap <C-c><C-c> <Plug>SlimeParagraphSend
nmap <C-c>v <Plug>SlimeConfig
Best Practices
Keep Terminals Ephemeral and Named
Avoid accumulating dozens of terminal buffers. Give meaningful names to terminals you intend to keep, and close transient ones automatically. In Neovim, you can name a terminal at creation time:
:term
:file build-output
With toggleterm, each terminal can be tagged and recalled by name, which prevents the "which buffer was my test runner?" problem.
Set a Sensible Shell and Startup Command
Configure the shell and any startup commands explicitly so your terminal behaves identically across machines:
" Use a consistent shell
if exists('$SHELL')
set shell=$SHELL
else
set shell=/bin/bash
endif
" Neovim: pass extra options to the shell
set shellcmdflag=-ic
The -i flag forces an interactive shell, which loads your full environment (aliases, functions, prompt). This is essential if your terminal commands depend on shell customizations.
Handle Window Resizing Gracefully
Terminal buffers can misbehave when resized because the underlying PTY must be informed of the new dimensions. Neovim handles this automatically, but in Vim you may need to trigger a resize signal:
augroup TerminalResize
autocmd!
autocmd VimResized * if &buftype ==# 'terminal' | call term_setsize(bufnr(), winheight(0), winwidth(0)) | endif
augroup END
Use Autocmds to Manage Terminal Lifecycle
Automate common terminal behaviors so you do not have to think about them. A robust setup might look like this:
augroup TerminalLifecycle
autocmd!
" Enter insert mode when focusing a terminal
autocmd BufEnter,WinEnter term://* startinsert
" Exit insert mode when leaving a terminal window
autocmd BufLeave term://* stopinsert
" Close the window when the process exits
autocmd TermClose * if !expand('<afile>') =~# 'keep' | bwipeout! | endif
augroup END
Scope Keybindings to Terminal Buffers
Global terminal mappings can collide with plugin mappings. Use buffer-local mappings scoped to terminal buffers to avoid surprises:
function! TerminalMappings()
tnoremap <buffer> <C-c> <C-\><C-n>
tnoremap <buffer> <C-w> <C-\><C-n><C-w>
endfunction
autocmd FileType terminal call TerminalMappings()
Combine with Async Jobs for Non-Interactive Tasks
For build systems and linters where you do not need interactivity, prefer async jobs over terminal buffers. They are lighter and integrate better with quickfix lists:
" Neovim: run make asynchronously and populate quickfix
function! MakeAsync()
let cmd = 'make'
call jobstart(cmd, {
\ 'on_stdout': function('s:handle_output'),
\ 'on_stderr': function('s:handle_output'),
\ 'on_exit': function('s:handle_exit'),
\ })
endfunction
function! s:handle_output(job_id, data, event) abort
" Append output to a log buffer or parse for quickfix
endfunction
nnoremap <leader>m :call MakeAsync()<CR>
For a more polished experience, plugins like vim-dispatch or neomake wrap this pattern with sensible defaults.
Conclusion
Vim's terminal integration has matured from a niche feature into an essential part of a modern editing workflow. Whether you rely on the built-in :terminal command, enhance it with plugins like toggleterm or vim-floaterm, or bridge it with tmux for session persistence, the goal is the same: keep your hands on the keyboard and your mind in the code. Start with the basics — a single terminal split and a comfortable escape mapping — then layer in REPL sending, floating windows, and async jobs as your needs grow. The result is an editing environment where the boundary between editor and shell disappears entirely, letting you move at the speed of thought.