Neovim - Lua Configuration - Complete File-by-File Breakdown
Greetings!

I’ve finalized my Neovim configuration in Lua and lazy.nvim, and in this article I’ll explain: what the config provides out of the box, how to install it with a single command, and for those curious, a detailed breakdown of each file following the project structure.

Preamble

I have an entire series of older notes about the previous config on init.vim and vim-plug:

That config works fine; after switching from Vim to Neovim, I’ve long wanted to sit down and rewrite everything in Lua instead of vimscript. A modular file structure instead of one massive monolithic file.

It’s worth mentioning LazyVim separately. It’s a ready-made distribution of a Neovim configuration with pre-selected plugins, LSP, autocompletion, search, and hotkeys. LazyVim uses the lazy.nvim plugin manager internally, and additional plugins are added as regular Lua tables - plugin specs.

In my config, LazyVim as a ready-made distribution is not installed, but the same lazy.nvim and the same plugin spec format are used. So each file in lua/plugins returns a standard spec table with fields like event, cmd, keys, dependencies, opts, and config. This is done for configuration unification and format consistency. Looking at the code, you’ll understand what I mean.

So, below is a map of the finished result: what the config can do, how to install it, and what each file contains.

Initial Information

SoftwareVersion
LMDE/Debian/Ubuntu7/13/24
Neovim0.11+
lazy.nvim11.17
ThemeNord (shaunsingh/nord.nvim)

What the Config Provides

The config doesn’t try to be a full-fledged IDE - the focus is on a pleasant Neovim UI, fast startup, syntax highlighting, optional diagnostics through external CLI linters, and formatting on demand. Plus a plugin for working with AI agents. But that will be a separate article.

What comes out of the box:

Almost Quick Start

Installing Neovim from Standard Repositories

The current version of the config requires Neovim 0.11 or newer. First, check the installed version:

BASH
nvim --version
Click to expand and view more

If the distribution already provides a suitable version, it’s enough to install basic dependencies through the package manager. For Debian/Ubuntu:

BASH
sudo apt update && sudo apt install -y neovim git ripgrep
Click to expand and view more

In stable repositories of some distributions, Neovim 0.10 may still be available. In that case, it’s better to build the editor yourself.

Building Neovim from Source

Install build tools and required libraries:

BASH
sudo apt install -y ninja-build gettext cmake unzip curl build-essential
Click to expand and view more

Clone the official repository and build the release version:

BASH
git clone https://github.com/neovim/neovim /tmp/neovim

cd /tmp/neovim

git checkout stable

make CMAKE_BUILD_TYPE=Release
Click to expand and view more

And install it to the system executable directory:

BASH
sudo make install
Click to expand and view more

After installation, reinitialize the shell and check that the new version launches:

BASH
exec $SHELL

# or

hash -r

command -v nvim

nvim --version
Click to expand and view more

Usually, the compiled editor ends up in /usr/local/bin/nvim.

Installing Linters

Linters are preferably installed from standard repositories, i.e. system packages are updated along with the distribution.

All linters for which the config has system fallback options are installed with one command:

BASH
sudo apt install -y shellcheck lua-check pylint yamllint jsonlint
Click to expand and view more

ruff is also supported by the config, but it’s currently not available in the standard LMDE 7 / Debian 13 repository. So for Python, pylint is provided: installing Ruff via pip just for F7 to work is not necessary.

Available formatters from standard repositories are installed separately:

BASH
sudo apt install -y shfmt jq black
Click to expand and view more

stylua, yamlfmt, and prettier remain optional: they make sense to install separately if needed. If any tool is not in $PATH, the config simply doesn’t include it and continues to work.

Installing My Config

Backup the current config, if it exists:

BASH
test -d ~/.config/nvim && mv -v ~/.config/nvim{,.backup}
Click to expand and view more

Now clone the config via git directly to ~/.config/nvim:

BASH
git clone --branch raven https://github.com/r4ven-me/neovim ~/.config/nvim
Click to expand and view more

First launch:

BASH
cd ~/.config/nvim

nvim ./init.lua
Click to expand and view more

On first startup, the config will automatically clone lazy.nvim, install plugins from lua/plugins, create service directories under ~/.local/state/nvim, and fetch the required Treesitter parsers (will take some time :)

Custom Hotkeys

Below are the additional/modified editor hotkeys:

KeyAction
Shift+F1Open built-in config help
F13Run git add, commit with message Upd, and git push for the current file directory
F2Find a file in the current file’s directory
Shift+F2Find text in files of the current file’s directory
F3Open or hide Neo-tree
Shift+F3 / F15Format current buffer or visual selection
F4Open or hide bottom terminal
Shift+F4Open or hide Noice message history via Telescope
F5Open or hide AI chat CodeCompanion
Shift+F5Save and run current shell or Python file
F6Show workspace diagnostics in Telescope
Shift+F6Show current buffer diagnostics in Telescope
F7Enable linting and run check immediately
Shift+F7 / F19Disable linting and clear diagnostics
F8Show Git status in Telescope
Shift+F8Show Git history of current file
F9-F12Load one of four numbered sessions
Shift+F9-Shift+F12Save one of four numbered sessions
Shift+h / Shift+lGo to previous or next buffer
Shift+MouseWheelMove current buffer left or right
Ctrl+Up / Ctrl+DownIncrease or decrease bottom terminal height
Space fFormat current buffer or visual selection
Space dShow diagnostics for current line
[d / ]dGo to previous or next diagnostic
Space aiOpen or hide CodeCompanion chat
Space aaOpen CodeCompanion actions palette
WWSave current file
WSSave current AutoSession session
WRRestore last AutoSession session
jkExit insert mode
Esc EscClose messages or clear search highlight

Config Structure

Output
.
├── init.lua
├── lua
│   ├── config
│   │   ├── autocmds.lua
│   │   ├── keymaps.lua
│   │   ├── lazy.lua
│   │   └── options.lua
│   └── plugins
│       ├── auto-session.lua      # auto-save and restore sessions
│       ├── autopairs.lua         # auto-close brackets and quotes
│       ├── bufferline.lua        # open buffers bar and UI buttons
│       ├── codecompanion.lua     # AI chat with Claude Code and Codex
│       ├── conform.lua           # code formatting on demand
│       ├── gitsigns.lua          # Git changes in sign column
│       ├── indent-blankline.lua  # indent level guides
│       ├── lint.lua              # async external linter execution
│       ├── lsp.lua               # optional LSP boilerplate
│       ├── lualine.lua           # bottom status line
│       ├── neo-tree.lua          # project file tree
│       ├── noice.lua             # command line, notifications and history
│       ├── nord.lua              # Nord theme and additional highlight groups
│       ├── ruscmd.lua            # Neovim commands in Russian keyboard layout
│       ├── scrollview.lua        # current window scrollbar
│       ├── startify.lua          # startup screen and recent files
│       ├── telescope.lua         # search, diagnostics and interactive lists
│       ├── toggleterm.lua        # bottom terminal full width
│       └── treesitter.lua        # syntax highlighting and parsers
Click to expand and view more

The principle is simple: lua/config is the “engine” (options, keys, autocommands, plugin loader), and lua/plugins is one file per plugin, in the standard lazy.nvim spec format. Want to add a plugin - create a file that returns a spec table, lazy.nvim will pick it up automatically via { import = "plugins" }. No central plugin list to scroll through to understand where one specific setting came from.

In general, nothing else is needed to work with the config. Below is a detailed breakdown of each file in the structure from top to bottom. This is for the curious :).

init.lua

LUA
require("config.options")
require("config.keymaps")
require("config.autocmds")
require("config.lazy")
Click to expand and view more

Just loading four config modules. They, in this same order, set options, keys, autocommands, and start lazy.nvim.

lua/config Directory

lua/config/options.lua

LUA
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"

local state_path = vim.fn.stdpath("state")
local function state_dir(name)
  return table.concat({ state_path, name }, "/")
end

for _, dir in ipairs({ "swap", "undo", "backup", "sessions", "shada" }) do
  vim.fn.mkdir(state_dir(dir), "p")
end

local function clean_stale_shada_tmp()
  local shada_tmp_files = vim.fn.glob(state_dir("shada") .. "/main.shada.tmp.*", false, true)
  local now = os.time()

  for _, file in ipairs(shada_tmp_files) do
    local mtime = vim.fn.getftime(file)
    if mtime > 0 and now - mtime > 86400 then
      pcall(vim.fn.delete, file)
    end
  end
end

clean_stale_shada_tmp()

vim.opt.mouse = "a"
vim.opt.encoding = "utf-8"
vim.opt.number = true
vim.opt.scrolloff = 7
vim.opt.showmode = false
vim.opt.showcmd = false
vim.opt.more = false
vim.opt.cursorline = true
vim.opt.cursorlineopt = "both"
vim.opt.ignorecase = true
vim.opt.smartcase = true
vim.opt.laststatus = 2
vim.opt.cmdheight = 1
vim.opt.tabstop = 4
vim.opt.softtabstop = 4
vim.opt.shiftwidth = 4
vim.opt.expandtab = true
vim.opt.autoindent = true
vim.opt.smartindent = true
vim.opt.fileformats = { "unix", "dos", "mac" }
vim.opt.showtabline = 2
vim.opt.clipboard = "unnamedplus"
vim.opt.termguicolors = true
vim.opt.splitbelow = true
vim.opt.splitright = true
vim.opt.equalalways = true
vim.opt.updatetime = 250
vim.opt.timeoutlen = 500
vim.opt.ttimeoutlen = 200
vim.opt.completeopt = { "menu", "menuone", "noselect" }
vim.opt.signcolumn = "yes"

vim.opt.sessionoptions = {
  "buffers",
  "curdir",
  "folds",
  "help",
  "tabpages",
  "winsize",
  "winpos",
  "terminal",
  "localoptions",
}

vim.opt.shada = { "!", "'100", "<50", "s10", "h" }
vim.opt.shadafile = state_dir("shada") .. "/main.shada"

vim.opt.swapfile = true
vim.opt.directory = state_dir("swap") .. "//"
vim.opt.writebackup = true
vim.opt.backup = false
vim.opt.backupcopy = "auto"
vim.opt.backupdir = state_dir("backup") .. "//"
vim.opt.undofile = true
vim.opt.undodir = state_dir("undo") .. "//"

vim.opt.foldmethod = "indent"
vim.opt.foldnestmax = 10
vim.opt.foldenable = false
vim.opt.foldlevel = 2

vim.opt.guicursor = table.concat({
  "n-v-c:block-Cursor",
  "i-ci-ve:ver25-iCursor",
  "r-cr:hor20-rCursor",
  "o:hor50",
}, ",")

vim.diagnostic.config({
  virtual_text = {
    spacing = 2,
    prefix = "●",
    source = "if_many",
  },
  signs = true,
  underline = true,
  update_in_insert = false,
  severity_sort = true,
  float = {
    border = "rounded",
    source = "if_many",
    header = "",
  },
})

local diagnostic_signs = {
  Error = "E",
  Warn = "W",
  Hint = "H",
  Info = "I",
}

for type, icon in pairs(diagnostic_signs) do
  local hl = "DiagnosticSign" .. type
  vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = hl })
end
Click to expand and view more

A few places deserve special mention.

State directories (swap, undo, backup, sessions, ShaDa) are moved from ~/.local/share/nvim to ~/.local/state/nvim. A separate clean_stale_shada_tmp function cleans up stale main.shada.tmp.* files older than a day to avoid E138 error after an abnormal session termination.

clipboard = "unnamedplus" - system clipboard and Neovim’s unnamed register are shared, p/P work with what’s copied outside the editor, without extra "+.

more = false disables the built-in pager -- More --. On first startup, Treesitter prints many lines when downloading and compiling parsers; without this setting, installation would pause after each screen waiting for Enter, space, or scrolling down.

sessionoptions includes terminal - when saving a session, open toggleterm terminals are also restored.

At the end of the file, diagnostic signs are replaced with letters E/W/H/I instead of standard icon symbols: they remain readable on any font without icons.

lua/config/keymaps.lua

LUA
local map = vim.keymap.set
local opts = { noremap = true, silent = true }

map("n", "<CR>", "o<Esc>", opts)
map("n", "<Space>", "a <Esc>", opts)
map("i", "jk", "<Esc>", opts)
map("n", ",<Space>", "<cmd>nohlsearch<CR>", opts)
map("n", "x", '"_x', opts)
map("x", "x", '"_x', opts)
map("x", "p", "P", opts)
map("x", "P", "p", opts)
map("n", "WW", "<cmd>w<CR>", opts)

local function git_commit_current_dir()
  local file = vim.fn.expand("%:p")
  local dir = vim.fn.fnamemodify(file ~= "" and file or vim.fn.getcwd(), ":h")

  vim.cmd("!" .. table.concat({
    "git add " .. vim.fn.shellescape(dir),
    "git commit -m Upd",
    "git push",
  }, " && "))
end

map("n", "<S-F1>", "<cmd>help nvim-config<CR>", vim.tbl_extend("force", opts, { desc = "Open Neovim config help" }))
map("n", "<F13>", git_commit_current_dir, vim.tbl_extend("force", opts, { desc = "Git add/commit/push current dir" }))

local session_prefix = vim.fn.stdpath("state") .. "/sessions/session"

local function save_session(num)
  vim.cmd("mksession! " .. vim.fn.fnameescape(session_prefix .. num .. ".vim"))
end

local function load_session(num)
  vim.cmd("source " .. vim.fn.fnameescape(session_prefix .. num .. ".vim"))
  vim.schedule(function()
    vim.cmd("Neotree show")
    vim.cmd("wincmd w")
  end)
end

for key, num in pairs({
  ["<S-F9>"] = "1",
  ["<S-F10>"] = "2",
  ["<S-F11>"] = "3",
  ["<S-F12>"] = "4",
  ["<F21>"] = "1",
  ["<F22>"] = "2",
  ["<F23>"] = "3",
  ["<F24>"] = "4",
}) do
  map("n", key, function()
    save_session(num)
  end, { desc = "Save session " .. num })
  map("i", key, function()
    vim.cmd.stopinsert()
    save_session(num)
    vim.cmd.startinsert()
  end, { desc = "Save session " .. num })
end

for key, num in pairs({
  ["<F9>"] = "1",
  ["<F10>"] = "2",
  ["<F11>"] = "3",
  ["<F12>"] = "4",
}) do
  map("n", key, function()
    load_session(num)
  end, { desc = "Load session " .. num })
end

map("", "<S-ScrollWheelUp>", "<cmd>BufferLineMovePrev<CR>", { desc = "Move buffer left" })
map("", "<S-ScrollWheelDown>", "<cmd>BufferLineMoveNext<CR>", { desc = "Move buffer right" })

map("n", "<leader>d", vim.diagnostic.open_float, { desc = "Line diagnostics" })
map("n", "[d", vim.diagnostic.goto_prev, { desc = "Previous diagnostic" })
map("n", "]d", vim.diagnostic.goto_next, { desc = "Next diagnostic" })

map("n", "<Esc><Esc>", function()
  local ok, noice = pcall(require, "noice")
  if ok then
    noice.cmd("dismiss")
  else
    vim.cmd.nohlsearch()
  end
end, { desc = "Dismiss UI messages/search" })
Click to expand and view more

Basic remaps at the top: Enter in normal mode inserts a line below, Space inserts a space without entering insert mode, jk exits insert mode, x/X don’t clobber the register ("_x), and pasting in visual mode swaps p/P - handy when pasting over selected text and you want the old selection to stay in the register.

Shift+F1 opens local config help (:help nvim-config), while F13 is reserved for my personal “quick commit” that adds, commits with the message Upd :) and pushes the current file directory. Don’t do this in production repositories, but for personal dotfiles and notes - perfect, saves about ten seconds per save.

Next - four session slots on F9-F12 (save with Shift versions, plus F21-F24 as a duplicate for terminals without Shift+Fn): without plugins, just mksession!/source along fixed paths in the state directory.

At the end - navigation through diagnostics ([d/]d, leader+d) and Esc Esc, which closes Noice popup messages; fallback to nohlsearch remains in case the plugin isn’t loaded.

lua/config/autocmds.lua

LUA
local group = vim.api.nvim_create_augroup("UserConfig", { clear = true })

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = "*",
  callback = function()
    vim.opt_local.formatoptions:remove({ "c", "r", "o" })
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = { "lua", "json", "yaml", "toml" },
  callback = function()
    vim.opt_local.shiftwidth = 2
    vim.opt_local.tabstop = 2
    vim.opt_local.softtabstop = 2
    vim.opt_local.expandtab = true
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  group = group,
  pattern = { "sh", "bash", "zsh", "python" },
  callback = function(event)
    local function run_current_file()
      local file = vim.api.nvim_buf_get_name(event.buf)
      if file == "" then
        return
      end

      vim.cmd.write()
      vim.fn.system({ "chmod", "ug+x", file })
      vim.cmd("!" .. vim.fn.shellescape(file))
    end

    vim.keymap.set({ "n", "i" }, "<S-F5>", run_current_file, {
      buffer = event.buf,
      desc = "Save and run current file",
    })
  end,
})

vim.api.nvim_create_autocmd("BufReadPost", {
  group = group,
  callback = function(event)
    vim.api.nvim_create_autocmd("BufWinEnter", {
      group = group,
      once = true,
      buffer = event.buf,
      callback = function()
        local ft = vim.bo[event.buf].filetype
        local last_line = vim.api.nvim_buf_get_mark(event.buf, '"')[1]

        if ft:match("commit") or ft:match("rebase") then
          return
        end

        if last_line > 1 and last_line <= vim.api.nvim_buf_line_count(event.buf) then
          pcall(vim.cmd.normal, { args = { 'g`"' }, bang = true })
        end
      end,
    })
  end,
})

vim.api.nvim_create_autocmd("SessionLoadPost", {
  group = group,
  command = "wincmd =",
})

local function is_editor_buffer(buf)
  if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" or vim.bo[buf].filetype == "neo-tree" then
    return false
  end

  if vim.api.nvim_buf_get_name(buf) ~= "" or vim.bo[buf].modified then
    return true
  end

  local lines = vim.api.nvim_buf_get_lines(buf, 0, 2, false)
  return #lines > 1 or lines[1] ~= ""
end

vim.api.nvim_create_autocmd("QuitPre", {
  group = group,
  callback = function()
    local current_win = vim.api.nvim_get_current_win()
    if not is_editor_buffer(vim.api.nvim_get_current_buf()) then
      return
    end

    local auxiliary_windows = {}
    for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
      if win ~= current_win then
        local buf = vim.api.nvim_win_get_buf(win)
        if is_editor_buffer(buf) then
          return
        end

        local filetype = vim.bo[buf].filetype
        if
          filetype == "neo-tree"
          or filetype == "codecompanion"
          or filetype == "codecompanion_input"
          or vim.bo[buf].buftype == "terminal"
        then
          table.insert(auxiliary_windows, win)
        end
      end
    end

    for _, win in ipairs(auxiliary_windows) do
      if vim.api.nvim_win_is_valid(win) then
        vim.api.nvim_win_close(win, true)
      end
    end
  end,
})

vim.api.nvim_create_autocmd("VimLeavePre", {
  group = group,
  callback = function()
    vim.fn.mkdir(vim.fn.stdpath("state") .. "/shada", "p")
  end,
})
Click to expand and view more

Two things deserve highlighting here.

First - normal cursor position restoration when opening a file (analog of the classic au BufReadPost * if line("'\"") > 1 ...), but rewritten using nvim_create_autocmd, with explicit exclusion of commit/rebase buffers so the cursor doesn’t jump to the end of the commit message.

Second - behavior on :q (QuitPre). If in the current tab only Neo-tree and terminal remain besides the “work” buffer, they close automatically with it.

At the same time, indentation is redefined for lua/json/yaml/toml to 2 spaces (these language ecosystems de facto require it) and Shift+F5 is attached to save-and-run for sh/bash/zsh/python (be careful here!).

lua/config/lazy.lua

LUA
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"

if not (vim.uv or vim.loop).fs_stat(lazypath) then
  local lazyrepo = "https://github.com/folke/lazy.nvim.git"
  local out = vim.fn.system({
    "git",
    "clone",
    "--filter=blob:none",
    "--branch=stable",
    lazyrepo,
    lazypath,
  })

  if vim.v.shell_error ~= 0 then
    vim.api.nvim_echo({
      { "Failed to clone lazy.nvim:\n", "ErrorMsg" },
      { out, "WarningMsg" },
      { "\nPress any key to exit..." },
    }, true, {})
    vim.fn.getchar()
    os.exit(1)
  end
end

vim.opt.rtp:prepend(lazypath)

require("lazy").setup({
  spec = {
    { import = "plugins" },
  },
  install = {
    colorscheme = { "nord" },
  },
  checker = {
    enabled = false,
  },
  change_detection = {
    notify = false,
  },
  rocks = {
    enabled = false,
  },
  performance = {
    rtp = {
      disabled_plugins = {
        "gzip",
        "netrwPlugin",
        "tarPlugin",
        "tohtml",
        "tutor",
        "zipPlugin",
      },
    },
  },
})
Click to expand and view more

Classic lazy.nvim bootstrap: if it’s not yet in stdpath("data"), it’s cloned from GitHub with a small --filter=blob:none clone, then added to runtimepath and setup is run. spec = { { import = "plugins" } } is where lazy.nvim picks up files from lua/plugins. install.colorscheme = { "nord" } ensures that even on first install, while plugins are still being installed, the editor doesn’t flash the standard gray theme. checker.enabled = false disables background update checks and notifications about them - I’m used to updating everything manually. rocks.enabled = false turns off hererocks - without it, lazy.nvim silently tries to spin up its own Lua 5.1 and luarocks on every startup, and when it fails, spews warnings in the log; currently no plugin in the config declares rocks-dependencies, so this is just noise. In performance.rtp.disabled_plugins, standard Neovim plugins are disabled that I don’t need (netrw, tutor, archivers, etc.) - slightly faster editor startup.

lua/plugins Directory

lua/plugins/auto-session.lua

Hotkey - WS saves the current session, WR restores the last:

LUA
local neo_tree_was_open = false
local window_before_save

local function close_neo_tree_before_save()
  neo_tree_was_open = false
  window_before_save = vim.api.nvim_get_current_win()

  for _, win in ipairs(vim.api.nvim_list_wins()) do
    if vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "neo-tree" then
      neo_tree_was_open = true
      vim.cmd("Neotree close")
      break
    end
  end
end

local function restore_neo_tree_after_save()
  if not neo_tree_was_open then
    return
  end

  vim.cmd("Neotree show")
  if window_before_save and vim.api.nvim_win_is_valid(window_before_save) then
    vim.api.nvim_set_current_win(window_before_save)
  end
end

return {
  "rmagatti/auto-session",
  lazy = false,
  opts = {
    auto_save = true,
    auto_restore = false,
    auto_session_root_dir = vim.fn.stdpath("state") .. "/sessions/",
    bypass_save_filetypes = { "neo-tree", "toggleterm" },
    pre_save_cmds = {
      close_neo_tree_before_save,
    },
    post_save_cmds = {
      restore_neo_tree_after_save,
    },
    post_restore_cmds = {
      "Neotree show",
      "wincmd w",
    },
  },
  keys = {
    { "WS", "<cmd>AutoSession save<CR>", desc = "Save current session" },
    { "WR", "<cmd>AutoSession restore<CR>", desc = "Restore last session" },
  },
}
Click to expand and view more

Auto-save on exit is enabled, but automatic restore on startup is intentionally disabled: an accidental nvim launch in another directory shouldn’t bring up the old set of buffers. Sessions are saved via WS, restored via WR; current AutoSession save and AutoSession restore commands are used.

Before saving the session, Neo-tree is temporarily closed so its utility buffer doesn’t end up in the layout. The tree state and active window are remembered: right after saving, the panel returns only if it was open before WS, and focus stays in the previous editor window. After restoring the session, Neo-tree opens again.

lua/plugins/autopairs.lua

LUA
return {
  "windwp/nvim-autopairs",
  event = "InsertEnter",
  opts = {},
}
Click to expand and view more

Auto-closing of brackets and quotes, without a single custom setting - nvim-autopairs defaults are enough. Loads lazily on entering insert mode.

lua/plugins/bufferline.lua

Hotkey - Shift+h and Shift+l switch to previous and next buffer:

LUA
return {
  "akinsho/bufferline.nvim",
  version = "*",
  event = "VeryLazy",
  dependencies = {
    "nvim-tree/nvim-web-devicons",
    "famiu/bufdelete.nvim",
  },
  keys = {
    { "<S-l>", "<cmd>BufferLineCycleNext<CR>", desc = "Next buffer" },
    { "<S-h>", "<cmd>BufferLineCyclePrev<CR>", desc = "Previous buffer" },
  },
  config = function()
    vim.cmd([[
      function! OpenFileExplorer(_1, _2, _3, _4)
        Neotree toggle
      endfunction
    ]])

    vim.cmd([[
      function! OpenAIChat(_1, _2, _3, _4)
        if exists(':CodeCompanionChat') == 2
          CodeCompanionChat Toggle
        else
          echohl ErrorMsg
          echom 'CodeCompanion requires Neovim 0.11+. Run ~/bin/install-neovim first.'
          echohl None
        endif
      endfunction

      function! OpenBottomTerminal(_1, _2, _3, _4)
        lua toggle_bottom_terminal()
      endfunction
    ]])

    local function open_buffer(bufnr)
      local current_buf = vim.api.nvim_get_current_buf()

      if vim.bo[current_buf].filetype == "neo-tree" then
        local editor_win = require("neo-tree").get_prior_window({
          "codecompanion",
          "codecompanion_input",
          "terminal",
          "toggleterm",
        }, true)

        if editor_win < 0 or not vim.api.nvim_win_is_valid(editor_win) then
          vim.notify("No editor window available", vim.log.levels.WARN)
          return
        end

        vim.api.nvim_set_current_win(editor_win)
      end

      vim.api.nvim_set_current_buf(bufnr)
    end

    require("bufferline").setup({
      options = {
        mode = "buffers",
        themable = true,
        numbers = "none",
        close_command = "Bdelete! %d",
        left_mouse_command = open_buffer,
        right_mouse_command = "Bdelete! %d",
        middle_mouse_command = "Bdelete! %d",
        diagnostics = "nvim_lsp",
        show_buffer_icons = true,
        show_buffer_close_icons = false,
        show_close_icon = false,
        show_tab_indicators = true,
        persist_buffer_sort = true,
        separator_style = "thick",
        always_show_bufferline = true,
        indicator = {
          icon = "",
          style = "icon",
        },
        offsets = {
          {
            filetype = "neo-tree",
            text = " Files",
            highlight = "Directory",
            separator = true,
          },
        },
        custom_areas = {
          left = function()
            return {
              { text = " %@OpenFileExplorer@  %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
              { text = " ", fg = "#5E81AC" },
            }
          end,
          right = function()
            return {
              { text = " ", fg = "#5E81AC" },
              { text = " %@OpenAIChat@ 󰍹 %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
              { text = "", bg = "#5E81AC", fg = "#81A1C1" },
              { text = " %@OpenBottomTerminal@  %X ", bg = "#5E81AC", fg = "#ECEFF4", gui = "bold" },
            }
          end,
        },
      },
      highlights = {
        fill = { fg = "#3B4252", bg = "#3B4252" },
        background = { fg = "#D8DEE9", bg = "#434C5E" },
        tab = { fg = "#D8DEE9", bg = "#434C5E" },
        tab_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        tab_separator = { fg = "#3B4252", bg = "#4C566A" },
        tab_separator_selected = { fg = "#3B4252", bg = "#5E81AC" },
        buffer_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        buffer_visible = { fg = "#D8DEE9", bg = "#434C5E" },
        separator = { fg = "#3B4252", bg = "#3B4252" },
        indicator_visible = { fg = "#D8DEE9", bg = "#434C5E" },
        modified = { fg = "#EBCB8B", bg = "#434C5E" },
        modified_visible = { fg = "#EBCB8B", bg = "#434C5E" },
        modified_selected = { fg = "#EBCB8B", bg = "#5E81AC" },
        duplicate_selected = { fg = "#E5E9F0", bg = "#5E81AC", italic = true },
        duplicate_visible = { bg = "#434C5E", italic = true },
        duplicate = { fg = "#D8DEE9", bg = "#434C5E", italic = true },
        error = { fg = "#BF616A", bg = "#434C5E" },
        error_visible = { fg = "#BF616A", bg = "#434C5E" },
        error_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        warning = { fg = "#EBCB8B", bg = "#434C5E" },
        warning_visible = { fg = "#EBCB8B", bg = "#434C5E" },
        warning_selected = { fg = "#ECEFF4", bg = "#5E81AC", bold = true, italic = false },
        trunc_marker = { fg = "#81A1C1", bg = "#434C5E" },
        offset_separator = { bg = "#2E3440" },
      },
    })
  end,
}
Click to expand and view more

Besides the tabs themselves (Shift+h/Shift+l for switching, closing via Bdelete! to not break window layout) there’s a trick with custom_areas: clickable zones are drawn at the edges of the tab bar, which via %@FuncName@...%X call vimscript wrappers. On the left, Neo-tree opens; on the right are buttons for the AI chat and bottom terminal. Buttons are separated by the symbol; a computer `󰍹` calls `CodeCompanionChat Toggle`, a terminal calls toggleterm.

Bufferline with multiple open buffers and clickable areas at the edges

lua/plugins/codecompanion.lua

Hotkey - F5 or Space ai opens and closes the AI chat, Space aa opens the actions palette:

LUA
return {
  "olimorris/codecompanion.nvim",
  version = "*",
  enabled = vim.fn.has("nvim-0.11") == 1,
  cmd = {
    "CodeCompanion",
    "CodeCompanionActions",
    "CodeCompanionChat",
    "CodeCompanionCLI",
    "CodeCompanionCodeReview",
  },
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-treesitter/nvim-treesitter",
    {
      "MeanderingProgrammer/render-markdown.nvim",
      ft = { "markdown", "codecompanion" },
      opts = {
        file_types = { "markdown", "codecompanion" },
      },
    },
  },
  init = function()
    vim.api.nvim_create_autocmd("FileType", {
      pattern = { "codecompanion", "codecompanion_input" },
      callback = function()
        vim.schedule(function()
          if _G.keep_bottom_terminal_full_width then
            _G.keep_bottom_terminal_full_width(true)
          end
        end)
      end,
    })
  end,
  keys = {
    { "<F5>", "<cmd>CodeCompanionChat Toggle<CR>", mode = { "n", "i" }, desc = "Toggle AI chat" },
    { "<leader>ai", "<cmd>CodeCompanionChat Toggle<CR>", desc = "Toggle AI chat" },
    { "<leader>aa", "<cmd>CodeCompanionActions<CR>", mode = { "n", "v" }, desc = "AI actions" },
  },
  opts = {
    adapters = {
      acp = {
        extend = {
          codex = {
            defaults = {
              auth_method = "chat-gpt",
            },
          },
        },
      },
    },
    interactions = {
      chat = {
        adapter = "codex",
      },
      cli = {
        agent = "claude_code",
        agents = {
          claude_code = {
            cmd = "claude",
            args = {},
            description = "Claude Code CLI",
            provider = "terminal",
          },
          codex = {
            cmd = "codex",
            args = {},
            description = "OpenAI Codex CLI",
            provider = "terminal",
          },
        },
      },
    },
    display = {
      action_palette = {
        provider = "telescope",
      },
      chat = {
        show_context = false,
        show_header_separator = false,
        window = {
          layout = "vertical",
          position = "right",
          width = 0.4,
        },
        start_in_insert_mode = true,
      },
    },
    opts = {
      language = "Russian",
    },
  },
}
Click to expand and view more

CodeCompanion.nvim gives an AI chat in the right vertical panel. Open or close it via F5, leader+ai, the :CodeCompanionChat Toggle command, or the 󰍹 button in Bufferline; leader+aa opens an actions palette for the current buffer or visual selection. Responses are formatted by render-markdown.nvim.

The main chat adapter is Codex ACP with ChatGPT account authorization. If needed, inside the chat via ga select a different adapter and model, including Claude Code; telescope-ui-select.nvim turns this selection into a normal Telescope dropdown. A separate CodeCompanionCLI runs already-authorized claude and codex commands in a terminal window.

The FileType autocommand after opening the chat returns the bottom ToggleTerm to the very bottom, so the right panel doesn’t shrink the terminal width. The vim.fn.has("nvim-0.11") check prevents an incompatible plugin from loading on old Neovim.

lua/plugins/conform.lua

LUA
local function format_buffer()
  require("conform").format({ async = true, lsp_fallback = true })
end

return {
  "stevearc/conform.nvim",
  cmd = "ConformInfo",
  keys = {
    { "<leader>f", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
    { "<S-F3>", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
    { "<F15>", format_buffer, mode = { "n", "v" }, desc = "Format buffer" },
  },
  opts = {
    notify_on_error = false,
    formatters_by_ft = {
      lua = { "stylua" },
      sh = { "shfmt" },
      bash = { "shfmt" },
      zsh = { "shfmt" },
      python = { "ruff_format", "black" },
      json = { "jq" },
      yaml = { "yamlfmt", "prettier" },
    },
  },
}
Click to expand and view more

Formatting only on demand - leader+f or Shift+F3/F15 (duplicate in case the terminal sends this code), no format-on-save. For each language, a primary formatter and a fallback are defined (e.g., for Python first ruff_format, if it’s missing - black). notify_on_error = false - if the needed formatter isn’t installed, conform.nvim simply silently skips the step rather than spewing error notifications.

lua/plugins/gitsigns.lua

LUA
return {
  "lewis6991/gitsigns.nvim",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    signs = {
      add = { text = "+" },
      change = { text = "~" },
      delete = { text = "_" },
      topdelete = { text = "‾" },
      changedelete = { text = "~" },
      untracked = { text = "+" },
    },
    current_line_blame = false,
  },
}
Click to expand and view more

Git change signs in the column to the left of line numbers. current_line_blame is intentionally disabled - icons in the gutter are enough, blame for each line just distracts.

Gitsigns marks in the column to the left of line numbers

lua/plugins/indent-blankline.lua

LUA
return {
  "lukas-reineke/indent-blankline.nvim",
  main = "ibl",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    scope = {
      show_start = false,
      show_end = true,
    },
  },
}
Click to expand and view more

Vertical indent guides. show_start = false removes highlighting of the first line of the current block (it’s already visible by the cursor), show_end = true keeps highlighting the last one - convenient to see where a block closes, especially in deeply nested code.

lua/plugins/lint.lua

Hotkey - F7 enables linting and runs it immediately, Shift+F7/F19 disables and clears diagnostics:

LUA
return {
  "mfussenegger/nvim-lint",
  event = { "BufReadPost", "BufNewFile" },
  config = function()
    local lint = require("lint")
    local enabled = false

    lint.linters.shellcheck.args = {
      "--format",
      "json1",
      "-",
    }

    local function executable(name)
      return vim.fn.executable(name) == 1
    end

    local function add_linters(target, ft, linters)
      local available = {}

      for _, name in ipairs(linters) do
        if executable(name) then
          table.insert(available, name)
        end
      end

      if #available > 0 then
        target[ft] = available
      end
    end

    local linters_by_ft = {}
    add_linters(linters_by_ft, "lua", { "luacheck" })
    add_linters(linters_by_ft, "sh", { "shellcheck" })
    add_linters(linters_by_ft, "bash", { "shellcheck" })
    add_linters(linters_by_ft, "zsh", { "zsh" })
    add_linters(linters_by_ft, "python", { "ruff", "pylint" })
    add_linters(linters_by_ft, "json", { "jsonlint" })
    add_linters(linters_by_ft, "yaml", { "yamllint" })

    lint.linters_by_ft = linters_by_ft

    local group = vim.api.nvim_create_augroup("UserLint", { clear = true })

    local timers = {}

    local function run_lint(notify_status, bufnr)
      bufnr = bufnr or vim.api.nvim_get_current_buf()
      if not vim.api.nvim_buf_is_valid(bufnr) then
        return
      end

      vim.api.nvim_buf_call(bufnr, function()
        local filetype = vim.bo.filetype
        local linters = lint.linters_by_ft[filetype]

        if not linters or #linters == 0 then
          if notify_status then
            vim.notify(
              "No installed linter configured for filetype: " .. (filetype ~= "" and filetype or "unknown"),
              vim.log.levels.WARN
            )
          end
          return
        end

        lint.try_lint()
        if notify_status then
          vim.notify("Lint started: " .. table.concat(linters, ", "))
        end
      end)
    end

    local function try_lint(bufnr)
      if enabled then
        run_lint(false, bufnr)
      end
    end

    local function try_lint_debounced(bufnr)
      local timer = timers[bufnr]
      if timer then
        timer:stop()
        timer:close()
      end

      timers[bufnr] = vim.defer_fn(function()
        timers[bufnr] = nil
        try_lint(bufnr)
      end, 500)
    end

    vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost", "InsertLeave", "TextChanged", "TextChangedI" }, {
      group = group,
      callback = function(event)
        try_lint_debounced(event.buf)
      end,
    })

    local initial_buf = vim.api.nvim_get_current_buf()
    vim.schedule(function()
      try_lint(initial_buf)
    end)

    vim.api.nvim_create_user_command("Lint", function()
      run_lint(true)
    end, {})

    local function toggle_linter()
      enabled = not enabled
      if enabled then
        run_lint(false)
      else
        vim.diagnostic.reset(nil, 0)
      end
      vim.notify("Lint " .. (enabled and "enabled" or "disabled"))
    end

    vim.api.nvim_create_user_command("LinterToggle", toggle_linter, {})
    vim.api.nvim_create_user_command("LintToggle", toggle_linter, {})

    vim.keymap.set({ "n", "i" }, "<F7>", function()
      if vim.api.nvim_get_mode().mode:match("^i") then
        vim.cmd.stopinsert()
      end
      enabled = true
      run_lint(true)
    end, { desc = "Enable and run lint" })

    vim.keymap.set({ "n", "i" }, "<S-F7>", function()
      enabled = false
      vim.diagnostic.reset(nil, 0)
      vim.notify("Lint disabled")
    end, { desc = "Disable lint" })

    vim.keymap.set({ "n", "i" }, "<F19>", function()
      enabled = false
      vim.diagnostic.reset(nil, 0)
      vim.notify("Lint disabled")
    end, { desc = "Disable lint" })
  end,
}
Click to expand and view more

Linting is disabled by default. F7 enables it and runs the first check immediately; while the mode is active, subsequent checks start 500 ms after BufEnter, save, exiting insert mode, or text change. Debounce doesn’t launch a new external process on every keystroke, but diagnostics remain nearly live.

A timer is stored separately for each buffer. This is important when quickly switching files: a deferred check always runs in the buffer where the change happened and can’t accidentally apply to an already-open neighboring file.

F7 explicitly enables linting and runs the check immediately; Shift+F7 or F19 disables automatic checks and clears diagnostics. Commands :LinterToggle and :LintToggle toggle the same mode. On manual run, the linter name is shown, and if no installed tool is found for the current filetype - a clear warning instead of a silent no-op.

The linter list is built dynamically via vim.fn.executable: only commands really available in $PATH are used. For shell files, shellcheck reads the current buffer from stdin in json1 format, so it sees unsaved changes.

Shellcheck diagnostics from automatically updated nvim-lint

lua/plugins/lsp.lua

LUA
return {
  "neovim/nvim-lspconfig",
  enabled = false,
  event = { "BufReadPre", "BufNewFile" },
  config = function()
    local lspconfig = require("lspconfig")
    local capabilities = vim.lsp.protocol.make_client_capabilities()

    -- Flip enabled=true above and add servers here when full LSP is needed.
    lspconfig.lua_ls.setup({ capabilities = capabilities })
    lspconfig.pyright.setup({ capabilities = capabilities })
  end,
}
Click to expand and view more

LSP boilerplate, disabled via enabled = false. For 90% of tasks, lightweight linters and formatters above are enough without running heavy language servers. When full LSP is needed - I install the needed servers, flip enabled to true, and customize the config block: examples for lua_ls and pyright are already inside.

lua/plugins/lualine.lua

LUA
return {
  "nvim-lualine/lualine.nvim",
  event = "VeryLazy",
  dependencies = { "nvim-tree/nvim-web-devicons" },
  opts = {
    options = {
      theme = {
        normal = {
          a = { fg = "#ECEFF4", bg = "#5E81AC", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        insert = {
          a = { fg = "#2E3440", bg = "#A3BE8C", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        visual = {
          a = { fg = "#2E3440", bg = "#B48EAD", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        replace = {
          a = { fg = "#2E3440", bg = "#EBCB8B", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        command = {
          a = { fg = "#2E3440", bg = "#88C0D0", gui = "bold" },
          b = { fg = "#E5E9F0", bg = "#4C566A" },
          c = { fg = "#D8DEE9", bg = "#3B4252" },
        },
        inactive = {
          a = { fg = "#D8DEE9", bg = "#3B4252" },
          b = { fg = "#D8DEE9", bg = "#3B4252" },
          c = { fg = "#81A1C1", bg = "#2E3440" },
        },
      },
      component_separators = { left = "", right = "" },
      section_separators = { left = "", right = "" },
      globalstatus = true,
    },
    sections = {
      lualine_a = { "mode" },
      lualine_b = { "branch", "diagnostics" },
      lualine_c = {
        { "filename", path = 1 },
      },
      lualine_x = { "encoding", "fileformat", "filetype" },
      lualine_y = { "progress" },
      lualine_z = { "location" },
    },
    inactive_sections = {
      lualine_a = {},
      lualine_b = {},
      lualine_c = { "filename" },
      lualine_x = { "location" },
      lualine_y = {},
      lualine_z = {},
    },
    extensions = { "neo-tree", "quickfix" },
  },
}
Click to expand and view more

The theme is manually written for the Nord palette, not taken from lualine presets - different a-section colors per mode (normal/insert/visual/replace/command) immediately show what mode the editor is in, even peripherally. globalstatus = true - one status line for all windows, not one per each. path = 1 in filename - show the path relative to the current directory, not just the filename.

lua/plugins/neo-tree.lua

Hotkey - F3 opens and closes Neo-tree:

LUA
return {
  "nvim-neo-tree/neo-tree.nvim",
  branch = "v3.x",
  cmd = "Neotree",
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-tree/nvim-web-devicons",
    "MunifTanjim/nui.nvim",
  },
  keys = {
    { "<F3>", "<cmd>Neotree toggle<CR>", desc = "Toggle Neo-tree" },
  },
  opts = {
    close_if_last_window = true,
    enable_git_status = true,
    event_handlers = {
      {
        event = "neo_tree_window_after_open",
        handler = function()
          local terminal_win = vim.t.toggleterm_restore_win
          vim.t.toggleterm_restore_win = nil

          vim.schedule(function()
            if _G.keep_bottom_terminal_full_width then
              _G.keep_bottom_terminal_full_width(false)
            end

            if type(terminal_win) == "number" and vim.api.nvim_win_is_valid(terminal_win) then
              vim.api.nvim_set_current_win(terminal_win)
              vim.cmd.startinsert()
            end
          end)
        end,
      },
    },
    window = {
      width = 35,
    },
    filesystem = {
      bind_to_cwd = true,
      cwd_target = "window",
      filtered_items = {
        hide_dotfiles = false,
        hide_gitignored = false,
      },
    },
  },
}
Click to expand and view more

Side panel on F3, close_if_last_window = true - closes itself if it becomes the only window (the missing piece that together with QuitPre from autocmds.lua eliminates E444). Hidden and git-ignored files aren’t hidden - hide_dotfiles/hide_gitignored are both false.

A separate event_handlers - this is the junction with toggleterm: if Neo-tree is opened from the terminal (it has its own F3 binding, see below), after opening the panel the focus and width of the bottom terminal are restored so it doesn’t “shrink” to the new window width.

lua/plugins/noice.lua

Hotkey - Shift+F4/F16 opens and closes message history via Telescope:

LUA
local function toggle_history()
  if vim.bo.filetype == "TelescopePrompt" then
    require("telescope.actions").close(vim.api.nvim_get_current_buf())
    return
  end

  require("noice").cmd("telescope")
end

return {
  "folke/noice.nvim",
  event = "VeryLazy",
  keys = {
    { "<S-F4>", toggle_history, mode = { "n", "i", "t" }, desc = "Toggle Noice history" },
    { "<F16>", toggle_history, mode = { "n", "i", "t" }, desc = "Toggle Noice history" },
  },
  dependencies = {
    "MunifTanjim/nui.nvim",
    {
      "rcarriga/nvim-notify",
      opts = {
        stages = "fade_in_slide_out",
        timeout = 4000,
        top_down = true,
      },
    },
  },
  opts = {
    lsp = {
      override = {
        ["vim.lsp.util.convert_input_to_markdown_lines"] = true,
        ["vim.lsp.util.stylize_markdown"] = true,
        ["cmp.entry.get_documentation"] = true,
      },
    },
    presets = {
      bottom_search = true,
      command_palette = false,
      long_message_to_split = true,
      inc_rename = false,
      lsp_doc_border = true,
    },
  },
}
Click to expand and view more

noice.nvim replaces standard message output and command line with neat popup windows, and nvim-notify shows notifications in the top right corner. The : command line opens in the center; search / and ? thanks to bottom_search = true remain at the bottom. Double Esc closes current messages.

Shift+F4 (and terminal duplicate F16) opens Noice message history via Telescope. Inside the list, filtering by typing works, arrows or Ctrl+n/Ctrl+p, Enter and Esc; repeated Shift+F4 also closes the picker. The same history can be called with :Noice telescope.

lua/plugins/nord.lua

LUA
return {
  "shaunsingh/nord.nvim",
  lazy = false,
  priority = 1000,
  config = function()
    vim.g.nord_contrast = true
    vim.g.nord_borders = false
    vim.g.nord_disable_background = false
    vim.g.nord_italic = false
    vim.g.nord_uniform_diff_background = true
    vim.g.nord_bold = false

    vim.cmd.colorscheme("nord")

    local function set_highlights()
      local hl = vim.api.nvim_set_hl

      hl(0, "Cursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "lCursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "TermCursor", { fg = "#2E3440", bg = "#ECEFF4" })
      hl(0, "iCursor", { fg = "#2E3440", bg = "#A3BE8C" })
      hl(0, "rCursor", { fg = "#2E3440", bg = "#EBCB8B" })
      hl(0, "CursorIM", { fg = "#2E3440", bg = "#A3BE8C" })
      hl(0, "CursorLine", { bg = "#3B4252" })
      hl(0, "CursorLineNr", { fg = "#ECEFF4", bg = "#3A4150", bold = true })
      hl(0, "WinSeparator", { fg = "#88C0D0", bg = "#2E3440" })
      hl(0, "ToggleTermSeparator", { fg = "#88C0D0", bg = "#2E3440" })
      hl(0, "MatchParen", { fg = "#8FBCBB", bg = "NONE", bold = true, underline = true })

      hl(0, "DiagnosticSignError", { fg = "#BF616A", bg = "NONE" })
      hl(0, "DiagnosticSignWarn", { fg = "#EBCB8B", bg = "NONE" })
      hl(0, "DiagnosticSignInfo", { fg = "#88C0D0", bg = "NONE" })
      hl(0, "DiagnosticSignHint", { fg = "#A3BE8C", bg = "NONE" })
      hl(0, "DiagnosticVirtualTextError", { fg = "#BF616A", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextWarn", { fg = "#EBCB8B", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextInfo", { fg = "#88C0D0", bg = "#3B4252" })
      hl(0, "DiagnosticVirtualTextHint", { fg = "#A3BE8C", bg = "#3B4252" })
      hl(0, "DiagnosticFloatingError", { fg = "#BF616A" })
      hl(0, "DiagnosticFloatingWarn", { fg = "#EBCB8B" })
      hl(0, "DiagnosticFloatingInfo", { fg = "#88C0D0" })
      hl(0, "DiagnosticFloatingHint", { fg = "#A3BE8C" })
    end

    set_highlights()
    vim.api.nvim_create_autocmd("ColorScheme", {
      pattern = "nord",
      callback = set_highlights,
    })
  end,
}
Click to expand and view more

priority = 1000 and lazy = false - the theme must load before all other plugins to avoid “flashing” standard colors on startup. On top of the standard Nord palette, a set of manual tweaks is applied: the cursor is recolored per mode (Cursor/iCursor/rCursor/CursorIM) so it’s visible on any background, not lost like it sometimes is with some colorschemes “out of the box”, plus explicit colors for window separators and diagnostics. set_highlights is called not only immediately but also repeatedly on the ColorScheme autocommand - if the scheme is reloaded, manual adjustments won’t be lost.

lua/plugins/ruscmd.lua

LUA
return {
  "powerman/vim-plugin-ruscmd",
  event = "VimEnter",
}
Click to expand and view more

Allows executing : commands even if you forgot to switch the keyboard layout from Russian to English. A small thing, but useful.

lua/plugins/scrollview.lua

LUA
return {
  "dstein64/nvim-scrollview",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    scrollview_current_only = true,
    scrollview_excluded_filetypes = { "neo-tree" },
    hide_on_cursor_intersect = true,
    hide_on_text_intersect = true,
  },
}
Click to expand and view more

Scrollbar on the right - only for the current window (scrollview_current_only), hides when it intersects the cursor or text on screen (hide_on_cursor_intersect/hide_on_text_intersect), and isn’t shown in Neo-tree.

lua/plugins/startify.lua

LUA
return {
  "mhinz/vim-startify",
  event = "VimEnter",
  init = function()
    vim.g.startify_session_dir = vim.fn.stdpath("state") .. "/sessions"
  end,
}
Click to expand and view more

Startup screen with a list of recent files and sessions when launching without arguments. The only customization - the sessions directory points to the same place as keymaps.lua (slots F9-F12) and auto-session.lua.

lua/plugins/telescope.lua

Hotkeys - F2/Shift+F2 search files and text in the current file’s directory, F6/Shift+F6 show workspace and buffer diagnostics, F8/Shift+F8 show git status and current file history:

LUA
return {
  "nvim-telescope/telescope.nvim",
  tag = "0.1.8",
  cmd = "Telescope",
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-telescope/telescope-ui-select.nvim",
  },
  keys = {
    {
      "<F2>",
      function()
        local file = vim.api.nvim_buf_get_name(0)
        local cwd = file ~= "" and vim.fs.dirname(file) or vim.fn.getcwd()

        require("telescope.builtin").find_files({
          cwd = cwd,
          prompt_title = "Files: " .. vim.fn.fnamemodify(cwd, ":~"),
          sorter = require("telescope.sorters").get_substr_matcher(),
        })
      end,
      desc = "Find files in current file directory",
    },
    {
      "<S-F2>",
      function()
        local file = vim.api.nvim_buf_get_name(0)
        local cwd = file ~= "" and vim.fs.dirname(file) or vim.fn.getcwd()

        require("telescope.builtin").live_grep({
          cwd = cwd,
          prompt_title = "Grep: " .. vim.fn.fnamemodify(cwd, ":~"),
        })
      end,
      desc = "Search file contents in current file directory",
    },

    {
      "<F6>",
      function()
        require("telescope.builtin").diagnostics()
      end,
      desc = "Workspace diagnostics",
    },
    {
      "<S-F6>",
      function()
        require("telescope.builtin").diagnostics({ bufnr = 0 })
      end,
      desc = "Buffer diagnostics",
    },
    {
      "<F8>",
      function()
        require("telescope.builtin").git_status()
      end,
      desc = "Git status",
    },
    {
      "<S-F8>",
      function()
        require("telescope.builtin").git_bcommits()
      end,
      desc = "Current file Git history",
    },
  },
  config = function()
    local actions = require("telescope.actions")

    require("telescope").setup({
      extensions = {
        ["ui-select"] = {
          require("telescope.themes").get_dropdown({}),
        },
      },
      defaults = {
        mappings = {
          i = {
            ["<Esc>"] = actions.close,
          },
          n = {
            ["<Esc>"] = actions.close,
          },
        },
        layout_config = {
          horizontal = {
            width = 0.9,
            height = 0.7,
            preview_width = 0.5,
          },
          vertical = {
            width = 0.9,
            height = 0.9,
            preview_height = 0.5,
          },
        },
        layout_strategy = "horizontal",
      },

    })

    require("telescope").load_extension("ui-select")
  end,
}
Click to expand and view more

File search in the current file directory on F2 and content search on Shift+F2.

The key decision here - cwd for find_files/live_grep is taken not from the project root and not from the global cwd, but from the directory of the currently open file (vim.fs.dirname). On monorepos with dozens of services, global search across the entire tree is too noisy - usually you’re interested in searching near what you’re working on right now. <Esc> closes Telescope in both insert and normal modes of the prompt.

telescope-ui-select.nvim redefines the standard vim.ui.select, so CodeCompanion adapter and model selection, along with other system lists, get search and normal arrow navigation. Besides search, Telescope is used as a unified interface for diagnostics and Git: F6 shows workspace diagnostics, Shift+F6 - only current buffer diagnostics, F8 - changed files, Shift+F8 - current file history. The old buffer list on F5 has been removed: now this key switches the CodeCompanion AI chat.

lua/plugins/toggleterm.lua

Hotkey - F4 opens and closes the bottom terminal (or visual button at top-right), stretched to full width; Ctrl+Up/Ctrl+Down change its height.

LUA
return {
  "akinsho/toggleterm.nvim",
  version = "*",
  lazy = false,
  init = function()
    _G.toggle_bottom_terminal = function()
      vim.cmd("1ToggleTerm direction=horizontal")
      vim.defer_fn(function()
        local term = require("toggleterm.terminal").get(1, true)
        if term and term:is_open() and vim.api.nvim_win_is_valid(term.window) then
          vim.api.nvim_set_current_win(term.window)
          vim.cmd("startinsert!")
        end
      end, 100)
    end

    _G.keep_bottom_terminal_full_width = function(restore_focus)
      local term = require("toggleterm.terminal").get(1, true)
      if not term or not term:is_open() or not vim.api.nvim_win_is_valid(term.window) then
        return
      end

      local current_win = vim.api.nvim_get_current_win()
      vim.api.nvim_set_current_win(term.window)
      vim.cmd("wincmd J")

      if restore_focus and vim.api.nvim_win_is_valid(current_win) then
        vim.api.nvim_set_current_win(current_win)
      end
    end
  end,
  opts = {
    size = 15,
    open_mapping = nil,
    direction = "horizontal",
    persist_size = true,
    persist_mode = false,
    start_in_insert = true,
    shade_terminals = false,
    shell = vim.o.shell,
    on_open = function(term)
      local previous_win = vim.fn.win_getid(vim.fn.winnr("#"))
      if previous_win ~= 0 and vim.api.nvim_win_is_valid(previous_win) then
        vim.t.toggleterm_last_editor_win = previous_win
      end

      _G.keep_bottom_terminal_full_width(false)

      vim.api.nvim_set_hl(0, "ToggleTermSeparator", { fg = "#88C0D0", bg = "#2E3440" })

      local winhighlight = vim.wo[term.window].winhighlight
      if not winhighlight:find("WinSeparator:", 1, true) then
        vim.wo[term.window].winhighlight = table.concat({
          winhighlight,
          "WinSeparator:ToggleTermSeparator",
        }, ","):gsub("^,", "")
      end

      local function resize(delta)
        vim.cmd(("resize %s%d"):format(delta > 0 and "+" or "", delta))
      end

      local opts = { buffer = term.bufnr, silent = true }
      local function enter_terminal_mode()
        if term:is_open() and vim.api.nvim_get_current_win() == term.window then
          vim.cmd("startinsert!")
        end
      end

      vim.keymap.set({ "n", "t" }, "<C-Up>", function()
        resize(2)
      end, vim.tbl_extend("force", opts, { desc = "Increase terminal height" }))
      vim.keymap.set({ "n", "t" }, "<C-Down>", function()
        resize(-2)
      end, vim.tbl_extend("force", opts, { desc = "Decrease terminal height" }))

      vim.defer_fn(enter_terminal_mode, 50)
    end,
  },
  config = function(_, opts)
    require("toggleterm").setup(opts)

    local toggle = "<cmd>1ToggleTerm direction=horizontal<CR>"
    local toggle_keys = { "<F4>" }

    for _, key in ipairs(toggle_keys) do
      vim.keymap.set("n", key, toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
      vim.keymap.set("i", key, "<Esc>" .. toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
      vim.keymap.set("t", key, [[<C-\><C-n>]] .. toggle, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Toggle bottom terminal",
      })
    end

    local function forward_to_editor(key)
      vim.cmd.stopinsert()

      local previous_win = vim.t.toggleterm_last_editor_win
      if type(previous_win) == "number" and previous_win ~= 0 and vim.api.nvim_win_is_valid(previous_win) then
        local previous_buf = vim.api.nvim_win_get_buf(previous_win)
        if vim.bo[previous_buf].buftype ~= "terminal" then
          vim.api.nvim_set_current_win(previous_win)
        end
      end

      vim.schedule(function()
        local mapping = vim.fn.maparg(key, "n", false, true)

        if type(mapping.callback) == "function" then
          local ok, err = pcall(mapping.callback)
          if not ok then
            vim.notify(err, vim.log.levels.ERROR)
          end
          return
        end

        if type(mapping.rhs) == "string" and mapping.rhs ~= "" then
          local rhs = vim.api.nvim_replace_termcodes(mapping.rhs, true, false, true)
          vim.api.nvim_feedkeys(rhs, "nx", false)
          return
        end

        vim.notify("No editor mapping for " .. key, vim.log.levels.WARN)
      end)
    end

    vim.keymap.set("t", "<F3>", function()
      local terminal_win = vim.api.nvim_get_current_win()
      vim.t.toggleterm_restore_win = terminal_win

      require("neo-tree.command").execute({
        action = "show",
        source = "filesystem",
        position = "left",
        toggle = true,
      })

      if vim.api.nvim_win_is_valid(terminal_win) then
        vim.api.nvim_set_current_win(terminal_win)
        vim.cmd.startinsert()
      end
    end, {
      noremap = true,
      silent = true,
      nowait = true,
      desc = "Toggle Neo-tree without leaving terminal",
    })

    local editor_function_keys = {
      "<S-F1>",
      "<F13>",
      "<F2>",
      "<S-F2>",
      "<F5>",
      "<S-F5>",
      "<F6>",
      "<S-F6>",
      "<F7>",
      "<S-F7>",
      "<F8>",
      "<S-F8>",
      "<F19>",
      "<F9>",
      "<F10>",
      "<F11>",
      "<F12>",
      "<S-F9>",
      "<S-F10>",
      "<S-F11>",
      "<S-F12>",
      "<F21>",
      "<F22>",
      "<F23>",
      "<F24>",
    }

    for _, key in ipairs(editor_function_keys) do
      local editor_key = key
      vim.keymap.set("t", editor_key, function()
        forward_to_editor(editor_key)
      end, {
        noremap = true,
        silent = true,
        nowait = true,
        desc = "Run " .. editor_key .. " in the editor",
      })
    end
  end,
}
Click to expand and view more

The longest config file. The idea: while focus is inside the terminal, all familiar function keys (sessions, search, git commit, linting, etc.) should continue to work as if the focus were in a regular buffer. For this, forward_to_editor before executing the key switches to the last “editor” window (toggleterm_last_editor_win, remembered in on_open), finds its actual mapping (callback or rhs), and executes it there already - and this entire list of keys (editor_function_keys) is rebind in terminal mode to this function.

The terminal switches only via regular F4: Shift+F4/F16 are now given to Noice history via Telescope. Separately - keep_bottom_terminal_full_width: the terminal is always stretched to full width (wincmd J moves it down across all columns), even if a vertical split or Neo-tree is open.

lua/plugins/treesitter.lua

LUA
return {
  "nvim-treesitter/nvim-treesitter",
  branch = "master",
  build = ":TSUpdate",
  event = { "BufReadPost", "BufNewFile" },
  opts = {
    auto_install = true,
    ensure_installed = {
      "lua",
      "vim",
      "vimdoc",
      "bash",
      "python",
      "json",
      "sql",
      "yaml",
      "toml",
      "markdown",
      "tmux",
      "ssh_config",
      "terraform",
      "nginx",
      "groovy",
      "pem",
      "dockerfile",
      "javascript",
      "css",
      "html",
    },
    -- Avoid concurrent installers racing over tree-sitter-<parser>-tmp
    -- while bootstrapping Neovim on a clean host.
    sync_install = true,
    highlight = {
      enable = true,
      additional_vim_regex_highlighting = false,
    },
    indent = {
      enable = true,
    },
  },
  config = function(_, opts)
    local ok, configs = pcall(require, "nvim-treesitter.configs")
    if not ok then
      vim.notify("nvim-treesitter legacy config module is unavailable", vim.log.levels.WARN)
      return
    end

    local function first_node(match, capture)
      local nodes = match[capture]
      return type(nodes) == "table" and nodes[1] or nodes
    end

    vim.treesitter.query.add_directive("set-lang-from-info-string!", function(match, _, bufnr, pred, metadata)
      local node = first_node(match, pred[2])
      if not node then
        return
      end

      local alias = vim.treesitter.get_node_text(node, bufnr):lower()
      local language_aliases = {
        ex = "elixir",
        pl = "perl",
        sh = "bash",
        ts = "typescript",
        uxn = "uxntal",
      }
      metadata["injection.language"] = vim.filetype.match({ filename = "a." .. alias })
        or language_aliases[alias]
        or alias
    end, { force = true })

    vim.treesitter.query.add_directive("set-lang-from-mimetype!", function(match, _, bufnr, pred, metadata)
      local node = first_node(match, pred[2])
      if not node then
        return
      end

      local mime = vim.treesitter.get_node_text(node, bufnr)
      local mime_languages = {
        importmap = "json",
        module = "javascript",
        ["application/ecmascript"] = "javascript",
        ["text/ecmascript"] = "javascript",
      }
      local parts = vim.split(mime, "/", { plain = true })
      metadata["injection.language"] = mime_languages[mime] or parts[#parts]
    end, { force = true })

    vim.treesitter.query.add_directive("downcase!", function(match, _, bufnr, pred, metadata)
      local capture = pred[2]
      local node = first_node(match, capture)
      if not node then
        return
      end

      metadata[capture] = metadata[capture] or {}
      local text = vim.treesitter.get_node_text(node, bufnr, { metadata = metadata[capture] }) or ""
      metadata[capture].text = text:lower()
    end, { force = true })

    -- GitHub's codeload archive endpoint sometimes 404s for pinned parser
    -- revisions that aren't a branch tip (e.g. tree-sitter-html), which
    -- surfaces as "gzip: stdin: not in gzip format". `git clone` of the
    -- same commit works fine, so prefer it over tarball downloads.
    require("nvim-treesitter.install").prefer_git = true

    configs.setup(opts)
  end,
}
Click to expand and view more

The ensure_installed list covers languages and formats I work with regularly - from lua/bash/python to terraform/nginx/dockerfile. auto_install = true automatically installs a missing parser when opening a file of a supported type; if there’s no ready parser for a language, Neovim just continues to work without Treesitter highlighting. sync_install = true installs parsers sequentially, not in parallel - without this, on a clean machine several installers running in parallel would race over the temporary tree-sitter-<parser>-tmp directory.

Three redefined directives (set-lang-from-info-string!, set-lang-from-mimetype!, downcase!) - a compatibility layer for the capture arrays of the new Treesitter API in Neovim 0.13-dev. Without it, re-parsing Markdown in render-markdown.nvim could crash with attempt to call method range (a nil value).

prefer_git = true - a separate story: codeload.github.com sometimes returns 404 on the archive for a pinned commit of a parser that isn’t a branch tip (I caught this on tree-sitter-html), while git clone of the same commit works normally. By default, prefer_git is only enabled on Windows; here it’s explicitly enabled on Linux/Mac as well.

Afterword

The move from init.vim to Lua took longer, but with the rise of AI development accelerated significantly. I intentionally left LSP disabled: for most tasks, lightweight linters and formatters are enough without running heavy language servers. But when needed, everything can be enabled with one line.

At first, I wanted to keep the configuration just as “minimalist” - stored in one or two files. But as the config grew, the modular file structure completely justified itself: need to disable or change something - go to a specific file, fix what’s needed. Of course, this is a matter of taste, but personally I find such a config simply pleasant to maintain 🐧.

Thanks for reading. Good luck exiting Neovim!

References

Comments

Copyright Notice

Author: Ivan Cherniy

Link: https://r4ven.me/en/dots/neovim-lua-konfig-redaktora-polnyy-razbor-po-faylam/

License: CC BY-NC-SA 4.0

Blog materials may be used with attribution to the author and source, for non-commercial purposes, and under the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut