A Neovim plugin for seamless terminal workflow integration. Smart picker-based terminal selection, flexible text sending from any buffer, and persistent configuration with comprehensive lifecycle control.
Note: ErgoTerm started as a fork of toggleterm.nvim but has grown into something quite different. Big thanks to @akinsho for the solid foundation!
Using lazy.nvim:
{
"waiting-for-dev/ergoterm.nvim",
config = function()
require("ergoterm").setup()
end
}
Using packer.nvim:
use {
"waiting-for-dev/ergoterm.nvim",
config = function()
require("ergoterm").setup()
end
}
Using vim-plug:
Plug 'waiting-for-dev/ergoterm.nvim'
Then add this to your init.lua
or in a lua block:
require("ergoterm").setup()
After installation, you can verify everything is working correctly by running:
:checkhealth ergoterm
Create new terminals with :TermNew
and customize them with options:
:TermNew
:TermNew layout=float name=server dir=~/my-project cmd=iex
:TermNew layout=right auto_scroll=false persist_mode=true
Available options:
layout
- Window layout (default: below
)above
, below
, left
, right
, tab
, float
, window
name
- Terminal name for identification (defaults to the terminal command)dir
- Working directory (default: current directory)/home/user/project
), relative paths (~/my-project
, ./subdir
), "git_dir"
for auto-detected git repository root, or nil
for current directorycmd
- Shell command to run (default: system shell)auto_scroll
- Automatically scroll terminal output to bottom (default: true
)persist_mode
- Remember terminal mode between visits (default: false
)selectable
- Show in selection picker and allow as last focused (default: true
)start_in_insert
- Start terminal in insert mode (default: true
)sticky
- Keep terminals visible in picker even when stopped (requires selectable
to also be true
) (default: false
)close_on_job_exit
- Close terminal window when process exits (default: true
)Choose from active terminals:
:TermSelect " Open picker to select terminal
:TermSelect! " Focus last focused terminal directly
Uses your configured picker (Telescope, fzf-lua, or built-in) to display all available terminals.
Advanced Picker Options
When using fzf-lua or Telescope, additional keybindings are available in the picker:
<Enter>
- Open terminal in previous layout<Ctrl-s>
- Open in horizontal split<Ctrl-v>
- Open in vertical split<Ctrl-t>
- Open in new tab<Ctrl-f>
- Open in floating windowThese keybindings can be customized through the picker.select_actions
and picker.extra_select_actions
configuration options (see Configuration section).
Send text from your buffer to any terminal:
:TermSend " Send current line (opens picker)
:TermSend! " Send to last focused terminal
:'<,'>TermSend " Send visual selection
Available options:
text
- Custom text to send (default: current line or selection)action
- Terminal behavior (default: interactive
)interactive
- Focus terminal after sendingvisible
- Show terminal but keep current focussilent
- Send without opening terminaldecorator
- Text transformation (default: identity
)identity
- Send text as-ismarkdown_code
- Wrap in markdown code blocktrim
- Remove whitespace (default: true
)new_line
- Add newline for execution (default: true
)Modify existing terminal configuration:
:TermUpdate layout=float " Update via picker
:TermUpdate! name=server " Update last focused terminal
Available options:
layout
- Change window layoutname
- Rename terminalauto_scroll
- Auto-scroll behaviorpersist_mode
- Remember terminal mode when revisitingselectable
- Show in selection picker and allow as last focused (can be overridden by universal selection mode)start_in_insert
- Start in insert modeToggle universal selection mode to temporarily override the selectable
setting:
:TermToggleUniversalSelection
When enabled, all terminals become selectable and can be set as last focused, regardless of their individual selectable
setting. This provides a way to access non-selectable terminals through pickers and bang commands when needed.
Here are some useful keymaps to get you started:
local map = vim.keymap.set
local opts = { noremap = true, silent = true }
-- Terminal creation with different layouts
map("n", "<leader>cs", ":TermNew layout=below<CR>", opts) -- Split below
map("n", "<leader>cv", ":TermNew layout=right<CR>", opts) -- Vertical split
map("n", "<leader>cf", ":TermNew layout=float<CR>", opts) -- Floating window
map("n", "<leader>ct", ":TermNew layout=tab<CR>", opts) -- New tab
-- Open terminal picker
map("n", "<leader>cl", ":TermSelect<CR>", opts) -- List and select terminals
-- Send text to last focused terminal
map("n", "<leader>cs", ":TermSend! new_line=false<CR>", opts) -- Send line without newline
map("x", "<leader>cs", ":TermSend! new_line=false<CR>", opts) -- Send selection without newline
-- Send and show output without focusing terminal
map("n", "<leader>cx", ":TermSend! action=visible<CR>", opts) -- Execute in terminal, keep focus
map("x", "<leader>cx", ":TermSend! action=visible<CR>", opts) -- Execute selection in terminal, keep focus
-- Send as markdown code block
map("n", "<leader>cS", ":TermSend! action=visible trim=false decorator=markdown_code<CR>", opts)
map("x", "<leader>cS", ":TermSend! action=visible trim=false decorator=markdown_code<CR>", opts)
Create persistent terminal configurations that survive across Neovim sessions. These terminals are defined once and can be quickly accessed with a single command.
Define terminals in your configuration:
local terms = require("ergoterm.terminal")
-- Create standalone terminals
local lazygit = terms.Terminal:new({
name = "lazygit",
cmd = "lazygit",
layout = "float",
dir = "git_dir",
selectable = false
})
local aider = terms.Terminal:new({
name = "aider",
cmd = "aider",
layout = "right",
dir = "git_dir",
selectable = false
})
-- Map to keybindings for quick access
vim.keymap.set("n", "<leader>gg", function() lazygit:toggle() end, { desc = "Open lazygit" })
vim.keymap.set("n", "<leader>ai", function() aider:toggle() end, { desc = "Open aider" })
.nvim.lua
For project-specific terminal configurations, you can leverage a .nvim.lua
file in your project root. This is especially useful with the sticky
option to keep project terminals available even when stopped:
local term = require("ergoterm.terminal").Terminal
term:new({
name = "Phoenix Server",
cmd = "iex -S mix phx.server",
layout = "right",
sticky = true,
close_on_job_exit = false
})
term:new({
name = "DB Console",
cmd = "psql -U postgres my_database",
layout = "below",
sticky = true
})
With sticky = true
, these terminals remain visible in the picker (:TermSelect
) even when stopped, making it easy to restart your development environment. The terminals are automatically loaded when you open the project in Neovim.
All options default to values from your configuration:
auto_scroll
- Automatically scroll terminal output to bottomcmd
- Command to execute in the terminalclear_env
- Use clean environment for the jobclose_on_job_exit
- Close terminal window when process exitsdir
- Working directory for the terminal~
expansion), "git_dir"
for git repository root, or nil
for current directoryenv
- Environment variables for the job (table of key-value pairs){ PATH = "/custom/path", DEBUG = "1" }
float_opts
- Floating window configuration optionsfloat_winblend
- Transparency level for floating windowslayout
- Default window layout when openingname
- Display name for the terminalon_close
- Called when the terminal window is closed. Receives the terminal instance as its only argumenton_create
- Called when the terminal buffer is first created. Receives the terminal instance as its only argumenton_focus
- Called when the terminal window gains focus. Receives the terminal instance as its only argumenton_job_exit
- Called when the terminal process exits. Receives the terminal instance, job ID, exit code, and event nameon_job_stderr
- Called when the terminal process outputs to stderr. Receives the terminal instance, channel ID, data lines, and stream nameon_job_stdout
- Called when the terminal process outputs to stdout. Receives the terminal instance, channel ID, data lines, and stream nameon_open
- Called when the terminal window is opened. Receives the terminal instance as its only argumenton_start
- Called when the terminal job process starts. Receives the terminal instance as its only argumenton_stop
- Called when the terminal job process stops. Receives the terminal instance as its only argumentpersist_mode
- Remember terminal mode between visitsselectable
- Include terminal in selection picker and allow as last focused (can be overridden by universal selection mode)sticky
- Keep terminal visible in picker even when stopped (requires selectable
to also be true
)size
- Size configuration for different window layouts (table with above
, below
, left
, right
keys)"30%"
) or a number for absolute size{ below = 20, right = "40%" }
- 20 lines high for below splits, 40% width for right splitsstart_in_insert
- Start terminal in insert modeErgoTerm provides a comprehensive Lua API centered around terminal lifecycle management. The design follows a hierarchical pattern where higher-level methods automatically call lower-level ones as needed.
Every terminal follows this lifecycle progression:
Terminal:new()
- Creates terminal instance with configurationTerminal:start()
- Initializes buffer and job processTerminal:open()
- Creates window for the terminalTerminal:focus()
- Brings terminal into active focusEach method is idempotent and will automatically call prerequisite methods:
local terms = require("ergoterm.terminal")
-- Create a terminal instance
local term = terms.Terminal:new({ cmd = "htop", layout = "float" })
-- These methods cascade - focus() will start() and open() if needed
term:focus() -- Automatically calls start() and open() if not already done
-- You can also call methods individually
term:start() -- Just start the job process
term:open() -- Just create the window (calls start() if needed)
start()
- Creates buffer and starts job processopen(layout?)
- Creates window with optional layout overridefocus(layout?)
- Brings terminal into focus, cascades through start/openclose()
- Closes window but keeps job runningstop()
- Terminates job and cleans up buffercleanup()
- Cleans up terminal resourcestoggle(layout?)
- Closes if open, focuses if closedsend(input, opts)
- Sends text to terminal with various behaviorsis_started()
- Has active buffer and jobis_open()
- Has visible windowis_focused()
- Is currently active windowis_stopped()
- Job has been terminatedThe Terminal:send(input, opts)
method provides flexible text input to terminals with various interaction modes:
-- Send current line interactively (focuses terminal)
term:send("single_line")
-- Send custom text without focusing terminal
term:send({"echo hello", "ls -la"}, { action = "visible" })
-- Send visual selection silently (no UI changes)
term:send("visual_selection", { action = "silent" })
-- Send with custom formatting
term:send({"print('hello')"}, { trim = false, decorator = "markdown_code" })
Input types:
string[]
- Array of text lines to send directly"single_line"
- Current line under cursor"visual_lines"
- Current visual line selection "visual_selection"
- Current visual character selectionAction modes:
"interactive"
- Focus terminal after sending (default)"visible"
- Show terminal output without stealing focus"silent"
- Send text without any UI changesFor complete API documentation and advanced usage patterns, see lua/ergoterm/terminal.lua
.
Create custom text transformations for sending code to terminals:
-- Add timestamp to each line
local function timestamp_decorator(text)
local timestamp = os.date("%H:%M:%S")
local result = {}
for _, line in ipairs(text) do
table.insert(result, string.format("[%s] %s", timestamp, line))
end
return result
end
-- Use with Terminal:send()
terminal:send({"echo hello"}, { decorator = timestamp_decorator })
Here's an example showing how to integrate Aider for AI-assisted coding:
local terms = require("ergoterm.terminal")
-- Create persistent Aider terminal
local aider = terms.Terminal:new({
name = "aider",
cmd = "aider",
layout = "right",
dir = "git_dir",
selectable = false
})
local map = vim.keymap.set
local opts = { noremap = true, silent = true }
-- Toggle Aider terminal
map("n", "<leader>ai", function() aider:toggle() end, { desc = "Toggle Aider" })
-- Add current file to Aider session
map("n", "<leader>aa", function()
local file = vim.fn.expand("%:p")
aider:send({ "/add " .. file })
end, opts)
-- Sends current line to Aider session
map("n", "<leader>as", function()
aider:send("single_line")
end, opts)
-- Sends current visual selection to Aider session
map("v", "<leader>as", function()
aider:send("visual_selection", { trim = false })
end, opts)
-- Send code to Aider as markdown (preserves formatting)
map("n", "<leader>aS", function()
aider:send("single_line", { trim = false, decorator = "markdown_code" })
end, opts)
map("v", "<leader>aS", function()
aider:send("visual_selection", { trim = false, decorator = "markdown_code" })
end, opts)
ErgoTerm can be customized through the setup()
function. Here are the defaults:
require("ergoterm").setup({
-- Terminal defaults - applied to all new terminals but overridable per instance
terminal_defaults = {
-- Default shell command
shell = vim.o.shell,
-- Default window layout
layout = "below",
-- Auto-scroll terminal output
auto_scroll = true,
-- Close terminal window when job exits
close_on_job_exit = true,
-- Remember terminal mode between visits
persist_mode = false,
-- Start terminals in insert mode
start_in_insert = true,
-- Show terminals in picker by default
selectable = true,
-- Keep terminals visible in picker even when stopped, provided `selectable` is also true
sticky = false,
-- Floating window options
float_opts = {
title_pos = "left",
relative = "editor",
border = "single",
zindex = 50
},
-- Floating window transparency
float_winblend = 10,
-- Size configuration for different layouts
size = {
below = "50%", -- 50% of screen height
above = "50%", -- 50% of screen height
left = "50%", -- 50% of screen width
right = "50%" -- 50% of screen width
},
-- Clean job environment
clear_env = false,
-- Environment variables for terminal jobs
env = nil, -- Example: { PATH = "/custom/path", DEBUG = "1" }
-- Default callbacks (all no-ops by default)
on_close = function(term) end,
on_create = function(term) end,
on_focus = function(term) end,
on_job_exit = function(term, job_id, exit_code, event_name) end,
on_job_stderr = function(term, channel_id, data_lines, stream_name) end,
on_job_stdout = function(term, channel_id, data_lines, stream_name) end,
on_open = function(term) end,
on_start = function(term) end,
on_stop = function(term) end,
},
-- Picker configuration
picker = {
-- Picker to use for terminal selection
-- Can be "telescope", "fzf-lua", "vim-ui-select", or a custom picker object
-- nil = auto-detect (telescope > fzf-lua > vim.ui.select)
picker = nil,
-- Default actions available in terminal picker
-- These replace the built-in actions entirely
select_actions = {
default = { fn = function(term) term:focus() end, desc = "Open" },
["<C-s>"] = { fn = function(term) term:focus("below") end, desc = "Open in horizontal split" },
["<C-v>"] = { fn = function(term) term:focus("right") end, desc = "Open in vertical split" },
["<C-t>"] = { fn = function(term) term:focus("tab") end, desc = "Open in tab" },
["<C-f>"] = { fn = function(term) term:focus("float") end, desc = "Open in float window" }
},
-- Additional actions to append to select_actions
-- These are merged with select_actions, allowing you to add custom actions
-- without replacing the defaults
extra_select_actions = {}
}
})
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
# Install busted for Lua testing
luarocks install busted
busted
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.