← Back to DevBytes

Vim Terminal Integration: Complete Guide

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles