在 neovim 中使用 Foyer.nvim 搭配你最喜欢的编程代理 CLI

foyer.nvim:在 Neovim 里,像写代码一样和编码 Agent 对话

玄关(foyer):进门之前,先在这里换鞋、整理衣装,收拾好了再进屋。

foyer.nvim 就是你和终端之间的这道玄关:要发给 shell、调试器或编码 Agent 的每一句话, 都先在一个真正的 Vim 缓冲区里写好、改好,再送进去。


这些场景,你是不是很熟悉?

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


二、选中代码就能问,不用复制粘贴

痛点:想问一段代码,得先复制、粘贴,再手打它来自哪个文件、哪几行。偷懒只给一个 文件:行号? 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,
    },
})

五、随口一问,有个专门的地方

痛点:手头的活干到一半,突然冒出一个跟项目无关的问题: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), 那就读不回任何内容。


积少成多的小细节

foyer.jump() 把标签页、缓冲区和最近的文件排开,每项带一个标签


先说清楚:foyer 不是什么

提前讲明白,省得你白折腾一下午:


两分钟上手

-- 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。

配好之后,试一试:

  1. 在某个 git 仓库里打开一个文件,按 <leader>a,再按 o。
  2. 写一段多行提示词,按 <C-s> 发送。
  3. 选中一个函数,按 <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、在哪个项目里,都适用。 其他把这两个键挪作他用的程序,也可以用同样的方式单独配置。