Довёл до готовности свой конфиг Neovim на Lua и lazy.nvim, и в статье расскажу: что даёт конфиг из коробки, как поставить его одной командой, ну и для любопытных разбор каждого файла - по порядку структуры проекта.
🖐️Эй!
Подписывайтесь на наш телеграм @r4ven_me📱, чтобы не пропустить новые публикации на сайте😉. А если есть вопросы или желание пообщаться по тематике - заглядывайте в Вороний чат @r4ven_me_chat🧐. Также в блоге теперь доступно соавторство 🐧🐧🐧.
Предисловие

Про предыдущий конфиг на init.vim и vim-plug у меня есть целая прошлых старых заметок:
- Neovim - конфигурация редактора: базовая настройка
- Neovim - конфигурация редактора: swap, backup и undo файлы
- Neovim - конфигурация редактора: настройка горячих клавиш и автозапуск команд
- Neovim - конфигурация редактора: установка и настройка плагинов
- Neovim - установка и настройка редактора кода с элементами IDE всего в несколько команд
Тот конфиг вполне рабочий, но я давно собирался сесть и переписал всё на Lua вместо vimscript. Модульная структура файлов вместо одного файла-портянки.
Отдельно стоит упомянуть LazyVim. Это готовый дистрибутив конфигурации Neovim с уже подобранными плагинами, LSP, автодополнением, поиском и горячими клавишами. Внутри LazyVim использует менеджер плагинов lazy.nvim, а дополнительные плагины подключаются обычными Lua-таблицами - plugin specs.
В моём конфиге сам LazyVim как готовый дистрибутив не установлен, но используется тот же lazy.nvim и тот же формат описания плагинов. Поэтому каждый файл в lua/plugins возвращает стандартную spec-таблицу с полями вроде event, cmd, keys, dependencies, opts и config. Сделано это ради унификации конфига и некой “единости” формата. Заглянув в код вы поймёте о чём я.
И так, ниже - карта готового результата: что конфиг умеет, как его поставить и что содержит каждый файл.
Вводные данные
| ПО | Версия |
|---|---|
| LMDE/Debian/Ubuntu | 7/13/24 |
| Neovim | 0.11+ |
| lazy.nvim | 11.17 |
| Тема | Nord (shaunsingh/nord.nvim) |
💡 Для корректной отрисовки иконок (bufferline, neo-tree, статусная строка) нужен моноширный Nerd Font.
Мои читатели знают, что я предпочитаю шрифт Hack ☝️. Вот простой пример, как его можно установить:
📝 Примечание
Обратите вниманием, что для выполнения команд потребуются права sudo. Либо установите шрифты только для текущего пользователя в директории ~/.local/share/fonts.
# создаем директорию шрифта
sudo mkdir /usr/share/fonts/Hack
# скачиваем архив со шрифтами
curl -fsSLO \
$(curl -s https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest \
| grep browser_download_url \
| grep 'Hack.zip' \
| cut -d '"' -f 4)
# распаковываем архив, коприруем шрифты в систем
sudo unzip ./Hack.zip -d /usr/share/fonts/Hack/ && rm -f ./Hack.zip📝 Примечание
В команде curl используется механизм подстановки командной строки. Т.е. основной команде на скачивание: curl -fsSLO передается аргумент, который является результатом выполнения другой команды внутри конструкции $(command), выполняющейся предварительно. В итоге основная команда получит прямой URL на zip файл последнего релиза шрифта Hack из GitHub. Команда универсальна.

После установки шрифта, активируйте его в настройках вашего терминала🛠.
В Gnome-terminal это делается так:

Что даёт конфиг
Конфиг не пытается быть полноценной IDE - фокус на приятном UI Neovim, быстром старте, подсветке синтаксиса, опциональной диагностике через внешние CLI-линтеры и форматировании по требованию. + плагин для работы с ИИ агентами. Но про это будет отдельная статья.
Что идёт из коробки:
- тема Nord с ручной донастройкой части highlight-групп поверх темы (курсор, разделители окон, диагностика);
- статусная строка lualine и вкладки-буферы bufferline сверху, обе в Nord-палитре;
- боковая панель с деревом проекта neo-tree (
F3), с показом скрытых и игнорируемых git-файлов; - быстрый поиск telescope - по файлам и по содержимому в директории текущего файла (
F2/Shift+F2); через него же работают интерактивные списки выбора и история сообщений; - современная подсветка синтаксиса через Treesitter, с автоустановкой парсеров под десяток языков и форматов;
- плавающий/нижний терминал toggleterm (
F4), растянутый на всю ширину, с прозрачной переброской функциональных клавиш обратно в редактор, даже когда фокус внутри терминала; - git-статус построчно через gitsigns (
+/~/_в колонке знаков); - лёгкая асинхронная проверка кода через nvim-lint и форматирование по требованию через conform.nvim;
- заготовка LSP, выключенная по умолчанию - включается одной строкой;
- красивые уведомления и command line через noice.nvim/nvim-notify, история сообщений через Telescope (
Shift+F4), автосохранение и восстановление сессий, стартовый экран, автопарные скобки/кавычки, направляющие отступов, полоса прокрутки и поддержка команд:в русской раскладке; - AI-чат CodeCompanion в правой панели (
F5илиleader+ai) с красивым Markdown черезrender-markdown.nvim, контекстом редактора и переключением между любыми моделями, включая Codex и Claude Code - оба агента подключаются через ACP/CLI и могут использовать подписочную авторизацию без отдельных API-ключей.
Почти быстрый старт
Установка Neovim из стандартных репозиторив
Актуальная версия конфига требует Neovim 0.11 или новее. Сначала проверяем установленную версию:
nvim --versionЕсли дистрибутив уже предоставляет подходящую версию, достаточно поставить базовые зависимости через пакетный менеджер. Для Debian/Ubuntu:
sudo apt update && sudo apt install -y neovim git ripgrepВ стабильных репозиториях некоторых дистрибутивов всё ещё может лежать Neovim 0.10. В таком случае редактор лучше собрать самостоятельно.
Сборка Neovim из исходников
Ставим инструменты сборки и необходимые библиотеки:
sudo apt install -y ninja-build gettext cmake unzip curl build-essentialКлонируем официальный репозиторий, собираем release-вариант:
git clone https://github.com/neovim/neovim /tmp/neovim
cd /tmp/neovim
git checkout stable
make CMAKE_BUILD_TYPE=Release
И устанавливаем его в системный каталог исполняемых файлов:
sudo make install
После установки переинициализируем оболочку и проверяем, что запускается новая версия:
exec $SHELL
# или
hash -r
command -v nvim
nvim --version
Обычно собранный редактор оказывается в /usr/local/bin/nvim.
☝️ Если оболочка продолжает находить старый /usr/bin/nvim, нужно проверить порядок каталогов в $PATH.
Установка линтеров
Линтеры предпочтительно устанавливать из стандартных репозиториев, т.к. системные пакеты обновляются вместе с дистрибутивом.
Все линтеры, для которых в конфиге есть системные fallback-варианты, ставятся одной командой:
sudo apt install -y shellcheck lua-check pylint yamllint jsonlintruff конфиг тоже поддерживает, но в стандартном репозитории LMDE 7 / Debian 13 его сейчас нет. Поэтому для Python предусмотрен pylint: ставить Ruff через pip только ради работы F7 необязательно.
Доступные в стандартных репозиториях форматтеры устанавливаем отдельно:
sudo apt install -y shfmt jq blackstylua, yamlfmt и prettier остаются опциональными: их имеет смысл ставить отдельно при необходимости. Если какого-либо инструмента нет в $PATH, конфиг просто не включает его и продолжает работать.
Установка моего конфига
Бэкап текущего конфига, если он есть:
test -d ~/.config/nvim && mv -v ~/.config/nvim{,.backup}Теперь клонируем сам конфиг через git прямо в ~/.config/nvim:
git clone --branch raven https://github.com/r4ven-me/neovim ~/.config/nvim📝 Примечание
Для AI-чата нужны Node.js 22+ и ACP-адаптеры Claude/Codex:
npm install -g node@22
npm install -g @agentclientprotocol/claude-agent-acp @agentclientprotocol/codex-acpАвторизация агентов настраивается отдельно: Codex может использовать аккаунт ChatGPT, а Claude Code - OAuth подписки Pro/Max или Anthropic API.
Установку CLI/ACP, безопасное хранение токенов и проверку окружения подробно разберу в отдельной заметке.
Первый запуск:
cd ~/.config/nvim
nvim ./init.luaНа первом старте конфиг сам клонирует lazy.nvim, ставит плагины из lua/plugins, создаёт служебные директории под ~/.local/state/nvim и подтягивает нужные парсеры Treesitter (займёт некоторое время:)


💡 Чтобы закрыть окно плагина Lazy, нажмите клавишу q.
📝 Проверить или обновить список плагинов в любой момент можно командой :Lazy внутри редактора.
Кастомные горячие клавиши

Ниже приведены дополнительные/изменённые горячие клавиши редактора:
📝 В качестве leader используется Space.
| Клавиша | Действие |
|---|---|
Shift+F1 | Открыть встроенную справку по конфигурации |
F13 | Выполнить git add, коммит с сообщением Upd и git push для директории текущего файла |
F2 | Найти файл в директории текущего файла |
Shift+F2 | Найти текст в файлах директории текущего файла |
F3 | Открыть или скрыть Neo-tree |
Shift+F3 / F15 | Отформатировать текущий буфер или visual-выделение |
F4 | Открыть или скрыть нижний терминал |
Shift+F4 | Открыть или скрыть историю сообщений Noice через Telescope |
F5 | Открыть или скрыть AI-чат CodeCompanion |
Shift+F5 | Сохранить и запустить текущий shell- или Python-файл |
F6 | Показать диагностику workspace в Telescope |
Shift+F6 | Показать диагностику текущего буфера в Telescope |
F7 | Включить линтинг и сразу запустить проверку |
Shift+F7 / F19 | Выключить линтинг и очистить диагностику |
F8 | Показать Git status в Telescope |
Shift+F8 | Показать Git-историю текущего файла |
F9-F12 | Загрузить одну из четырёх нумерованных сессий |
Shift+F9-Shift+F12 | Сохранить одну из четырёх нумерованных сессий |
Shift+h / Shift+l | Перейти к предыдущему или следующему буферу |
Shift+MouseWheel | Переместить текущий буфер влево или вправо |
Ctrl+Up / Ctrl+Down | Увеличить или уменьшить высоту нижнего терминала |
Space f | Отформатировать текущий буфер или visual-выделение |
Space d | Показать диагностику текущей строки |
[d / ]d | Перейти к предыдущей или следующей диагностике |
Space ai | Открыть или скрыть чат CodeCompanion |
Space aa | Открыть палитру действий CodeCompanion |
WW | Сохранить текущий файл |
WS | Сохранить текущую сессию AutoSession |
WR | Восстановить последнюю сессию AutoSession |
jk | Выйти из insert-режима |
Esc Esc | Закрыть сообщения или убрать подсветку поиска |
Структура конфига

.
├── init.lua
├── lua
│ ├── config
│ │ ├── autocmds.lua
│ │ ├── keymaps.lua
│ │ ├── lazy.lua
│ │ └── options.lua
│ └── plugins
│ ├── auto-session.lua # автосохранение и восстановление сессий
│ ├── autopairs.lua # автоматическое закрытие скобок и кавычек
│ ├── bufferline.lua # строка открытых буферов и UI-кнопки
│ ├── codecompanion.lua # AI-чат с Claude Code и Codex
│ ├── conform.lua # форматирование кода по требованию
│ ├── gitsigns.lua # Git-изменения в колонке знаков
│ ├── indent-blankline.lua # направляющие уровней отступов
│ ├── lint.lua # асинхронный запуск внешних линтеров
│ ├── lsp.lua # опциональная заготовка LSP
│ ├── lualine.lua # нижняя статусная строка
│ ├── neo-tree.lua # дерево файлов проекта
│ ├── noice.lua # command line, уведомления и их история
│ ├── nord.lua # тема Nord и дополнительные highlight-группы
│ ├── ruscmd.lua # команды Neovim в русской раскладке
│ ├── scrollview.lua # полоса прокрутки текущего окна
│ ├── startify.lua # стартовый экран и недавние файлы
│ ├── telescope.lua # поиск, диагностика и интерактивные списки
│ ├── toggleterm.lua # нижний терминал на всю ширину
│ └── treesitter.lua # синтаксическая подсветка и парсерыПринцип простой: lua/config - это “движок” (опции, клавиши, автокоманды, загрузчик плагинов), а lua/plugins - один файл на один плагин, в обычном формате спеки lazy.nvim. Хочешь добавить плагин - создаёшь файл, возвращающий таблицу-спеку, lazy.nvim подхватит его сам через { import = "plugins" }. Никакого центрального списка плагинов, который приходится скроллить, чтобы понять, откуда взялась одна конкретная настройка.
В целом для работы с конфигом больше ничего не нужно. Дальше пойдёт подробный разбор каждого файла структуры по порядку, сверху вниз. Это уже для любопытных 😉.
init.lua
require("config.options")
require("config.keymaps")
require("config.autocmds")
require("config.lazy")Тут только подключение четырёх модулей config. Именно они, в этом же порядке, задают опции, клавиши, автокоманды и запускают lazy.nvim.
Директория lua/config
lua/config/options.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Тут заслуживают отдельного упоминания несколько мест.
Каталоги состояния (swap, undo, backup, сессии, ShaDa) вынесены из ~/.local/share/nvim в ~/.local/state/nvim. Отдельная функция clean_stale_shada_tmp чистит зависшие main.shada.tmp.* файлы старше суток, чтобы избежать ошибки E138 после аварийного завершения сеанса.
clipboard = "unnamedplus" - буфер обмена системы и безымянный регистр Neovim общие, p/P работают с тем, что скопировано вне редактора, без лишних "+.
more = false отключает встроенный pager -- More --. На первом запуске Treesitter печатает много строк при скачивании и компиляции парсеров; без этой настройки установка останавливалась после каждого экрана и ждала Enter, пробел или прокрутку вниз.
sessionoptions включает terminal - при сохранении сессии восстанавливаются и открытые терминалы toggleterm.
В конце файла - диагностические знаки заменены на буквы E/W/H/I вместо стандартных иконок-закорючек: на любом шрифте без иконок остаются читаемыми.
lua/config/keymaps.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" })Базовые ремапы сверху: Enter в нормальном режиме вставляет строку ниже, Space - пробел без входа в insert, jk - выход из insert, x/X не затирают регистр ("_x), а вставка в visual-режиме меняет p/P местами - удобно, когда вставляешь поверх выделенного и хочешь, чтобы старое выделение не улетело в регистр.
Shift+F1 открывает локальную справку конфига (:help nvim-config), а F13 оставлен под мой персональный “быстрый коммит”, который добавляет, коммитит с сообщением Upd 🙃 и пушит директорию текущего файла. Для рабочих репозиториев так не делаем, но для личных дотфайлов и заметок - самое то, экономит секунд десять на каждое сохранение.
Дальше - четыре слота сессий на F9-F12 (сохранение - Shift-версии, плюс F21-F24 как дубль для терминалов без Shift+Fn): без плагина, просто mksession!/source по фиксированным путям в state-директории.
В конце - навигация по диагностике ([d/]d, leader+d) и Esc Esc, который закрывает всплывающие сообщения Noice; fallback на nohlsearch остаётся на случай запуска без плагина.
lua/config/autocmds.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,
})Тут также стоит выделить две вещи.
Первая - обычное восстановление позиции курсора при открытии файла (аналог классического au BufReadPost * if line("'\"") > 1 ...), но переписанное через nvim_create_autocmd, с явным исключением commit/rebase буферов, чтобы курсор не улетал в конец сообщения коммита.
Вторая - поведение при :q (QuitPre). Если в текущей вкладке кроме “рабочего” буфера остались только Neo-tree и терминал, они закрываются автоматически вместе с ним.
Заодно тут же переопределяются отступы под lua/json/yaml/toml на 2 пробела (экосистемы этих языков де-факто того требуют) и вешается Shift+F5 на save-and-run для sh/bash/zsh/python (тут будьте осторожны!).
lua/config/lazy.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",
},
},
},
})Классический бутстрап lazy.nvim: если его ещё нет в stdpath("data"), клонируется с GitHub мелким --filter=blob:none клоном, дальше добавляется в runtimepath и запускается setup. spec = { { import = "plugins" } } - это место, откуда lazy.nvim подхватывает файлы из lua/plugins. install.colorscheme = { "nord" } гарантирует, что даже при первой установке, пока плагины ещё ставятся, редактор не мигает стандартной серой темой. checker.enabled = false отключает фоновую проверку обновлений и уведомления о них - я привык обновлять всё вручную. rocks.enabled = false выключает hererocks - без него lazy.nvim при каждом старте молча пытается поднять собственные Lua 5.1 и luarocks, а когда это не выходит - сыплет предупреждениями в лог; на данный момент ни один плагин в конфиге rocks-зависимостей не объявляет, так что это просто шум. В performance.rtp.disabled_plugins отключены штатные плагины Neovim, которые мне не нужны (netrw, tutor, архиваторы и т.д.) - чуть быстрее старт редактора.
Директория lua/plugins
lua/plugins/auto-session.lua
Горячая клавиша - WS сохраняет текущую сессию, WR восстанавливает последнюю:

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" },
},
}Автосохранение при выходе включено, а автоматическое восстановление при старте намеренно выключено: случайный запуск nvim в другой директории не должен поднимать старый набор буферов. Сессию сохраняю через WS, восстанавливаю через WR; используются актуальные команды AutoSession save и AutoSession restore.
Перед записью сессии Neo-tree временно закрывается, чтобы его служебный буфер не попадал в layout. Состояние дерева и активное окно запоминаются: сразу после сохранения панель возвращается только в том случае, если была открыта до WS, а фокус остаётся в прежнем редакторском окне. После восстановления сессии Neo-tree открывается заново.
lua/plugins/autopairs.lua

return {
"windwp/nvim-autopairs",
event = "InsertEnter",
opts = {},
}Автозакрытие скобок и кавычек, без единой кастомной настройки - дефолтов nvim-autopairs достаточно. Загружается “лениво”, по входу в insert-режим.
lua/plugins/bufferline.lua
Горячая клавиша - Shift+h и Shift+l переключают предыдущий и следующий буфер:

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,
}Помимо самих вкладок (Shift+h/Shift+l для переключения, закрытие через Bdelete!, чтобы не ломался layout окон) тут есть трюк с custom_areas: по краям строки табов рисуются кликабельные зоны, которые через %@FuncName@...%X вызывают vimscript-обёртки. Слева открывается Neo-tree, справа находятся кнопки AI-чата и нижнего терминала. Кнопки разделены символом ; компьютер вызывает CodeCompanionChat Toggle, терминал - toggleterm.
Bufferline с несколькими открытыми буферами и кликабельными областями по краям
lua/plugins/codecompanion.lua
Горячая клавиша - F5 или Space ai открывает и закрывает AI-чат, Space aa - палитру действий:

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",
},
},
}CodeCompanion.nvim даёт AI-чат в правой вертикальной панели. Открыть или скрыть его можно через F5, leader+ai, команду :CodeCompanionChat Toggle или кнопку в Bufferline; leader+aa открывает палитру действий для текущего буфера или visual-выделения. Ответы оформляет render-markdown.nvim.
Основной chat-адаптер - Codex ACP с авторизацией через аккаунт ChatGPT. При необходимости внутри чата через ga выбираются другой адаптер и модель, в том числе Claude Code; telescope-ui-select.nvim превращает этот выбор в нормальный Telescope dropdown. Отдельный CodeCompanionCLI запускает уже авторизованные команды claude и codex в терминальном окне.
Автокоманда FileType после открытия чата возвращает нижний ToggleTerm в самый низ, поэтому правая панель не уменьшает ширину терминала. Проверка vim.fn.has("nvim-0.11") не даёт несовместимому плагину загрузиться на старом Neovim.
📝 Установку Claude Code, Codex и ACP-агентов, подписочную и API-авторизацию, выбор моделей, режимы Chat/CLI, Telescope, работу с контекстом, исправление layout и диагностику ошибок подробно разберу в отдельной заметке.
lua/plugins/conform.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" },
},
},
}Форматирование только по требованию - leader+f или Shift+F3/F15 (дубль на случай, если терминал шлёт именно этот код), никакого format-on-save. Для каждого языка задан приоритетный форматтер и запасной (например, для Python сначала ruff_format, при его отсутствии - black). notify_on_error = false - если нужный форматтер не установлен, conform.nvim просто молча пропускает шаг, а не сыплет уведомлениями об ошибке.
lua/plugins/gitsigns.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,
},
}Знаки изменений git в колонке слева от номеров строк. current_line_blame выключен намеренно - значков в gutter достаточно, blame по каждой строке только отвлекает.
Знаки gitsigns в колонке слева от номеров строк
lua/plugins/indent-blankline.lua

return {
"lukas-reineke/indent-blankline.nvim",
main = "ibl",
event = { "BufReadPost", "BufNewFile" },
opts = {
scope = {
show_start = false,
show_end = true,
},
},
}Вертикальные направляющие отступов. show_start = false убирает подсветку первой строки текущего блока (она и так видна по курсору), show_end = true оставляет подсветку последней - удобно видеть, где блок закрывается, особенно в глубоко вложенном коде.
lua/plugins/lint.lua
Горячая клавиша - F7 включает линтинг и сразу его запускает, Shift+F7/F19 выключает и очищает диагностику:

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,
}Линтинг выключен по умолчанию. F7 включает его и сразу запускает первую проверку; пока режим активен, следующие проверки стартуют через 500 мс после BufEnter, сохранения, выхода из insert-режима или изменения текста. Debounce не запускает новый внешний процесс на каждое нажатие клавиши, но диагностика остаётся практически живой.
Таймер хранится отдельно для каждого буфера. Это важно при быстром переключении файлов: отложенная проверка всегда выполняется в том буфере, где произошло изменение, и не может случайно примениться к уже открытому соседнему файлу.
F7 явно включает линтинг и сразу запускает проверку; Shift+F7 или F19 отключает автоматические проверки и очищает diagnostics. Команды :LinterToggle и :LintToggle переключают тот же режим. При ручном запуске показывается имя запущенного линтера, а если для текущего filetype ни один установленный инструмент не найден - понятное предупреждение вместо молчаливого no-op.
Список линтеров собирается динамически через vim.fn.executable: используются только команды, реально доступные в $PATH. Для shell-файлов shellcheck читает текущий буфер из stdin в формате json1, поэтому видит ещё не сохранённые изменения.
Диагностика shellcheck от автоматически обновляемого nvim-lint
lua/plugins/lsp.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,
}Заготовка полноценного LSP, выключенная через enabled = false. Для 90% задач мне хватает лёгких линтеров и форматтеров выше, без запуска тяжёлых языковых серверов. Когда всё-таки нужен go-to-definition и прочие возможности LSP - ставлю нужные серверы, переключаю enabled на true и donастраиваю блок config: примеры для lua_ls и pyright уже внутри.
lua/plugins/lualine.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" },
},
}Тема прописана вручную под Nord-палитру, а не взята из готовых пресетов lualine - разные цвета a-секции под режим (normal/insert/visual/replace/command) сразу показывают, в каком режиме находится редактор, даже боковым зрением. globalstatus = true - одна статусная строка на все окна, а не по одной на каждое. path = 1 в filename - показывать путь относительно текущей директории, а не только имя файла.
lua/plugins/neo-tree.lua
Горячая клавиша - F3 открывает и закрывает Neo-tree:

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,
},
},
},
}Боковая панель на F3, close_if_last_window = true - закрывается сама, если остаётся единственным окном (тот самый недостающий кусочек, который вместе с QuitPre из autocmds.lua убирает E444). Скрытые и игнорируемые git файлы не прячутся - hide_dotfiles/hide_gitignored оба false.
Отдельный event_handlers - это стык с toggleterm: если Neo-tree открывают из терминала (там есть свой биндинг на F3, см. ниже), после открытия панели фокус и ширина нижнего терминала восстанавливаются, чтобы он не “съёживался” под новую ширину окна.
lua/plugins/noice.lua
Горячая клавиша - Shift+F4/F16 открывает и закрывает историю сообщений через Telescope:

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,
},
},
}noice.nvim заменяет стандартный вывод сообщений и command line на аккуратные всплывающие окна, а nvim-notify показывает уведомления в правом верхнем углу. Командная строка : открывается по центру; поиск / и ? благодаря bottom_search = true остаётся снизу. Двойной Esc закрывает текущие сообщения.
Shift+F4 (и терминальный дубль F16) открывает историю сообщений Noice через Telescope. Внутри списка работают фильтрация вводом, стрелки или Ctrl+n/Ctrl+p, Enter и Esc; повторный Shift+F4 тоже закрывает picker. Ту же историю можно вызвать командой :Noice telescope.
lua/plugins/nord.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,
}priority = 1000 и lazy = false - тема должна загрузиться раньше всех остальных плагинов, чтобы не было “мигания” стандартных цветов при старте. Поверх стандартной палитры Nord накатывается набор ручных правок: курсор перекрашен под каждый режим (Cursor/iCursor/rCursor/CursorIM) так, чтобы он был заметен на любом фоне, а не терялся, как это бывает у части colorscheme “из коробки”, плюс явные цвета для разделителей окон и диагностики. set_highlights вызывается не только сразу, но и повторно на автокоманде ColorScheme - если схема будет перезагружена, ручные правки не потеряются.
lua/plugins/ruscmd.lua

return {
"powerman/vim-plugin-ruscmd",
event = "VimEnter",
}Позволяет выполнять команды : даже если забыл переключить раскладку с русской на английскую. Мелочь, но полезная.
lua/plugins/scrollview.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,
},
}Полоса прокрутки справа - только для текущего окна (scrollview_current_only), скрывается, когда пересекается с курсором или с текстом на экране (hide_on_cursor_intersect/hide_on_text_intersect), и не показывается в Neo-tree.
lua/plugins/startify.lua

return {
"mhinz/vim-startify",
event = "VimEnter",
init = function()
vim.g.startify_session_dir = vim.fn.stdpath("state") .. "/sessions"
end,
}Стартовый экран со списком недавних файлов и сессий при запуске без аргументов. Единственная кастомизация - каталог сессий указывает туда же, куда пишут keymaps.lua (слоты F9-F12) и auto-session.lua.
lua/plugins/telescope.lua
Горячие клавиши - F2/Shift+F2 ищут файлы и текст в директории текущего файла, F6/Shift+F6 показывают диагностику workspace и буфера, F8/Shift+F8 - git status и историю текущего файла:

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,
}Поиск файлов в директории текущего файла на F2 и поиск по содержимому на Shift+F2.
Ключевое решение тут - cwd для find_files/live_grep берётся не из корня проекта и не из глобального cwd, а из директории текущего открытого файла (vim.fs.dirname). На монорепах из десятка сервисов глобальный поиск по всему дереву слишком шумный - обычно интересен поиск рядом с тем, над чем прямо сейчас работаешь. <Esc> закрывает Telescope и в insert-, и в normal-режиме prompt.
telescope-ui-select.nvim переопределяет стандартный vim.ui.select, поэтому выбор адаптера и модели CodeCompanion, а также другие системные списки получают поиск и нормальную навигацию стрелками. Кроме поиска, Telescope используется как единый интерфейс для диагностики и Git: F6 показывает диагностику всего workspace, Shift+F6 - только текущего буфера, F8 - изменённые файлы, Shift+F8 - историю текущего файла. Прежний список буферов на F5 убран: теперь эта клавиша переключает AI-чат CodeCompanion.
lua/plugins/toggleterm.lua
Горячая клавиша - F4 открывает и закрывает нижний терминал (или визуальная кнопка справа-вверху), растянутый на всю ширину; Ctrl+Up/Ctrl+Down меняют его высоту.

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,
}Самый длинный файл конфига. Идея: пока фокус внутри терминала, все привычные функциональные клавиши (сессии, поиск, git-коммит, линт и так далее) должны продолжать работать, как если бы фокус был в обычном буфере. Для этого forward_to_editor перед выполнением клавиши переключается на последнее “редакторское” окно (toggleterm_last_editor_win, запомненное в on_open), находит её реальный маппинг (callback либо rhs) и выполняет его уже там - а весь список этих клавиш (editor_function_keys) перепривязан в режиме терминала на эту функцию.
Терминал переключается только обычным F4: Shift+F4/F16 теперь отданы истории Noice через Telescope. Отдельно - keep_bottom_terminal_full_width: терминал всегда растянут на всю ширину (wincmd J двигает его вниз через все колонки), даже если открыт вертикальный сплит или Neo-tree.
lua/plugins/treesitter.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,
}Список ensure_installed покрывает языки и форматы, с которыми регулярно работаю - от lua/bash/python до terraform/nginx/dockerfile. auto_install = true автоматически ставит отсутствующий парсер при открытии файла поддерживаемого типа; если для языка нет готового парсера, Neovim просто продолжит работать без Treesitter-подсветки. sync_install = true ставит парсеры последовательно, а не параллельно - без этого на чистой машине несколько установщиков параллельно роняли друг друга гонкой за временную директорию tree-sitter-<parser>-tmp.
Три переопределённые директивы (set-lang-from-info-string!, set-lang-from-mimetype!, downcase!) - compatibility-слой для capture-массивов нового Treesitter API в Neovim 0.13-dev. Без него повторный разбор Markdown в render-markdown.nvim мог падать на attempt to call method range (a nil value).
prefer_git = true - отдельная история: codeload.github.com иногда отдаёт 404 на архив для запиненного коммита парсера, который не является кончиком ветки (ловил это на tree-sitter-html), а git clone того же коммита работает штатно. По умолчанию prefer_git включён только на Windows, здесь он явно включён и на Linux/Mac.
Послесловие

Переезд с init.vim на Lua затянулся, но с развитием ИИ дело пошло намного быстрее. LSP я специально оставил выключенным: для большинства задач хватает быстрых линтеров и форматтеров без запуска языковых серверов. Но при необходимости включить всё можно одной строкой.
Сперва я хотел всё также “минималистично” хранить конфигурацию в одном-двух файлах. Но при разрастании конфига модульная структура начинает полностью себя оправдывать: нужно выключить или изменить что-то одно - зашли в конкретный файл, поправили что нужно. Это конечно вкусовщина, лично мне такой конфиг банально удобнее поддерживать 🐧.
Спасибо, что читаете. Успехов в при выходе из Neovim!
Используемые материалы
- Конфиг из статьи на GitHub
- lazy.nvim
- nvim-treesitter
- Neovim - конфигурация редактора: базовая настройка
- Neovim - конфигурация редактора: установка и настройка плагинов
👨💻Ну и…
Не забывайте про нашу телегу📱и чат 💬
Или может хотите стать соавтором? Тогда клик сюда🔗
Всех благ✌️
That should be it. If not, check the logs 🙂



Комментарии