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!
- π Flexible terminal creation - Spawn terminals with your preferred layout (split, float, tab, etc.)
- π― Smart terminal selection - Pick from active terminals using your favorite picker (Telescope, fzf-lua, or built-in)
- π€ Seamless text sending - Send code, commands, or selections directly to any terminal
- πΎ Saved terminals - Reuse terminal configurations across Neovim sessions
- β‘ Powerful API - Extensive Lua API for custom workflows and integrations
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)- Accepts absolute paths (
/home/user/project
), relative paths (~/my-project
,./subdir
),"git_dir"
for auto-detected git repository root, ornil
for current directory
- Accepts absolute paths (
cmd
- 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
)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 window
These 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 terminal
decorator
- Text transformation (default:identity
)identity
- Send text as-ismarkdown_code
- Wrap in markdown code block
trim
- 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 mode
Toggle 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" })
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- Accepts absolute paths, relative paths (with
~
expansion),"git_dir"
for git repository root, ornil
for current directory
- Accepts absolute paths, relative paths (with
env
- Environment variables for the job (table of key-value pairs)- Example:
{ PATH = "/custom/path", DEBUG = "1" }
- Example:
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)size
- Size configuration for different window layouts (table withabove
,below
,left
,right
keys)- Each direction accepts either a string with percentage (e.g.,
"30%"
) or a number for absolute size - Example:
{ below = 20, right = "40%" }
- 20 lines high for below splits, 40% width for right splits
- Each direction accepts either a string with percentage (e.g.,
start_in_insert
- Start terminal in insert mode
ErgoTerm 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:
- Create -
Terminal:new()
- Creates terminal instance with configuration - Start -
Terminal:start()
- Initializes buffer and job process - Open -
Terminal:open()
- Creates window for the terminal - Focus -
Terminal:focus()
- Brings terminal into active focus
Each 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 bufferdelete()
- Permanently removes terminal from sessiontoggle(layout?)
- Closes if open, focuses if closedsend(input, opts)
- Sends text to terminal with various behaviors
is_started()
- Has active buffer and jobis_open()
- Has visible windowis_focused()
- Is currently active windowis_stopped()
- Job has been terminated
The 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 selection
Action modes:
"interactive"
- Focus terminal after sending (default)"visible"
- Show terminal output without stealing focus"silent"
- Send text without any UI changes
For 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,
-- 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.
- Clone the repository
- Install dependencies for testing:
# Install busted for Lua testing luarocks install busted
- Run tests:
busted
- Follow existing code style and conventions
- Add tests for new features
- Update documentation for user-facing changes
- Keep commits focused and write clear commit messages
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.
- Thanks to @akinsho for toggleterm.nvim, which provided the foundation for this project
- The Neovim community for their excellent plugin ecosystem and documentation