在 neovim 中使用 Foyer.nvim 搭配你最喜欢的编程代理 CLI
foyer.nvim:在 Neovim 里,像写代码一样和编码 Agent 对话
玄关(foyer):进门之前,先在这里换鞋、整理衣装,收拾好了再进屋。
foyer.nvim 就是你和终端之间的这道玄关:要发给 shell、调试器或编码 Agent 的每一句话, 都先在一个真正的 Vim 缓冲区里写好、改好,再送进去。
这些场景,你是不是很熟悉?
- 给 Claude Code、opencode 或 pi 写一段三四段的需求说明,却只能挤在 TUI 的单行输入框里。
想用
ciw,没有;想撤销,没有;只有一个退格键。 - 粘贴一段多行提示词,结果 Agent 只收到第一行就开始干活了,后面的话还没说完。
- 想问”这个函数为什么有问题”,先复制四十行代码粘进去,再补一句”在 src/parser.c 第 120 行左右……”。
- 跟 Agent 说”帮我修一下这里”,它反问你:哪个文件?
- 三个项目各开了一个 Agent,切来切去,最后把 A 项目的问题发给了 B 项目的那个。
- 突然想问个跟项目无关的问题,要么把项目 Agent 的对话搅乱,要么切到浏览器里另开一个聊天页面。
- 十分钟前交给 Agent 一个大任务,想看看进度,得先在一堆缓冲区里翻出它的终端。
- 在 shell 里敲了一长串
docker run …,发现四十个字符之前有个错字,只好按住左方向键一路挪回去。 - “跑一下测试,总结失败原因”这句话,昨天打了一遍,前天也打了一遍。
foyer.nvim 就是为这些问题写的,而且你不用改变任何现有习惯。
一、在 Vim 里写提示词,一次完整送达
痛点:现在很多思考都发生在和 Agent 的对话里,可它们的输入框,偏偏是你一天里用到的最差的编辑器。
foyer 的做法:先给每个常用的 Agent 分配一个字母:
require('foyer').setup({
commands = {
c = { 'claude', '-c' },
o = { 'opencode', '-c' },
},
})
写成字符串就是整条命令;写成表就是”程序 + 参数”,程序名会显示在提示窗口的标题上。
-c 别漏掉。 foyer 会在项目根目录启动 Agent,而这两个 CLI 都能用 -c 接上
它在当前目录里的上一次对话。
所以哪怕重启了 Neovim,回到项目里按一下键,你接上的还是这个项目原来那段对话,不用从头交代背景;
别的项目也各自接上各自的。想每次都开新会话,去掉参数就行。
再映射一个键:
-- <leader>a,再按 Agent 对应的字母
vim.keymap.set({ 'n', 'v' }, '<leader>a', function()
require('foyer').terminal_command({ win_type = 'sidebar' })
end)
之后,<leader>a c 找 Claude Code,<leader>a o 找 opencode,换个字母就是换个 Agent。它们事先都不用开着,第一次选中时 foyer 会帮你启动。
弹出来的是一个真正的缓冲区(浮窗或侧边栏,随你选)。写多长、分多少行都行,
你熟悉的移动、文本对象、宏和插件统统可用。
插入模式按 <C-s>,或普通模式按 <CR>,整段内容作为一条提示词送到 Agent。
多行内容会用 bracketed paste 包起来发送,Agent 会把换行当成换行,而不是当成回车提交。
发送之后,这个窗口直接变成 Agent 的终端,你就在提问的地方看它回答;按 <CR> 收起。
Agent 还在启动时发出的提示词,会等它准备好再送达。如果 Agent 启动时先问了你一个问题(比如要不要信任这个文件夹),
foyer 不会替你回答:它会提醒你,提示词按一下 <Up> 就能找回来。
记不住字母?按完 <leader>a 稍等一下,会弹出一个列表,列出你配置的所有 Agent。
二、选中代码就能问,不用复制粘贴
痛点:想问一段代码,得先复制、粘贴,再手打它来自哪个文件、哪几行。偷懒只给一个 文件:行号?
Agent 往往会读那一行附近的一大段,然后回答的却是旁边那几行;要是你还有没保存的修改,磁盘上的行号早就对不上了。
foyer 的做法:可视模式选中那几行,<leader>a,选 Agent,直接写问题,比如
“错误分支这里为什么会内存泄漏?”。发送时,foyer 会把选区附在问题下面,并且明明白白说清楚这是什么:
错误分支这里为什么会内存泄漏?
Selected in my editor: /repo/src/parser.c, lines 118-131
```c
……你选中的那几行……
```
Agent 拿到的就是你指的那几行,和你缓冲区里的一模一样;完整路径也在,想看上下文可以自己去读。
缓冲区有未保存的修改时,标签里会注明。超过 40 行的选区只发位置,免得手一滑把整个文件贴过去。
发送前,窗口标题上会显示 src/parser.c:118-131,心里有数。什么都不写就不会发送:只有选区,等于什么都没问。
问题写到一半,才想起要指一下另一个文件里的代码?直接输入 @:已打开的文件和最近用过的文件会弹出来,每个都停在你最后修改的那一行。
按 <CR> 插入 @src/parser.c:120;按 <C-l> 则插入整块代码,和事先选中那几行再开始写,效果一样。
三、说”改这里”,Agent 就知道”这里”是哪里
痛点:Agent 跑在终端里,看不到你的编辑器。每次提问都得先交代一遍”我在哪个文件、哪一行”。
foyer 的做法:foyer 启动的每个终端,环境变量里都有一个 $FOYER_CONTEXT,
指向一个小小的 JSON 文件,记录着你所在的文件、光标行和选区。每次你通过 foyer 找这个 Agent,它都会刷新:
{"captured_at":"…","launch_info":{"project_root":"/repo",
"current_file":"src/parser.c","current_line":118,
"start_line":118,"end_line":131}}
仓库里自带一个 Agent Skill,链接到 opencode、Claude Code 等工具的 skill 目录即可。
平时它只占模型上下文里一行描述,只有当你说”这个文件”“这里”“我选中的部分”时才会加载,
然后 Agent 自己去读这个文件,而不是反过来问你。不支持 skill 的工具,在 AGENTS.md 或 CLAUDE.md
里加三行规则,效果一样。
这里没有任何”偷看屏幕”或猜测:Agent 看到的只是你自己带过去的上下文,还附带时间戳, 它能看出那句”改这里”是二十分钟前说的,必要时先跟你确认。
四、一个字母一个 Agent,每个项目各管各的
痛点:Agent 的对话是围绕某个仓库展开的。每个项目开一个,终端缓冲区越堆越多,
分不清哪段对话属于哪个项目;想开对的那个,又得 :term、cd、重新启动 Agent。
foyer 的做法:第一节配好的字母天然就是按项目隔离的,不需要任何额外配置。
在此基础上,每一项还可以指定启动目录(cwd),或者写成函数,按项目决定用哪个 Agent:
require('foyer').setup({
commands = {
c = { 'claude', '-c' },
o = { 'opencode', '-c' },
-- 按项目根目录决定:公司项目用 Claude Code,其他用 opencode
a = function(root)
if root:find('/work/', 1, true) then return { 'claude', '-c' } end
return { 'opencode', '-c' }
end,
},
})
- 按项目隔离:在
~/work/api按o和在~/work/frontend按o,是两个独立的 Agent, 各自在自己的仓库里启动,之后一直复用。切换项目,同一个键自动对上正确的那个。 - 熟手不打扰,新手有提示:连着敲
<leader>a o,什么都不会弹出来;稍一停顿,就会弹出列表, 已经在运行的标●,全局的(见第五节)标出global,统一排在最下面。 - Agent 可以随项目变化:写成函数后,
a在不同仓库里代表不同的 Agent,手指只需要记住一个键。
五、随口一问,有个专门的地方
痛点:手头的活干到一半,突然冒出一个跟项目无关的问题:epoll 和 kqueue 有什么区别?
某个工具的参数是什么意思?这封邮件怎么写比较得体?问项目里的 Agent,会把一段本该围绕这个仓库的对话搅乱;
切到浏览器里开个聊天页面,又离开了编辑器,思路也断了。
foyer 的做法:单独留一个 Agent 专门聊这些”题外话”,和项目里的 Agent 分开:
-- 整个会话只有一个,在哪都能叫出来;在 $HOME 启动,所以重启后 -c 接上的还是同一段闲聊
F = { 'opencode', '-c --agent counselor',
cwd = vim.env.HOME, scope = 'global' },
scope = 'global' 表示它只有一个,不按项目区分,所以不管在哪个仓库,<leader>a F 打开的都是同一段对话。
它是一段独立的对话,不是给眼前代码帮忙的助手,所以不会在代码旁边弹出浮窗或侧边栏,而是直接占用你当前的窗口,
就像翻到另一页:写好问题发出去,回答就出现在原地;按 <CR> 翻回刚才在看的文件,窗口布局原封不动。
项目里的 Agent 完全不知道你中途走开过。
如果题外话正好是关于眼前某段代码的,也没问题:先选中那几行,再按 <leader>a F,
和第二节一样会自动附上选区。标签里是完整的绝对路径,所以不属于任何项目的闲聊 Agent,
也照样能找到那个文件、看更多上下文。窗口标题会告诉你这次问的是谁:闲聊 Agent 显示 opencode · global,
api 项目里的 Agent 则显示 opencode · api。
六、一个键:提问,也查看进度
痛点:交给 Agent 一个大任务,你回去写自己的代码。过几分钟想看看它干到哪了, 就得在一堆缓冲区里翻出它的终端,看完再找路回来。
foyer 的做法:按你提问时用的同一个 <leader>a o 就行。
如果 Agent 还在输出,foyer 给你看的是它的终端(直接滚到最新输出),而不是提示窗口;
如果它已经安静下来,你得到的就是一个新的提示窗口。同一个键,根据 Agent 当下的状态做对的事。
看完了?<CR> 收起。想趁它干活时先把下一个问题写好?<C-o> 切到提示窗口。
七、好用的提示词,留下来
痛点:好的提示词总是一遍遍重打,终端自带的历史又粗糙,重启就没了。
foyer 的做法:每个终端都有自己的历史记录,跨 Neovim 会话保留。<C-p>/<C-n> 翻看;
先敲几个字再翻,就只显示包含这些字的条目,匹配部分高亮。zsh 的历史和 Agent 的历史互不干扰。
八、救回已经敲了一半的那一行 (实验性)
痛点:一条命令敲了四十个字符,或者 Agent 输入框里的提示词写到一半,发现开头写错了。 Readline 的移动方式不是 Vim 的,Agent 的输入框能用的就更少了。
foyer 的做法:在任意终端缓冲区里按 <C-x>e,shell、REPL、Agent 都可以,哪怕不是 foyer 启动的。
输入行上已有的内容会被提取到一个浮窗里,光标停在末尾,用 Vim 改好再送回去,接着在原处编辑。
vim.api.nvim_create_autocmd('TermOpen', {
callback = function(ev)
vim.keymap.set('t', '<C-x>e',
'<C-\\><C-n><Cmd>lua require("foyer").edit_input()<CR>',
{ buffer = ev.buf })
end,
})
为什么是实验性的? 终端并没有”把你的输入行告诉我”这种接口,foyer 只能推算: 发送程序的清空输入键(
Ctrl-U),观察光标落在哪里。普通 shell 和支持Ctrl-U清空输入的 Agent 上效果很好; 提示符比较特殊或响应很慢时可能会出错;如果某个程序的清空输入键被配置成''(见”两分钟上手”里的send_prefix), 那就读不回任何内容。
积少成多的小细节
edit_selection():把任意可视选区放进浮动草稿缓冲区单独编辑,改完写回原处。 适合单独打磨一段文字或一个复杂的正则。- 从终端复制更干净:终端会用空格把每行补满窗口宽度,yank 时这些行尾空白会被自动去掉。
[p/]p:在终端的滚动历史里,跳到上一个 / 下一个 shell 提示符。x:终端窗口在全屏和原尺寸之间切换。tab_switch():跳到下一个值得看的窗口,自动跳过被全屏窗口挤成一条缝的那些。jump():所有标签页、窗口、缓冲区和最近的文件按屏幕上的位置排开,各带一个标签,输入标签就过去, 按住 Shift 则在新标签页打开。名字只取刚好能区分的长度,被挤扁的窗口不列出。
先说清楚:foyer 不是什么
提前讲明白,省得你白折腾一下午:
- 它不是 Agent,也不是 AI 客户端。 它通过终端和你已经在用的 CLI 打交道。Agent 你自己选。
- 它不会偷看你的屏幕。 Agent 能看到的上下文,只有你自己带过去的那些,而且只发布给 foyer 启动的终端。
- 读回半截输入是实验性功能。 原理见第八节,普通 shell 上很可靠,特殊情况可能出错。
- 有些东西暂时是写死的。 foyer 窗口里的快捷键、几个时间参数,以及
edit_selection()用到的寄存器(a),目前都还不能配置。
两分钟上手
-- lazy.nvim
{
'brookhong/foyer.nvim',
opts = {
commands = {
c = { 'claude', '-c' },
o = { 'opencode', '-c' },
},
},
keys = {
{ '<leader>a', function() require('foyer').terminal_command({ win_type = 'sidebar' }) end,
mode = { 'n', 'v' }, desc = 'foyer: prompt a terminal' },
},
}
再把仓库里的 skill 链接到你的 Agent 的 skill 目录,它就能自己弄明白”这个文件”指的是哪个。 命令见 README。
配好之后,试一试:
- 在某个 git 仓库里打开一个文件,按
<leader>a,再按o。 - 写一段多行提示词,按
<C-s>发送。 - 选中一个函数,按
<leader>a c,这次换 Claude Code 来回答,问它“这段代码是做什么的?”。
用 pi 的话:每次发送前,foyer 会先按 Ctrl-U 清空 Agent 的输入行,再按 Ctrl-L 清屏。
而 pi 的 Ctrl-L 是打开模型选择器,会把你的提示词”吞”掉,所以让 foyer 只发 Ctrl-U:
opts = {
commands = { p = 'pi' },
send_prefix = { pi = { vim.keycode('<C-u>'), '' } }, -- { 清空输入, 清屏 }
},
send_prefix 按程序名生效,不管你用哪个字母启动 pi、在哪个项目里,都适用。
其他把这两个键挪作他用的程序,也可以用同样的方式单独配置。