-- Terminal helpers layered on top of the native LazyVim/Snacks terminal.
--
-- Native behaviour is kept intact and untouched:
-- / toggle a bottom terminal at the project root (supports a
-- count, e.g. 2 opens a *separate* terminal #2)
-- fT terminal in the current working directory
--
-- This module adds:
-- open/focus a *dedicated* floating terminal (close it with )
-- ft a picker that lists every live terminal so any contextual
-- terminal can be focused or restored even after its window was
-- closed/hidden.
local M = {}
-- The floating terminal. Tracked as an upvalue so always re-focuses
-- the *same* float regardless of which directory the editor is in.
---@type snacks.win?
local float
--- Open or focus the floating terminal, or hide it if already focused.
-- NOTE: currently unused; calls open_float() instead so shell
-- tab-completion keeps working while the terminal is open. Kept around for a
-- future explicit open/close toggle.
function M.toggle_float()
-- Reuse the existing instance: hide if visible, re-show if hidden.
if float and float:buf_valid() then
if float:win_valid() then
float:hide()
else
float:show()
vim.cmd("startinsert")
end
return
end
-- First open: a high fixed count guarantees this terminal never collides with
-- the count-keyed bottom terminals (1, 2, 3 ...), so it is always the *same*
-- float for no matter the context.
float = Snacks.terminal(nil, {
count = 99,
win = { position = "float", border = "rounded" },
})
-- Close it with a single keystroke from inside the shell: hides the
-- window (buffer-local, so it only affects this floating terminal and never
-- the bottom split, where shell tab-completion must keep working).
if float and float.buf then
vim.keymap.set("t", "", function()
if float and float:win_valid() then
float:hide()
end
end, { buffer = float.buf, silent = true, desc = "Hide floating terminal" })
end
end
--- Open or focus the floating terminal. This never hides the window, so
--- once the float is open falls through to the shell and stays available
--- for tab-completion. Close the floating terminal with while it is
--- visible (handled by Snacks' native terminal keymap).
function M.open_float()
if float and float:buf_valid() then
if not float:win_valid() then
float:show()
end
if float:win_valid() then
vim.api.nvim_set_current_win(float.win)
end
vim.cmd("startinsert")
return
end
-- First open: a high fixed count guarantees this terminal never collides with
-- the count-keyed bottom terminals (1, 2, 3 ...), so it is always the *same*
-- float for no matter the context.
float = Snacks.terminal(nil, {
count = 99,
win = { position = "float", border = "rounded" },
})
end
--- List every live Snacks terminal in a picker. Restore/focus one with ,
--- kill one with .
function M.picker()
local terms = Snacks.terminal.list()
if #terms == 0 then
Snacks.notify.info("No open terminals")
return
end
local items = {} ---@type snacks.picker.finder.Item[]
for _, term in ipairs(terms) do
local meta = vim.b[term.buf] and vim.b[term.buf].snacks_terminal or {}
items[#items + 1] = {
term = term,
buf = term.buf,
id = meta.id,
cwd = meta.cwd or "?",
cmd = meta.cmd or vim.o.shell,
visible = term:win_valid(),
text = ("#%s %s %s"):format(meta.id or "?", meta.cwd or "?", meta.cmd or vim.o.shell),
}
end
Snacks.picker({
title = "Terminals",
items = items,
format = function(item)
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = {
item.visible and "● " or "○ ",
item.visible and "SnacksPickerSpecial" or "SnacksPickerComment",
}
ret[#ret + 1] = { ("#%-2s"):format(item.id or "?"), "Number" }
ret[#ret + 1] = { " " .. vim.fn.fnamemodify(item.cwd, ":~"), "SnacksPickerPath" }
ret[#ret + 1] = { " " .. vim.fn.fnamemodify(item.cmd, ":t"), "SnacksPickerComment" }
return ret
end,
confirm = function(picker, item)
picker:close()
local term = item.term
if not term:buf_valid() then
Snacks.notify.warn("Terminal no longer exists")
return
end
if term:win_valid() then
vim.api.nvim_set_current_win(term.win)
else
term:show()
vim.cmd("startinsert")
end
end,
win = { input = { keys = { ["d"] = "term_delete" } } },
actions = {
term_delete = function(picker, item)
local term = item.term
picker:close()
if term and term:buf_valid() then
term:close()
pcall(vim.api.nvim_buf_delete, term.buf, { force = true })
end
end,
},
})
end
return M