Exploration-first peek stack for Neovim — stack LSP/diagnostics/files/grep results without losing context.
peekstack.nvim keeps your exploration flow intact by stacking “peek” windows. You can move through the stack, then promote only the results you care about into splits or tabs.
Most peek workflows are built around viewing one thing at a time. peekstack.nvim focuses on preserving the trail of your exploration.
- Continuity: chase LSP/diagnostics/grep without breaking flow
- Context-preserving: move back/forward in a stack of popups
- Promote when needed: elevate only the interesting results to splits/tabs
- Session persistence: save and restore exploration sessions
- 🧭 Peek stack UI: stack / cascade / single layouts
- 🧱 Stack view: list popups, focus, pin, rename, history, preview syntax highlights
- 🌳 Tree guides: stack entries are grouped by navigation hierarchy
- 🔍 Providers: LSP / diagnostics / file / marks
- 🚀 Promote: fast split/tab promotion
- 🧷 Inline + quick peek: inline preview or ephemeral popups
- 🧩 Buffer modes: copy (default) / source
- 🔎 ripgrep integration:
rg-based grep search - 🧺 Picker integration: builtin / telescope / fzf-lua / snacks.nvim
- 💾 Persist: save/restore sessions + auto save/restore
- 🧹 Auto close: close stale popups by idle time
- Neovim ≥ 0.12
rg(only if you usegrep.search)- Optional:
telescope.nvim/fzf-lua/snacks.nvim(if you switch picker backends) - Optional: Tree-sitter parsers (for
ui.title.contextand stack view preview syntax highlighting; Neovim bundles the runtime, but parsers are separate)
Using lazy.nvim:
{
"mhiro2/peekstack.nvim",
config = function()
local peekstack = require("peekstack")
peekstack.setup({
-- Optional: enable additional providers
providers = {
marks = { enable = true }, -- browse vim marks
},
})
-- LSP: peek at definitions and references
vim.keymap.set("n", "<leader>pd", function() peekstack.peek.definition() end)
vim.keymap.set("n", "<leader>pr", function() peekstack.peek.references() end)
-- Diagnostics & files: peek at diagnostics or files under cursor
vim.keymap.set("n", "<leader>pl", function() peekstack.peek.diagnostics_cursor() end)
vim.keymap.set("n", "<leader>pf", function() peekstack.peek.file_under_cursor() end)
-- Marks: browse buffer marks (requires marks provider enabled)
vim.keymap.set("n", "<leader>pm", function() peekstack.peek.marks_buffer() end)
-- Utility: temporarily hide/show all popups in current stack
vim.keymap.set("n", "<leader>ph", "<cmd>PeekstackToggle<cr>", { desc = "Peekstack: toggle" })
end,
}-- Call by provider name
require("peekstack").peek("lsp.definition")
require("peekstack").peek("diagnostics.under_cursor")
require("peekstack").peek("file.under_cursor")
require("peekstack").peek("marks.buffer")
-- Inline preview (no stack)
require("peekstack").peek.definition({ mode = "inline" })
-- Quick peek (temporary, no stack)
require("peekstack").peek.references({ mode = "quick" })
-- Document symbols in current buffer
require("peekstack").peek.symbols_document()Built-in provider names:
lsp.definition, lsp.implementation, lsp.references, lsp.type_definition, lsp.declaration, lsp.symbols_document,
diagnostics.under_cursor, diagnostics.in_buffer, file.under_cursor, grep.search, marks.buffer,
marks.global, marks.all (marks require their provider enabled; grep.search requires rg).
The marks providers show the characters in providers.marks.include, letters only by default.
Numbered marks (0-9) appear only after adding them to include. Special marks
(' ` ^ . < > [ ] ") need both include_special = true and their character in include;
include_special alone adds nothing.
:PeekstackStack— open the stack view panel:PeekstackSaveSession— save current stack (persist enabled):PeekstackRestoreSession— restore a saved session:PeekstackListSessions— list all saved sessions:PeekstackDeleteSession [name]— delete a saved session (prompts to select when no name is given):PeekstackRestorePopup— restore the last closed popup (undo close), including popups closed with:qor<C-w>c:PeekstackRestoreAllPopups— restore all closed popups:PeekstackCloseAll— close all popups in the current stack:PeekstackToggle— temporarily hide/show all popups in the current stack (pushing, focusing or restoring a popup shows it again):PeekstackZoom— toggle zoom (maximize the top popup to fill the editor):PeekstackHistory— show popup history and select to restore:PeekstackQuickPeek [provider]— quick peek without stacking (default:lsp.definition, accepts any registered provider)
Defaults inside popup windows:
q— close<C-j>— focus next popup<C-k>— focus previous popup<C-x>— promote to horizontal split<C-v>— promote to vertical split<C-t>— promote to new tab<leader>os— open stack view<C-z>— toggle zoom (maximize top popup)<C-w>h/j/k/l— navigate to adjacent split window
Defaults in stack view:
<CR>— focus selected popup (moves to it, which closes the panel)dd— close selected popupu— undo close (restore last)U— restore all closed popupsH— history list (select to restore)r— renamep— pin/— filtergg/G— jump to first/last stack itemj/k— move cursor by stack item (skip header/preview lines)z— toggle zoom of the top popup (the most recently pushed one, not the selected entry)?— helpq— close
The stack view is a read-only panel that closes as soon as focus leaves it. Motions, visual selection
and yanking work as usual; editing commands fail because the buffer is not modifiable, and / filters
the list instead of searching.
Configure via require("peekstack").setup({ ... }).
Default Settings
{
ui = {
layout = {
style = "stack",
offset = { row = 1, col = 4 },
shrink = { w = 4, h = 2 },
min_size = { w = 60, h = 12 },
max_ratio = 0.65,
zindex_base = 50,
},
title = {
enabled = true,
format = "{icon}{kind}{provider} {path}:{line}{context}",
icons = {
enabled = true,
map = {
lsp = " ",
diagnostics = " ",
grep = " ",
file = " ",
marks = " ",
},
},
context = {
enabled = false,
max_depth = 5,
separator = " • ",
node_types = {},
},
},
path = {
base = "repo", -- "repo" | "cwd" | "absolute"
max_width = 80,
},
stack_view = {
position = "right", -- "left" | "right" | "bottom"
},
inline_preview = {
enabled = true,
max_lines = 10,
hl_group = "PeekstackInlinePreview",
close_events = { "CursorMoved", "InsertEnter", "BufLeave", "WinLeave" },
},
quick_peek = {
close_events = { "CursorMoved", "InsertEnter", "BufLeave", "WinLeave" },
},
popup = {
editable = false,
buffer_mode = "copy", -- "copy" | "source"
source = {
prevent_auto_close_if_modified = true,
confirm_on_close = true,
},
history = {
max_items = 50,
restore_position = "top", -- "top" | "original"
},
auto_close = {
enabled = false,
idle_ms = 300000,
check_interval_ms = 60000,
ignore_pinned = true,
},
},
feedback = {
highlight_origin_on_close = true,
},
promote = {
close_popup = true,
},
keys = {
close = "q",
focus_next = "<C-j>",
focus_prev = "<C-k>",
promote_split = "<C-x>",
promote_vsplit = "<C-v>",
promote_tab = "<C-t>",
toggle_stack_view = "<leader>os",
zoom = "<C-z>",
},
},
picker = {
backend = "builtin", -- "builtin" | "telescope" | "fzf-lua" | "snacks" | <custom name>
builtin = {
preview_lines = 1,
},
},
providers = {
lsp = { enable = true },
diagnostics = { enable = true },
file = { enable = true },
marks = {
enable = false,
include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
include_special = false,
},
},
persist = {
enabled = false,
max_items = 200,
session = {
default_name = "default",
prompt_if_missing = true,
},
auto = {
enabled = false,
session_name = "auto",
restore = true,
save = true,
restore_if_empty = true,
debounce_ms = 1000,
save_on_leave = true,
},
},
}Note
setup() never throws on bad config. Invalid values fall back to defaults
with a vim.notify warning naming the default, counts, sizes and durations
must be integers (fractions, NaN and infinities are rejected), and unknown or mistyped keys (e.g. ui.popups
instead of ui.popup) are reported the same way so typos are easy to spot.
peekstack uses a picker when multiple locations are returned (e.g. references). The
default backend is builtin. To use an external picker, install the plugin and set
picker.backend to one of: telescope, fzf-lua, snacks.
When using these external backends, the picker preview window shows the selected file
content around the target location.
Candidate labels are shown in a readable unified format:
<text> - <path>:<line>:<col> (or <path>:<line>:<col> when text is empty).
{
"mhiro2/peekstack.nvim",
dependencies = {
"nvim-telescope/telescope.nvim", -- or "ibhagwan/fzf-lua" / "folke/snacks.nvim"
},
config = function()
require("peekstack").setup({
picker = { backend = "telescope" },
})
end,
}If the chosen plugin is not installed, a warning is shown and the picker will not open.
You can also plug in your own picker with register_picker(name, mod) and select it
with picker.backend = name. The module must implement pick(locations, opts, cb)
and call cb with the chosen location (or nil to cancel). Registration works
before or after setup() and survives setup() re-runs; if the configured name is
not registered, peekstack falls back to builtin.
require("peekstack").register_picker("my_picker", {
pick = function(locations, opts, cb)
vim.ui.select(locations, {
format_item = function(loc)
return vim.uri_to_fname(loc.uri)
end,
}, cb)
end,
})
require("peekstack").setup({ picker = { backend = "my_picker" } })Push results from external pickers (telescope / fzf-lua / snacks.nvim) directly
onto the peekstack stack. Each extension provides push_file, push_grep,
push_lsp_references, and a generic actions.push for custom configurations.
-- snacks.nvim
vim.keymap.set("n", "<leader>pf", require("peekstack.extensions.snacks").push_file)
-- fzf-lua
vim.keymap.set("n", "<leader>pf", require("peekstack.extensions.fzf_lua").push_file)
-- telescope
vim.keymap.set("n", "<leader>pf", "<cmd>Telescope peekstack push_file<cr>")See :help peekstack-extensions for the full API and custom action examples.
When persist.enabled = true, PeekstackSaveSession uses persist.session.default_name
if you do not pass a name. If persist.session.prompt_if_missing = true, you'll be prompted
for a name instead of using the default.
A session holds one stack: the popups of the current window (or of the window the stack view
belongs to), oldest first, keeping the newest persist.max_items. Only locations, titles, pins,
buffer modes and parent links are saved; edits made in copy-mode popups are not.
Restoring a session adds its popups to the current window's stack and keeps the popups already there.
Warning
Persistence uses repository storage when the current working directory is inside a git repository. Outside a git repository, sessions fall back to cwd-based storage. The storage is resolved when a save, delete, or rename is requested, so changing directory while it is still being written does not move it to another repository.
Each git repository (each worktree, found by looking upward from the current window's working
directory, so :lcd / :tcd count) has its own storage file, and session names are scoped to it.
- Empty stacks: saving an empty stack replaces the session with an empty one; restoring an empty
session reports that there is no saved session. Use
:PeekstackDeleteSessionto remove one. - Skipped items: entries whose file no longer exists or that are malformed are skipped on restore.
The warning lists the first five skipped files with their reasons, and the
PeekstackRestoreevent data carries every skipped item asskipped. - Unreadable storage: if the storage file cannot be read (an I/O error, invalid JSON, or a version this release does not know), peekstack warns and refuses to save, delete, or rename sessions in it instead of replacing it with an empty store. Fix or move the file to recover.
Important
Sessions are written as plain JSON under vim.fn.stdpath("state") .. "/peekstack/". Each entry
stores the file URI, line/column range, title, provider name, pin/buffer-mode flags, parent
popup id, and the timestamp the entry was captured. Each session also tracks created_at and
updated_at metadata. Any path you peek at while persistence is enabled is recorded on disk in
cleartext, so avoid enabling persistence on shared machines or for repositories whose file paths
or symbol names are sensitive.
When persist.auto.enabled = true, peekstack can automatically restore and save a session:
- Restore on
VimEnter/DirChanged(only when the stack is empty ifrestore_if_empty = true) - Save on
PeekstackPush/PeekstackClose/PeekstackRestorePopupwith a debounce - Save on leave on
VimLeavePreifsave_on_leave = true
The auto session holds a single stack: the one that changed most recently, saved to the repository that was current when it changed. Closing every popup of that stack saves it empty, so nothing is restored next time.
Leaving Neovim saves that stack synchronously, even if the cursor is in another window. It skips the save if no stack changed during the session, and keeps the stored session as-is once that stack's window has been closed. The save on leave waits up to one second for earlier saves to finish; if they are still writing, it is skipped with a warning rather than racing them.
Auto persist only runs inside a git repository and always uses the repository session storage. Make sure
persist.enabled = true as well.
Calling require("peekstack").setup() again replaces config, re-registers the built-in providers and
picker backends for the new config, and recreates the autocmds and auto-persist hooks. Providers and
pickers registered with register_provider() / register_picker() are kept.
User commands are created by the first setup() call and reused afterwards; they read the config
when they run, so they always use the latest settings.
It does not migrate existing popup windows, stack entries, or history in place. Updated settings apply to future actions, and to existing stacks only after those popups are reopened, restored, or recreated.
ui.popup.buffer_mode controls how popups are backed:
- copy (default): scratch buffer with copied lines; editing is controlled by
ui.popup.editable - source: uses the real source buffer; useful for editing, with safety options in
ui.popup.source(confirm_on_close,prevent_auto_close_if_modified)
A copy popup is a snapshot taken when it opens (up to 500 lines around the target in large files)
and does not follow later changes to the file. Edits made in it with ui.popup.editable = true
stay in the popup: they are never written to the file and are dropped when it closes, so history,
sessions and promote reopen the file itself.
A source popup edits the real buffer, so :w saves the file. Closing the popup keeps the buffer
loaded with any unsaved changes; confirm_on_close asks before the close key closes a modified
popup, and prevent_auto_close_if_modified keeps auto close away from it.
Switching buffers inside a popup (:buffer, :edit) keeps the popup and makes it follow the new buffer
as a source popup at the cursor, so its title, keymaps, provider requests and history refer to that buffer.
Run :checkhealth peekstack to verify requirements.
See :help peekstack for complete documentation.
MIT License. See LICENSE.
