Edit what you are about to type into a Neovim terminal in a real buffer first.
Terminal command lines are a hostile editing environment: no dw, no visual
block, no undo, and pasting a multi-line prompt into an LLM CLI is a gamble.
This plugin puts a normal, modifiable buffer in front of the terminal. You edit
with the full power of Vim, press <CR>, and the text is sent to the shell.
A foyer is the room you pass through on the way in. Nothing reaches the shell until you are done with it here.
- Edit the current command line —
edit_input()lifts whatever you have half-typed at the prompt into a floating buffer, then writes it back. - Project-scoped named terminals — one keypress picks a terminal by its
first character (
z→ zsh,g→ gdb,a→ your coding agent). Terminals are keyed by project root, spawned on demand, and reused. - Per-terminal command history — persisted across sessions in
stdpath('cache')/foyer.json, scoped per terminal name.<C-p>/<C-n>walk it, and typing first filters the history by substring, with the match highlighted. - Multi-line prompts — write a paragraph across many lines, send it as one payload. Made for agent CLIs, not just shell one-liners.
- Agent-readable context — the file, line and selection you attached when
you opened the prompt are published as a small JSON file, one per terminal,
named by
$FOYER_CONTEXT. A coding agent reads it when it needs it, instead of you pasting the same thing into every message. - Prompt navigation —
[p/]pjump between shell prompts in the terminal scrollback. - Editable visual selection —
edit_selection()opens any visual selection in a floating scratch buffer and writes the result back over the selection.
Neovim 0.10+. No other dependencies.
With lazy.nvim:
{
'brookhong/foyer.nvim',
opts = {
commands = {
z = 'zsh',
g = 'gdb',
},
},
}opts is passed straight to setup(). Calling setup() yourself works too.
require('foyer').setup({
-- Terminals, keyed by the single character that selects them.
commands = {
-- string: the command to run
z = 'zsh',
-- table: { command, arguments } -- the first element is also the
-- display name shown in the window title
a = { 'opencode', '-c' },
-- function(project_root, launch_info) -> string | table
-- resolved every time, so the terminal can depend on the project
e = function(project_root, launch_info)
if project_root:find('kernel', 1, true) then
return { 'bash', '--norc' }
end
return 'zsh'
end,
},
-- Markers used to find the project root that scopes terminals.
-- Defaults to vim.g.root_markers, then { '.git' }.
root_markers = { '.git', '.clangd' },
-- Wrap a multi-line send in bracketed paste. Turn it off for a terminal
-- program that does not understand the escapes. Defaults to true.
bracketed_paste = true,
-- Keys that clear the receiver's input line and its screen, keyed by the
-- program receiving them. Defaults to { Ctrl-U, Ctrl-L }. A program that
-- binds either key to something of its own needs its own -- see below.
send_prefix = {
pi = { vim.keycode('<C-u>'), '' },
},
})Before the text, foyer clears whatever the receiver already has on its input
line, and clears the screen with it: Ctrl-U then Ctrl-L, or { ESC, '' }
for cmd.exe. Those are a shell's bindings, and a program with its own UI is
free to bind them to anything — pi erases the line on Ctrl-U as a shell
does, but opens its model picker on Ctrl-L, which then swallows the prompt
arriving behind it. So name the keys the program does understand, and '' for
the rest:
send_prefix = {
-- { clear input, clear screen }
pi = { vim.keycode('<C-u>'), '' },
claude = { '', '' },
}A send is prefixed with both, the input key first. The input key alone is also
what edit_input() sends to find the command line, so a program given ''
there leaves it nothing to read — reading back a prompt is a shell feature
either way.
The key names the program, not the commands key that selected it: one
such key stands for whatever a project wants behind it, so a is free to be a
different agent in every repo. The whole command line as spawned, the program
alone and its basename are all tried, so pi --yolo, /opt/pi/bin/pi and pi
are all usable keys — the basename covers every path and argument spelling at
once. A program with no key of its own keeps the defaults.
Captured for you: terminal_command(), send_terminal_command() and
open_terminal() read the current file and line, plus start_line/end_line
when you are in visual mode, before anything in the call can change them. The
table reaches the commands resolver untouched and is published verbatim, so a
key of your own travels all the way to the agent:
foyer.terminal_command({
launch_info = {
project_root = '/repo',
current_file = 'src/main.c',
ticket = 'JIRA-123',
},
})current_file is relative to project_root, which is captured alongside it —
src/main.c, the name the project uses, not a path that only means something on
your machine. The two always travel together: when there is no project root to
name the file against, the project_root key is absent and current_file is
absolute instead.
Passing one yourself opts out of the capture entirely. In a buffer with no file
— a terminal, a scratch buffer — the captured table is empty, and an empty table
publishes nothing, so a prompt started from inside the agent's own terminal
leaves the last real context standing instead of overwriting it with term://….
The plugin defines no global keymaps; wire up only what you want.
local foyer = require('foyer')
-- Pick a terminal by its first character and compose a command for it.
-- From visual mode the prompt names the selected range with `file:start-end`,
-- appended as you send; either way the file, line and range are captured for
-- the agent.
vim.keymap.set({ 'n', 'v' }, '<leader>a', function()
foyer.terminal_command({ win_type = 'sidebar' })
end, { desc = 'Send command to terminal by first char' })
-- Edit the half-typed command line of the terminal you are in.
vim.api.nvim_create_autocmd('TermOpen', {
pattern = '*',
callback = function(ev)
vim.api.nvim_buf_set_keymap(ev.buf, 't', '<C-x>e',
'<C-\\><C-n>:lua require("foyer").edit_input()<CR>',
{ noremap = true, silent = true, desc = 'Edit terminal command line' })
end,
})
-- Edit the current visual selection in a floating buffer.
vim.keymap.set('v', 'E', function()
foyer.edit_selection()
end, { desc = 'Edit selection in floating window' })| Function | Description |
|---|---|
setup(opts) |
Merge opts into the configuration. |
terminal_command(opts) |
Read one character, resolve it against commands, spawn the terminal if needed, then open a command buffer for it. |
send_terminal_command(term_key, opts) |
Drive a specific terminal directly. opts.command sends non-interactively; omit it to open the command buffer. opts.auto_start = true spawns the terminal if it is missing. |
edit_input() |
Lift the terminal's current command line into a floating buffer. Must be called from a terminal buffer. |
edit_selection() |
Open the current visual selection in a floating buffer and write the result back. Must be called from visual mode. |
toggle_full_size() |
Toggle the current window between full screen and the size it had before: height for a float, width for a split. A neighbouring split keeps its minimum width, so a split may stop a few columns short. |
tab_switch() |
Move to the next window worth looking at: CTRL-W w order, wrapping around, skipping floats and windows a full-screen neighbour has squeezed to 'winminwidth'/'winminheight'. With nothing left to step into, switches to the alternate buffer (CTRL-^) instead. A maximised diff pane restores the squeezed pane and steps into it. |
is_idle(buf, threshold_ms) |
Whether buf has produced no output for threshold_ms (default 1000). |
open_terminal(name, command, opts) |
A terminal that is not project-scoped: one reused buffer per name, spawned in opts.cwd (default $HOME), context published and keymaps applied. For an agent you keep one conversation with wherever you are. |
| Key | Meaning |
|---|---|
command |
Send this text instead of opening a command buffer. |
interactive |
Force the command buffer open even when command is set; command then seeds the buffer. |
selection |
A reference appended to the prompt as it is sent. Set for you when the send starts in visual mode. |
win_type |
'sidebar' for a right-hand split, otherwise a floating window. |
auto_start |
Spawn the terminal if it does not exist yet. |
launch_info |
Override the captured context. Passed to the commands resolver and published for the agent. |
A selection never sits in the command buffer: that opens empty and in insert
mode, with the reference shown in its title, and the reference joins below the
prompt, after a blank line, as it is sent. So the line stays yours to edit and
to walk history with, and history remembers the prompt without a file position
that will not be true next time. Starting from visual mode sets it to
current_file:start_line-end_line — src/main.c:30-36, or src/main.c:30 for
a single line. Type nothing and nothing is sent; a bare range asks the agent
nothing.
A sidebar outlives the send that opened it, so sending again aims at the window
already there rather than splitting another one: a draft still in it is yours
and stays — only an empty one takes a seeded command — with the new reference
replacing whatever the title advertised before, and the terminal left in there
by the last send gives the window back to a fresh prompt. A floating window is
transient and is simply opened again.
A terminal still producing output keeps the window instead of giving it to a
prompt (see is_idle); there is no prompt for a reference to ride on then, so
it is dropped and said so with vim.notify.
On every send, the launch_info you attached is written as JSON to a file — one
per terminal — and every terminal this plugin spawns gets $FOYER_CONTEXT in
its environment naming that file:
$ cat "$FOYER_CONTEXT"
{"captured_at":"2026-09-22T15:59:07+0800","captured_unix":1790063947,
"launch_info":{"project_root":"/repo","current_file":"src/main.c",
"current_line":88,"start_line":30,"end_line":36}}That is the whole interface — a file an agent reads when it needs it. What remains is telling the agent it is there.
skills/foyer-editor-context/SKILL.md in this repo does that as an
agent skill: a one-line description stays in
the model's context, and the body is loaded only when a request actually points at
your editor. Symlink it into whichever skill directory your CLI scans, keeping
the directory name:
# from a clone of this repository, or from the plugin's install directory:
# ~/.local/share/nvim/lazy/foyer.nvim with lazy.nvim
src="$PWD/skills/foyer-editor-context/SKILL.md"
for dir in ~/.config/opencode/skills ~/.kiro/skills ~/.claude/skills; do
[ -d "$dir" ] || continue
mkdir -p "$dir/foyer-editor-context"
ln -sf "$src" "$dir/foyer-editor-context/SKILL.md"
doneFor a CLI with no skill mechanism, the same thing works as an always-loaded rule
(AGENTS.md, CLAUDE.md, ~/.kiro/steering/*.md), at the cost of those tokens
on every request:
When
$FOYER_CONTEXTis set it names a JSON file holding the file, line and selection the user had when they wrote their prompt. Read it withcat "$FOYER_CONTEXT"before acting on "this file", "here", "this line" or "my selection". If it is unset, ask which file is meant.
It is the attached context, not a fresh look at your buffers: the file you meant is the one you were in when you started writing, not the terminal you are typing in now. Each terminal has its own file, so an agent in one project never reads a path you attached for another.
Notes:
- Only terminals this plugin opened get a context.
open_terminal()is the way to get one outside the project-scopedcommands; a terminal you spawn by hand is not adopted at all — no context, and none of the terminal-buffer mappings. - The snapshot ages.
captured_atandcaptured_unixare in the file so a model can notice that "fix this function" was said twenty minutes ago. - The files live in
stdpath('cache')/foyer/, one per terminal, and are removed onVimLeavePre. A terminal closed and reopened keeps the same file, so a long session does not litter the directory. - A send with no
launch_infoleaves the previous context in place rather than blanking it, so:Gb-style automation does not erase what you attached. - Publishing never breaks a send: a
launch_infovalue that is not JSON-serializable, or an unwritable cache directory, warns and sends the command anyway.
These are buffer-local and currently not configurable.
A buffer with something to say already — a lifted command line, a seeded
command, a prompt that is going to carry a selection — starts in insert mode
at the end of its text. One opened with nothing to say starts in normal mode,
where <Up> walks the history.
| Key | Mode | Action |
|---|---|---|
<CR> |
n | Send the buffer to the terminal |
<C-s> |
i | Send the buffer to the terminal |
<C-p> / <Up> |
n | Previous history entry |
<C-n> / <Down> |
n | Next history entry |
<C-p> / <C-n> |
i | Previous / next history entry |
<C-x><C-p> / <C-x><C-n> |
i | Ordinary insert-mode completion |
<Esc> |
n | Close the window if the buffer is empty |
<C-c> |
n | Close the window, discarding the draft |
<C-x><C-x> |
n | Toggle the window between its previous size and full screen |
History is filtered by what you have typed: with a draft in the buffer, only the entries containing it are walked, and the match is highlighted.
edit_selection()'s window is text you are editing rather than a prompt: it
keeps <CR>, <C-s>, <Esc>, <C-c> and <C-x><C-x>, has no history, and
always starts in normal mode.
The last three are on every terminal this plugin opens. The rest are attached
when a terminal is shown in one of this plugin's own windows, which is something
open_terminal() does not do — so a terminal from open_terminal() has only
those three.
| Key | Mode | Action |
|---|---|---|
[p / ]p |
n | Jump to the previous / next shell prompt |
<C-r> |
n | cd the terminal to the project root and re-learn the prompt |
x |
n | Toggle the window between its previous size and full screen |
<CR> / <C-c> / <Esc> |
n | Close the terminal window |
<C-x>i |
t | Paste register a at the prompt |
<C-x>w |
t | Switch window, or tab when there is only one window |
<C-x>c |
t | Close the terminal buffer |
Yanking in a terminal buffer strips trailing whitespace from every line — a terminal pads its lines to the window width, and that padding is never what you wanted to copy.
| Group | Default | Used for |
|---|---|---|
FoyerMatch |
bg=#333333 fg=#ff7700 bold |
The filter text matched in the history palette |
Defined with default = true, so override it with vim.api.nvim_set_hl or from
your colorscheme.
- A send containing a newline — a multi-line draft, or any prompt with a
selectionreference under it — is wrapped in bracketed paste, so the receiver inserts the newline instead of reading it as Enter and running the first line alone. What the receiver then does with a multi-line input is its own business: an agent takes it as one prompt, a shell lands it on one command line and still runs each of its lines in turn. A terminal program that does not understand bracketed paste will show the[200~escapes as text — turnbracketed_pasteoff for it. Single-line sends are never wrapped. edit_selection()goes through registera: the selection is yanked into it on the way in, and the edited text on the way back out.<C-x>iin a terminal pastes registera, whatever put it there.- The plugin reads the command line by sending the erase sequence (
Ctrl-U, orESCforcmd.exe, or whateversend_prefixnames) and watching where the cursor lands, with short timers to let the shell respond. This is inherently heuristic: a shell with an unusual prompt or a very slow response can confuse it. - Timings, keymaps, and the register are hardcoded.
MIT