Pairwise
← 全部文章
nvimtmux终端效率工具

nvim + tmux:我的终端开发环境

tmux 管会话和进程,nvim 管文件和代码。讲我的 nvim 配置和日常按键,再讲两者怎么配合:真彩色、Esc 延迟、剪贴板、pane 与窗口的分工,以及一条命令开好整个项目。文中有一个演示按键会被哪一层接住的小工具。

发布 阅读约 10 分钟

上一篇讲了 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 管理,第一次启动时自动安装:

~/.config/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.config
lua/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 处理。下面这个小工具按我的两份配置演示这条路线:点一下输入区再按键,或者点下面的预设。

点这里,然后按键:试试 Ctrl-a \、Space f f、g d
还没有按键
按一个键,看它在哪一层被处理

    几个值得注意的地方:

    • 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 实例里用我的配置跑了一遍,结果如下:

    :checkhealth vim.health(tmux 部分)
    tmux ~
    - ✅ OK escape-time: 10
    - ✅ OK focus-events: on
    - $TERM: screen-256color

    对应的是 ~/.tmux.conf 里这几行:

    ~/.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 窗口:

    dev.sh
    #!/usr/bin/env bash
    S=myapp
    DIR=~/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:code
    tmux send-keys -t "$S:code.1" 'nvim .' Enter # 左边 pane 开 nvim
    tmux split-window -h -t "$S:code" -c "$DIR" -l 35% # 右边 35% 宽的 pane
    tmux send-keys -t "$S:code.2" 'pnpm dev' Enter # 在右边启动服务
    tmux new-window -t "$S" -n git -c "$DIR" # 窗口 2:git
    tmux send-keys -t "$S:git" 'lazygit' Enter
    tmux 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 只是版本号,省略。

    init.lua
    vim.g.mapleader = " "
    vim.g.maplocalleader = " "
    require("config.options")
    require("config.autocmds")
    require("config.keymaps")
    require("config.lazy")
    require("config.lsp")
    lua/config/options.lua
    local opt = vim.opt
    local 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 = true
    opt.relativenumber = true
    opt.mouse = "a"
    opt.clipboard = "unnamedplus"
    opt.termguicolors = true
    opt.signcolumn = "yes"
    opt.updatetime = 250
    opt.timeoutlen = 300
    opt.splitright = true
    opt.splitbelow = true
    opt.ignorecase = true
    opt.smartcase = true
    opt.cursorline = true
    opt.scrolloff = 8
    opt.sidescrolloff = 8
    opt.wrap = false
    opt.completeopt = { "menu", "menuone", "noselect" }
    opt.expandtab = true
    opt.shiftwidth = 2
    opt.tabstop = 2
    opt.softtabstop = 2
    opt.smartindent = true
    lua/config/autocmds.lua
    local 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,
    })
    lua/config/keymaps.lua
    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" })
    lua/config/lazy.lua
    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" },
    })
    lua/config/lsp.lua
    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)
    end
    end
    lua/plugins/editor.lua
    return {
    {
    "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 = {},
    },
    }
    lua/plugins/lsp.lua
    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,
    },
    },
    },
    }
    lua/plugins/search.lua
    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 文档