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; ascope = 'global'one is kept for the whole session instead. - 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. - Jump anywhere —
jump()labels every tab, window, buffer and recent file; type a label to go there. Windows squeezed flat are left out.
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,
-- a table entry may also name cwd and scope: 'global' keeps one
-- terminal for the whole session instead of one per project root,
-- and its prompt takes the current window instead of a float or
-- sidebar
F = { 'opencode', '-c --agent counselor',
cwd = vim.env.HOME, scope = 'global' },
},
-- 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,
-- Milliseconds terminal_command() waits for a terminal key before showing
-- the picker, so a mapping typed straight through never flashes it.
-- Defaults to 500.
picker_delay = 500,
-- A selection is sent with its lines, up to this many; a longer one is
-- sent as its location alone. 0 never sends the lines. Defaults to 40.
selection_max_lines = 40,
-- `@` in a terminal's prompt lists the open files to mention. Defaults to
-- true.
mention = true,
-- jump(): the keys its hint labels are made of, the 'winblend' of the
-- backdrop behind it (false for none), and what a key that is no label
-- opens -- by default Telescope's oldfiles if installed, else vim.ui.select.
jump = { characters = 'asdfgqwertzxcvb', backdrop = 60, fallback = nil },
-- 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() and send_terminal_command() 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 a picked terminal' })
-- 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) |
Pick a terminal by its commands key, spawn it if needed, then open a command buffer for it. A popup lists the keys if none is typed within picker_delay; <Esc> cancels. |
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. |
jump() |
Jump to any tab, window, buffer or recent file by typing the hint label drawn over it; see below. |
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). |
| 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 |
Text appended to the prompt as it is sent. Set for you when the send starts in visual mode, see below. |
win_type |
'sidebar' for a right-hand split, otherwise a floating window. Ignored for a scope = 'global' terminal, whose prompt takes the current window. |
auto_start |
Spawn the terminal if it does not exist yet. The send waits for it to start, see below. |
launch_info |
Override the captured context. Passed to the commands resolver and published for the agent. |
terminal_command also takes key, the commands key to drive instead of
asking for one, and switch, which shows the terminal in the current window
rather than opening a prompt and returns its buffer.
A scope = 'global' terminal is a conversation of its own rather than a helper
to the code beside it, so its prompt does not open in a float or a sidebar: it
takes the window you are in, and the terminal takes it in turn once you send.
<CR>, <C-c> or <Esc> there hand the window back to the buffer it was
showing, so your layout is never touched:
vim.keymap.set({ 'n', 'v' }, '<leader>af', function()
foyer.terminal_command({ key = 'F' })
end, { desc = 'opencode terminal' })Starting from visual mode attaches the selection. The command buffer opens empty
and in insert mode, with the selection's place shown in its title —
src/main.c:30-36 — and the selection joins below the prompt, after a blank
line, as it is sent:
what does this do?
Selected in my editor: /repo/src/main.c, lines 30-36
```c
…the selected lines, as the buffer has them…
```
The lines go with it because a bare src/main.c:30 reads to an agent as a
location, and it reads the lines around one and guesses which was meant; and
the numbers are the buffer's, while the agent reads the file on disk, which
unsaved edits can put out of step — the label says so when they have. The path
is absolute, so it resolves wherever the agent was started. A selection longer
than selection_max_lines is sent as the label alone.
None of it sits in the command buffer, so the line stays yours to edit and to
walk history with, and history remembers the prompt without a selection that
will not be true next time. Type nothing and nothing is sent; a selection alone
asks the agent nothing. A selection you pass yourself is appended as given.
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 selection
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 selection to ride on then, so
it is dropped and said so with vim.notify.
A picker for getting anywhere in two keystrokes, after the
tab switcher
of Surfingkeys. It lays out every tab page with its windows placed as they are
on screen, then the listed buffers not shown in any window, by when you last
used them, then recently opened files, each name just long enough to tell it
from the others. Each one has a hint label over it: type it to go there, with
Shift to open it in a new tab. <BS> starts the label again, <Esc> cancels,
and any other key hands over to jump.fallback, for an old file the picker had
no room for. A window squeezed flat by a full-screen neighbour is left out,
like tab_switch() does; what it shows is still listed among the buffers.
It opens in a bordered window titled Jump / Foyer, with the keys along its
bottom edge, and fits it whatever it holds. Tabs too many to sit side by side
carry on in bands below, each under a line of ─. A name that needs many
directories to tell it apart, as copies of a project on two drives do, keeps
only the one that does and the last two: C:/…/module/init.lua and
D:/…/module/init.lua. A name still too long for the window loses its start
to …, keeping the file name.
vim.keymap.set('n', '<leader>b', require('foyer').jump, { desc = 'Jump anywhere' })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. A
scope = 'global'terminal has one file for the session, since it is the same conversation everywhere; 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; with a menu open, move through it |
@ |
i | Mention an open file, see below |
<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.
Once a prompt is sent, the terminal takes its window, and <C-o> from there
comes back to the prompt emptied, with the selection it carried gone: a
follow-up written there goes alone. What was sent is in history, one <Up>
away.
In the prompt for one of your commands, @ at the start of a word lists the
open files: the one you opened the prompt from first, at your cursor line, then
the rest by when you last used them, each at its last change, then the files
you used recently (v:oldfiles) in the same way. The menu shows ten at a time;
typing after the @ searches all of them by name, so a file further down is
found as you type. While it is open:
| Key | Puts in |
|---|---|
<CR> |
@src/parser.c:120 — the path as the agent resolves it: from the directory it was started in, or absolute for a scope = 'global' one |
<C-l> |
The selection block a visual selection sends — label, then the lines as your buffer has them. The file's last visual selection if it holds that line, else the line alone |
<Esc> |
Nothing: the @ stays as typed |
An @ inside a word (user@host) is just an @, and so is one with no open
file to offer. The prompt for edit_input() has no mentions: it is a shell's
command line. A recent file is read only when it comes into the menu, without
its autocommands — no filetype plugin or language server starts for it — and
let go again when the menu closes. A block put in with <C-l> is left out of
history, which keeps the words around it.
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 switch
does not do — it hands the terminal your window, so a terminal opened that way
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 |
FoyerBackdrop |
bg=#000000 |
The veil dimming the editor behind the terminal picker (needs termguicolors) |
Defined with default = true, so override it with vim.api.nvim_set_hl or from
your colorscheme.
jump() has its own, with a palette for each 'background', given as colors
rather than links so they stay distinct in any colorscheme:
| Group | Dark | Light | Used for |
|---|---|---|---|
FoyerJumpTitle |
#65737e |
#8a8a8a |
section titles |
FoyerJumpName |
#6699cc bold |
#005f87 bold |
buffer, window and file names |
FoyerJumpCurrent |
#99c794 bold |
#008700 bold |
the current buffer or window |
FoyerJumpNoName |
#f99157 italic |
#af5f00 italic |
buffers without a file name |
FoyerJumpLabel |
#1b2b34 on #fac863 bold |
#ffffff on #d75f00 bold |
hint labels |
FoyerJumpLabelTyped |
#1b2b34 on #65737e |
#ffffff on #8a8a8a |
the part of a label already typed |
FoyerJumpBackdrop |
bg #000000 |
bg #000000 |
dims the editor behind it (see jump.backdrop) |
A colorscheme change clears highlight groups, so set yours from a ColorScheme
autocmd or after :colorscheme.
- A terminal foyer has just started gets nothing until it has: a program
starting up has not taken over its terminal yet, and taking it over commonly
throws away what was typed ahead. So a send to it — an
auto_startcommand, or a prompt written faster than the agent came up — is held, with a notice, until the program has drawn its first screen and been quiet for half a second, and then sent. One that draws nothing is taken as started after three seconds; one that never settles is sent to after fifteen, with a warning. Prompts open at once either way, so the writing does not wait. - Its first prompt is also typed without Enter, and the Enter goes only once the
text has shown on its screen. A program can come up on a question of its own
— whether to trust the workspace, a login, an update — and there the Enter
that ends a prompt would be the answer, given for you. So if the text has not
shown within two seconds, foyer sends nothing more and says so: the question
is yours to answer in the terminal, and the prompt is in history, one
<Up>away, to send again afterwards. While it waits, foyer keeps out of terminal mode, so no key of yours reaches the question by accident. - A send containing a newline — a multi-line draft, or any prompt with a
selectionunder 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