--[[ 50-quit_guard.lua — confirm before quitting Neovim while a terminal job is still running. Neovim only warns about *visible* terminals and unsaved files, so a job left running in a hidden terminal buffer (e.g. a backgrounded `claude`) is killed without warning when Neovim exits. Autocmds can't veto a quit (`QuitPre`/`ExitPre` are notify-only), so the exit commands are wrapped to prompt first. The quit-all commands always exit, so they always prompt; `:q`/`:wq`/`:x`/`ZZ` only prompt when they would close the last window. A `!` (e.g. `:qa!`), `ZQ`, and closing a split all skip the prompt. ]] -- Display names of every terminal buffer whose job is still alive. local function running_jobs() local names = {} for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.bo[buf].buftype == "terminal" then local job = vim.b[buf].terminal_job_id -- `jobwait` with a 0 timeout returns -1 while the job is still running. if job and vim.fn.jobwait({ job }, 0)[1] == -1 then table.insert(names, vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":t")) end end end return names end -- True when it's safe to quit: no running jobs, or the user confirms at the prompt. local function may_quit() local jobs = running_jobs() if #jobs == 0 then return true end local msg = ("Terminal job%s still running: %s\nQuit anyway?"):format( #jobs > 1 and "s" or "", table.concat(jobs, ", ") ) return vim.fn.confirm(msg, "&Quit\n&Cancel", 2) == 1 end -- Whether quitting the current window now would exit Neovim (last window of the last tabpage). local function is_last_window() return vim.fn.tabpagenr("$") == 1 and vim.fn.winnr("$") == 1 end -- Run `real` (a quit ex-command) unless a terminal job is running and the quit would actually exit -- Neovim, in which case prompt first. `always_exits` is true for the quit-all family; otherwise the -- quit only exits when it's the last window. A `!` always skips the prompt. local function run_guarded(real, always_exits, bang) if bang or not (always_exits or is_last_window()) or may_quit() then vim.cmd({ cmd = real, bang = bang }) end end -- Define an uppercase user command `name` wrapping `real`, then alias the lowercase `spellings` to it -- via command-line abbreviations that only fire when the spelling is the whole `:`-command typed (so a -- trailing `!` and arguments elsewhere are untouched). local function guard(name, real, always_exits, spellings) vim.api.nvim_create_user_command(name, function(o) run_guarded(real, always_exits, o.bang) end, { bang = true, desc = "Quit (guarded against running terminal jobs)" }) for _, s in ipairs(spellings) do vim.cmd( ("cnoreabbrev %s (getcmdtype() ==# ':' && getcmdline() ==# '%s') ? '%s' : '%s'"):format( s, s, name, s ) ) end end -- Quit-all family: always exits Neovim, so always guard. guard("Quitall", "quitall", true, { "qa", "qall", "quita", "quitall" }) guard("Wqall", "wqall", true, { "wqa", "wqall" }) guard("Xall", "xall", true, { "xa", "xall" }) -- Single-window quits: only exit (and so only prompt) when it's the last window. guard("Quit", "quit", false, { "q", "quit" }) guard("Wq", "wq", false, { "wq", "wquit" }) guard("Xit", "x", false, { "x", "xit" }) -- Normal-mode quits bypass the command-line abbreviations, so guard them directly. `ZZ` writes the -- buffer if modified and closes the window (like `:x`); `q` quits the window (like `:quit`). -- (`ZQ` is a deliberate force-quit, so it is left to skip the prompt.) vim.keymap.set("n", "ZZ", function() run_guarded("x", false, false) end, { desc = "Write & quit (guarded against running terminal jobs)" }) vim.keymap.set("n", "q", function() run_guarded("quit", false, false) end, { desc = "Quit window (guarded against running terminal jobs)" })