nvim + tmux:我的终端开发环境
tmux 管会话和进程,nvim 管文件和代码。讲我的 nvim 配置和日常按键,再讲两者怎么配合:真彩色、Esc 延迟、剪贴板、pane 与窗口的分工,以及一条命令开好整个项目。文中有一个演示按键会被哪一层接住的小工具。
上一篇讲了 tmux。这篇讲跑在 tmux 里面的编辑器:nvim。我的分工很简单:
| 谁 | 负责 | 例子 |
|---|---|---|
| tmux | 会话、窗口、pane,以及要一直跑着的进程 | 一个项目一个会话;dev server、测试、日志各占一个 pane |
| nvim | 文件和代码 | 找文件、全文搜索、跳定义、重命名、格式化、看 git 改动 |
关掉终端软件,tmux 里的 nvim 和服务都还在;第二天 tmux attach 回来,光标还停在昨天的位置。
本文以 nvim 0.11.5、tmux 3.6 为准,按键以我的 ~/.config/nvim 和 ~/.tmux.conf 为准,两份配置都在文末附录。
我的 nvim 配置
目录结构
配置用 Lua 写,按职责拆成几个文件,插件由 lazy.nvim 管理,第一次启动时自动安装:
init.lua # 设置 leader,按顺序加载下面的模块lazy-lock.json # 插件版本锁定,换机器时装出同一套版本lua/config/ options.lua # 编辑器选项 autocmds.lua # 自动命令:复制高亮、按语言设缩进 keymaps.lua # 全局快捷键 lazy.lua # 安装并启动 lazy.nvim lsp.lua # LSP:用 nvim 0.11 自带的 vim.lsp.configlua/plugins/ editor.lua # 主题、文件浏览、状态栏、git 标记 lsp.lua # 补全、代码片段、格式化 search.lua # 模糊搜索、语法树、lazygit基础选项
options.lua 里值得一提的几项:
| 选项 | 值 | 效果 |
|---|---|---|
number + relativenumber |
开 | 当前行显示行号,其余行显示相对距离,配合 5j 这类跳转 |
clipboard |
unnamedplus |
复制粘贴直接走系统剪贴板 |
splitright / splitbelow |
开 | 新窗口开在右边 / 下边,和 tmux 分屏的方向习惯一致 |
timeoutlen |
300 |
键序列有歧义时最多等 300 毫秒,下文有演示 |
scrolloff |
8 |
光标上下始终留 8 行 |
ignorecase + smartcase |
开 | 搜索默认不区分大小写,输入了大写才区分 |
wrap |
关 | 长行不折行 |
| 缩进 | 2 个空格 | Go 改成 tab、宽 4;Rust 改成 4 个空格(autocmds.lua) |
另外 options.lua 把 ~/go/bin、nvm 和 bun 的 bin 目录加进了 nvim 的 PATH,这样从任何地方启动 nvim 都能找到 gopls、prettierd 这些工具。
插件一览
| 插件 | 作用 | 我怎么用 |
|---|---|---|
| tokyonight.nvim | 主题 | 用 tokyonight-night |
| oil.nvim | 把目录当成一个文本缓冲区来编辑 | - 打开上级目录,改文件名就像改文字 |
| telescope.nvim | 模糊搜索 | 找文件、全文搜索、切换缓冲区 |
| nvim-treesitter | 语法树高亮 | 配置了 Go、TS、Rust 等文件类型,但目前没真正生效,见下面的坑 |
| nvim-cmp + LuaSnip | 补全和代码片段 | Tab / Shift-Tab 选,Enter 确认 |
| conform.nvim | 格式化 | 保存时自动格式化 |
| gitsigns.nvim | 行号旁显示 git 改动 | 一眼看出哪几行改过 |
| lazygit.nvim | 在 nvim 里开 lazygit | Space g g |
| lualine.nvim | 状态栏 | 开了 globalstatus,整个 nvim 只有一条状态栏 |
LSP 的启用写在我自己的 lsp.lua 里,用的是 nvim 0.11 新增的 vim.lsp.config + vim.lsp.enable。配置了 Go(gopls)、Rust(rust-analyzer)、TypeScript / JavaScript(typescript-language-server)三个,启动时先检查命令是否存在,没装就跳过,不会报错。nvim-lspconfig 插件仍然装着:它自带这几个服务的默认配置(lsp/ts_ls.lua 等),0.11 会把它和我写的配置合并,所以我只需要写和默认不同的部分。
日常按键
找文件、搜内容
| 按键 | 作用 |
|---|---|
| Space f f | 按文件名模糊查找 |
| Space f g | 全文搜索(Telescope 的 live_grep,底层用 ripgrep) |
| Space f b | 在已打开的文件之间切换 |
| Space f h | 搜 nvim 帮助文档 |
| - 或 Space e | 用 Oil 打开当前文件所在目录 |
| Esc | 清掉搜索高亮 |
Telescope 的结果列表我设成了输入框在上、结果从上往下排,和大多数编辑器的命令面板一样。
写代码:LSP 和补全
下面这些键只在 LSP 连上之后才有(在 lsp.lua 的 on_attach 里给当前缓冲区绑定)。我在本机用 headless 模式打开一个 TS 文件验证过:ts_ls 会连上,g d 是只对当前缓冲区生效的映射。
| 按键 | 作用 |
|---|---|
| g d | 跳到定义 |
| g r | 查找引用(会等 300 毫秒,原因见下文) |
| g i | 跳到实现 |
| K | 悬浮显示文档 |
| Space r n | 重命名符号,所有引用一起改 |
| Space c a | code action,例如自动导入、快速修复 |
| Space f | 格式化当前文件 |
补全菜单出现后:
| 按键 | 作用 |
|---|---|
| Tab / Shift-Tab | 下一个 / 上一个候选;没有菜单时在代码片段的占位符之间跳 |
| Enter | 确认当前候选(没选时默认选第一个) |
| Ctrl-Space | 手动呼出补全 |
保存、格式化、git
| 按键 | 作用 |
|---|---|
| Space w | 保存 |
| Space q | 关闭当前窗口 |
| Space g g | 在 nvim 里打开 lazygit |
保存时 conform 会自动格式化,超时 800 毫秒:Go 配的是 goimports 和 gofmt,Rust 是 rustfmt,JS / TS / JSON / Markdown / YAML 是 prettierd 和 prettier。一个都找不到时,退回 LSP 自带的格式化。
和 tmux 配合
一个键先到谁手里
按下一个键,它先经过终端软件,再到 tmux,最后才到 nvim。键盘输入里,tmux 只截住前缀 Ctrl-a 和紧跟在前缀后面的那个键,其余按键原样交给 nvim。鼠标是例外:开了 mouse on,点击和拖动先由 tmux 处理。下面这个小工具按我的两份配置演示这条路线:点一下输入区再按键,或者点下面的预设。
Windows / Linux 的浏览器会拦下 Ctrl-W(关闭标签页),想看 Ctrl-w 系列请点下面的预设按钮。
几个值得注意的地方:
- Space f 和 Space f f 都有映射。只按 Space f 时,nvim 不知道你是不是还要按第二个 f,会等
timeoutlen(我设的是 300 毫秒)后再执行格式化。timeoutlen设太长,Space f 就会显得卡。 - g r 也一样:nvim 0.11 自带了
grr(引用)、grn(重命名)、gri、gra、grt几个全局映射(:h grr),和我绑的 g r 前缀重叠,所以按 g r 也要等 300 毫秒。直接按 g r r 就不用等。 - nvim 里 Ctrl-a 本来是「把光标下的数字加 1」,被 tmux 前缀占了。我的 tmux 配置有
bind C-a send-prefix,连按两次 Ctrl-a,第二次就会转发给 nvim。 - nvim 自己也有一套窗口:Ctrl-w v 左右分、Ctrl-w h/j/k/l 移动。这些键 tmux 不管,全部交给 nvim。Ctrl-w 是 nvim 的内置命令,会一直等下一个键,不受
timeoutlen限制。
pane 还是 nvim 窗口
两边都能分屏,我按「里面跑的是什么」来选:
| 场景 | 用 | 原因 |
|---|---|---|
| 同时看两个文件、对照着改 | nvim 窗口(Ctrl-w v) | 共用补全、搜索、LSP,跳定义能直接在旁边打开 |
| dev server、测试 watch、日志 | tmux pane(Ctrl-a \) | 进程一直跑,关掉 nvim 不影响它 |
| 临时跑一条命令 | tmux pane,用完 Ctrl-a z 放大看输出 | 不打断 nvim 里的编辑状态 |
| 看 git 改动、提交 | nvim 里 Space g g | lazygit 浮在编辑器上,关掉就回到原来的位置 |
移动光标时两套键分开:Ctrl-a h/j/k/l 在 tmux pane 之间移动,Ctrl-w h/j/k/l 在 nvim 窗口之间移动。
真彩色、Esc 延迟、焦点事件:用 checkhealth 验证
tmux 里跑 nvim,最常见的三个问题是:主题颜色不对、按 Esc 有延迟、在别处改了文件 nvim 不知道。nvim 自带的 :checkhealth vim.health 会逐项检查。我在一个隔离的 tmux 实例里用我的配置跑了一遍,结果如下:
tmux ~- ✅ OK escape-time: 10- ✅ OK focus-events: on- $TERM: screen-256color对应的是 ~/.tmux.conf 里这几行:
set -g default-terminal "screen-256color"set -as terminal-features ",xterm-256color:RGB" # 外层终端是 xterm-256color 时,告诉 tmux 它支持真彩色set -sg escape-time 10 # 收到 Esc 后只等 10ms,判断是不是转义序列set -g focus-events on # 把「切回这个 pane」的焦点事件转发给 nvim- 真彩色:nvim 开了
termguicolors,主题用的是 24 位颜色。terminal-features里的RGB告诉 tmux 外层终端支持真彩色,tmux 才会原样转发。 - Esc 延迟:tmux 收到 Esc 后要等一会儿,判断它是不是方向键这类转义序列的开头。老版本 tmux 默认等 500 毫秒,在 nvim 里从插入模式退出时能明显感觉到卡。tmux 3.6 的默认值已经是 10(我用空配置启动实测过),配置里显式写上,是为了换到装着老版本 tmux 的机器上也不出问题。
- 焦点事件:nvim 的文档(
:h vim_diff)写着'autoread'在终端里需要终端支持焦点事件才生效。开了focus-events on,在旁边的 pane 里git checkout改了文件,切回 nvim 时它会自动重新载入。
剪贴板:两边都进系统剪贴板
- tmux 复制模式里按 y 会写进系统剪贴板。我的配置把它绑到
pbcopy,后加载的 tmux-yank 插件又重新绑定了一次,最终走的也是pbcopy。 - nvim 设了
clipboard=unnamedplus,:checkhealth vim.provider显示它用的也是pbcopy。
所以在 tmux 复制模式里复制的日志,切到 nvim 直接按 p 就能贴进来;nvim 里 y 复制的代码,在浏览器里 Cmd-v 也能粘贴。
lazygit 的两种打开方式
本机装了 lazygit,我有两个入口:
- 在 nvim 里按 Space g g:lazygit 浮在编辑器上面,关掉回到原来的文件。适合边改边提交。
- 上一篇提到的 tmux 弹出窗口:
bind g display-popup -E -w 90% -h 90% -d "#{pane_current_path}" lazygit。不在 nvim 里的时候也能用。这一行不在我现在的配置里,需要的话自己加上。
一条命令开好项目
上一篇的项目脚本改一下,让左边的 pane 直接打开 nvim,右边跑服务,再单独开一个 git 窗口:
#!/usr/bin/env bashS=myappDIR=~/code/myapp
# 会话已存在就直接进入tmux has-session -t "$S" 2>/dev/null && exec tmux attach -t "$S"
tmux new-session -d -s "$S" -n code -c "$DIR" # 窗口 1:codetmux send-keys -t "$S:code.1" 'nvim .' Enter # 左边 pane 开 nvimtmux split-window -h -t "$S:code" -c "$DIR" -l 35% # 右边 35% 宽的 panetmux send-keys -t "$S:code.2" 'pnpm dev' Enter # 在右边启动服务tmux new-window -t "$S" -n git -c "$DIR" # 窗口 2:gittmux send-keys -t "$S:git" 'lazygit' Entertmux select-window -t "$S:code"tmux select-pane -t "$S:code.1"exec tmux attach -t "$S"这个脚本依赖我配置里的 pane-base-index 1:code.1、code.2 是从 1 开始的 pane 编号,用默认配置(从 0 开始)会报 can't find pane: 1。我在加载了我配置的隔离 tmux 实例里跑过(把 pnpm dev 换成了 echo,目录换成临时目录):窗口 1 左边 pane 在跑 nvim、占约 65% 宽,右边 pane 在跑服务命令,窗口 2 在跑 lazygit,焦点停在 nvim 上。
关机后恢复 nvim
上一篇装的 tmux-resurrect 默认会重新启动 nvim,我在插件源码里确认过,它的默认进程列表是 vi vim view nvim emacs man less more tail top htop irssi weechat mutt。但它只是重新执行 nvim 命令,打开过哪些文件、分了几个窗口不会回来。
附录:我的完整配置
~/.tmux.conf 在上一篇的附录里。下面是 ~/.config/nvim 的全部文件,lazy-lock.json 只是版本号,省略。
vim.g.mapleader = " "vim.g.maplocalleader = " "
require("config.options")require("config.autocmds")require("config.keymaps")require("config.lazy")require("config.lsp")local opt = vim.optlocal home = vim.fn.expand("$HOME")
vim.env.PATH = table.concat({ home .. "/go/bin", home .. "/.nvm/versions/prefix/bin", home .. "/.bun/bin", vim.env.PATH or "",}, ":")
opt.number = trueopt.relativenumber = trueopt.mouse = "a"opt.clipboard = "unnamedplus"opt.termguicolors = trueopt.signcolumn = "yes"opt.updatetime = 250opt.timeoutlen = 300opt.splitright = trueopt.splitbelow = trueopt.ignorecase = trueopt.smartcase = trueopt.cursorline = trueopt.scrolloff = 8opt.sidescrolloff = 8opt.wrap = falseopt.completeopt = { "menu", "menuone", "noselect" }
opt.expandtab = trueopt.shiftwidth = 2opt.tabstop = 2opt.softtabstop = 2opt.smartindent = truelocal group = vim.api.nvim_create_augroup("local_config", { clear = true })
vim.api.nvim_create_autocmd("TextYankPost", { group = group, callback = function() vim.highlight.on_yank() end,})
vim.api.nvim_create_autocmd("FileType", { group = group, pattern = { "go" }, callback = function() vim.opt_local.expandtab = false vim.opt_local.shiftwidth = 4 vim.opt_local.tabstop = 4 vim.opt_local.softtabstop = 4 end,})
vim.api.nvim_create_autocmd("FileType", { group = group, pattern = { "rust" }, callback = function() vim.opt_local.shiftwidth = 4 vim.opt_local.tabstop = 4 vim.opt_local.softtabstop = 4 end,})local map = vim.keymap.set
map("n", "<leader>w", "<cmd>w<cr>", { desc = "Write file" })map("n", "<leader>q", "<cmd>q<cr>", { desc = "Quit window" })map("n", "<leader>e", "<cmd>Oil<cr>", { desc = "Open file browser" })map("n", "<leader>ff", "<cmd>Telescope find_files<cr>", { desc = "Find files" })map("n", "<leader>fg", "<cmd>Telescope live_grep<cr>", { desc = "Live grep" })map("n", "<leader>fb", "<cmd>Telescope buffers<cr>", { desc = "Buffers" })map("n", "<leader>fh", "<cmd>Telescope help_tags<cr>", { desc = "Help tags" })map("n", "<leader>gg", "<cmd>LazyGit<cr>", { desc = "LazyGit" })map("n", "-", "<cmd>Oil<cr>", { desc = "Open parent directory" })map("n", "<Esc>", "<cmd>nohlsearch<cr>", { desc = "Clear search highlight" })local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.fs_stat(lazypath) then vim.fn.system({ "git", "clone", "--filter=blob:none", "https://github.com/folke/lazy.nvim.git", "--branch=stable", lazypath, })end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup("plugins", { change_detection = { notify = false }, install = { colorscheme = { "tokyonight", "habamax" } }, ui = { border = "rounded" },})local capabilities = vim.lsp.protocol.make_client_capabilities()
local cmp_ok, cmp_lsp = pcall(require, "cmp_nvim_lsp")if cmp_ok then capabilities = cmp_lsp.default_capabilities(capabilities)end
local on_attach = function(_, bufnr) local map = function(mode, lhs, rhs, desc) vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc }) end
map("n", "gd", vim.lsp.buf.definition, "Go to definition") map("n", "gr", vim.lsp.buf.references, "References") map("n", "gi", vim.lsp.buf.implementation, "Implementation") map("n", "K", vim.lsp.buf.hover, "Hover") map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol") map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action") map("n", "<leader>f", function() require("conform").format({ async = false, lsp_fallback = true }) end, "Format buffer")end
local servers = { gopls = { cmd = { "gopls" }, filetypes = { "go", "gomod", "gowork", "gotmpl" }, root_markers = { "go.work", "go.mod", ".git" }, }, rust_analyzer = { cmd = { "rust-analyzer" }, filetypes = { "rust" }, root_markers = { "Cargo.toml", ".git" }, }, ts_ls = { cmd = { "typescript-language-server", "--stdio" }, filetypes = { "javascript", "javascriptreact", "typescript", "typescriptreact" }, root_markers = { "package.json", "tsconfig.json", "jsconfig.json", ".git" }, },}
for name, config in pairs(servers) do local executable = config.cmd[1] if vim.fn.executable(executable) == 1 and vim.lsp.config and vim.lsp.enable then config.capabilities = capabilities config.on_attach = on_attach vim.lsp.config(name, config) vim.lsp.enable(name) endendreturn { { "folke/tokyonight.nvim", lazy = false, priority = 1000, config = function() vim.cmd.colorscheme("tokyonight-night") end, }, { "stevearc/oil.nvim", dependencies = { "nvim-tree/nvim-web-devicons" }, opts = { default_file_explorer = true, view_options = { show_hidden = true }, }, }, { "nvim-lualine/lualine.nvim", dependencies = { "nvim-tree/nvim-web-devicons" }, opts = { options = { theme = "tokyonight", globalstatus = true, }, }, }, { "lewis6991/gitsigns.nvim", opts = {}, },}return { { "neovim/nvim-lspconfig", }, { "hrsh7th/nvim-cmp", dependencies = { "hrsh7th/cmp-nvim-lsp", "L3MON4D3/LuaSnip", "saadparwaiz1/cmp_luasnip", "rafamadriz/friendly-snippets", }, config = function() local cmp = require("cmp") local luasnip = require("luasnip")
require("luasnip.loaders.from_vscode").lazy_load()
cmp.setup({ snippet = { expand = function(args) luasnip.lsp_expand(args.body) end, }, mapping = cmp.mapping.preset.insert({ ["<C-Space>"] = cmp.mapping.complete(), ["<CR>"] = cmp.mapping.confirm({ select = true }), ["<Tab>"] = cmp.mapping(function(fallback) if cmp.visible() then cmp.select_next_item() elseif luasnip.expand_or_jumpable() then luasnip.expand_or_jump() else fallback() end end, { "i", "s" }), ["<S-Tab>"] = cmp.mapping(function(fallback) if cmp.visible() then cmp.select_prev_item() elseif luasnip.jumpable(-1) then luasnip.jump(-1) else fallback() end end, { "i", "s" }), }), sources = cmp.config.sources({ { name = "nvim_lsp" }, { name = "luasnip" }, }), }) end, }, { "stevearc/conform.nvim", opts = { formatters_by_ft = { go = { "goimports", "gofmt" }, javascript = { "prettierd", "prettier" }, javascriptreact = { "prettierd", "prettier" }, typescript = { "prettierd", "prettier" }, typescriptreact = { "prettierd", "prettier" }, json = { "prettierd", "prettier" }, jsonc = { "prettierd", "prettier" }, markdown = { "prettierd", "prettier" }, rust = { "rustfmt" }, yaml = { "prettierd", "prettier" }, }, format_on_save = { timeout_ms = 800, lsp_fallback = true, }, }, },}return { { "nvim-lua/plenary.nvim", lazy = true, }, { "nvim-telescope/telescope.nvim", dependencies = { "nvim-lua/plenary.nvim" }, config = function() require("telescope").setup({ defaults = { layout_strategy = "horizontal", sorting_strategy = "ascending", layout_config = { prompt_position = "top", }, }, }) end, }, { "nvim-treesitter/nvim-treesitter", lazy = false, build = ":TSUpdate", config = function() local ok, treesitter = pcall(require, "nvim-treesitter") if not ok then return end
treesitter.setup({ install_dir = vim.fn.stdpath("data") .. "/site", })
vim.api.nvim_create_autocmd("FileType", { pattern = { "bash", "go", "javascript", "json", "lua", "markdown", "rust", "toml", "tsx", "typescript", "yaml", }, callback = function(args) pcall(vim.treesitter.start, args.buf) end, }) end, }, { "kdheepak/lazygit.nvim", cmd = { "LazyGit", "LazyGitConfig", "LazyGitCurrentFile", "LazyGitFilter", "LazyGitFilterCurrentFile", }, dependencies = { "nvim-lua/plenary.nvim" }, },}延伸阅读:lazy.nvim · Telescope · oil.nvim · conform.nvim · nvim 0.11 LSP 文档