Building an Interactive Keybinding Cheatsheet in Neovim with Telescope
A self-documenting keybinding system that lives inside the editor. This plugin enables browsing 30+ categories of shortcuts through a fuzzy-searchable Telescope picker without leaving the workflow.
Problem Statement
Neovim configurations grow. Plugins are added, custom keymaps are written, LSP bindings are configured, DAP shortcuts are set up. Eventually there are 100+ keybindings scattered across a dozen files. Bindings mapped last month are forgotten. Opening the config and grepping around causes loss of context in the file being edited.
External cheatsheets (markdown files, printed references, browser tabs) break the workflow. The desired solution is something inside the editor that can be pulled up instantly, searched through, and dismissed just as fast.
Proposed Approach
Build a minimal local plugin that:
- Stores all keybindings as structured data in a single Lua table
- Presents categories through Telescope’s fuzzy picker
- Enables drilling into a category to view its bindings
- Supports navigating back to the category list
Three files, under 100 lines of logic. The data file is as long as the keybinding list.
Project Structure
1
2
3
4
~/.config/nvim/lua/cheatsheet/
├── init.lua # Entry point: setup, command, keymap
├── data.lua # Keybinding data organized by category
└── ui.lua # Telescope picker with drill-down navigation
Plus a plugin spec to register it with lazy.nvim:
1
~/.config/nvim/lua/plugins/cheatsheet.lua
Implementation
Plugin Spec
Register the local plugin with lazy.nvim using dir instead of a remote repo URL:
1
2
3
4
5
6
7
8
9
10
-- lua/plugins/cheatsheet.lua
return {
{
dir = vim.fn.stdpath("config") .. "/lua/cheatsheet",
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
require("cheatsheet").setup()
end,
},
}
The dir field tells lazy.nvim to load from a local path. No remote repo is needed.
Entry Point
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- lua/cheatsheet/init.lua
local M = {}
function M.open()
require("cheatsheet.ui").open_cheatsheet()
end
function M.setup(opts)
opts = opts or {}
vim.api.nvim_create_user_command("Cheatsheet", M.open, {})
vim.keymap.set("n", "<leader>cs", M.open, { desc = "Open cheatsheet" })
end
return M
This provides two ways to open it: the :Cheatsheet command or <leader>cs. The setup() function follows the standard Neovim plugin convention, so it works with any plugin manager’s config callback.
UI Layer
The UI module builds Telescope pickers dynamically. The key design choice is a two-level hierarchy: categories first, then items within a category, with a “Back” option to return to categories.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-- lua/cheatsheet/ui.lua
local pickers = require("telescope.pickers")
local finders = require("telescope.finders")
local conf = require("telescope.config").values
local actions = require("telescope.actions")
local action_state = require("telescope.actions.state")
local data = require("cheatsheet.data")
local M = {}
function M.create_picker(title, entries, on_select)
pickers.new({}, {
prompt_title = title,
finder = finders.new_table({
results = entries,
}),
sorter = conf.generic_sorter({}),
attach_mappings = function(prompt_bufnr, map)
actions.select_default:replace(function()
actions.close(prompt_bufnr)
local selection = action_state.get_selected_entry()
on_select(selection.value)
end)
return true
end,
}):find()
end
function M.show_categories()
local categories = {}
for _, section in ipairs(data.sections) do
table.insert(categories, section.category)
end
table.sort(categories)
M.create_picker("Neovim Shortcuts", categories, function(selected_category)
M.show_items(selected_category)
end)
end
function M.show_items(category)
for _, section in ipairs(data.sections) do
if section.category == category then
local items = vim.tbl_extend("force", section.items, { "Back" })
M.create_picker(category, items, function(selected_item)
if selected_item == "Back" then
M.show_categories()
end
end)
return
end
end
end
function M.open_cheatsheet()
M.show_categories()
end
return M
create_picker is a generic factory. It takes a title, a list of strings, and a callback for when the user selects one. Both the category view and the item view use it. This keeps the two-level navigation under 50 lines.
The “Back” entry is appended to each item list using vim.tbl_extend. When selected, it re-opens the category picker, creating a simple navigation loop.
Data Layer
The data file is a flat list of sections, each with a category name and an array of formatted strings. The following shows a trimmed example of the structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
-- lua/cheatsheet/data.lua
local M = {}
M.sections = {
{
category = "Leader Key",
items = {
"<Space> → Leader key",
},
},
{
category = "Standard Vim Bindings",
items = {
"## Normal Mode",
"h/j/k/l → Move cursor",
"w/b → Move to next/previous word",
"0/$ → Move to start/end of line",
"gg/G → Go to first/last line",
"u → Undo",
"<C-r> → Redo",
"yy → Yank (copy) line",
"dd → Delete (cut) line",
"## Visual Mode",
"v → Enter visual mode",
"V → Enter linewise visual mode",
"<C-v> → Enter blockwise visual mode",
"## Text Objects",
"iw → Inner word",
"aw → Around word",
"ci\"/di\"/yi\" → Change/delete/yank inner quotes",
},
},
{
category = "Harpoon (Normal Mode)",
items = {
"n: <leader>a → Add file",
"n: <C-e> → Toggle menu",
"n: <leader>1 → Jump to file 1",
"n: <leader>2 → Jump to file 2",
"n: <leader>3 → Jump to file 3",
"n: <leader>4 → Jump to file 4",
},
},
{
category = "Telescope (Normal Mode)",
items = {
"n: <leader>ph → Help tags",
"n: <leader>pf → Find files (fzf sorting)",
"n: <leader>en → Find files in config",
"n: <leader>mg → Multi grep",
},
},
{
category = "LSP (Normal Mode)",
items = {
"n: gr → Telescope lsp_references",
"n: gd → Go to definition",
"n: K → Hover",
"n: <leader>vca → Code action",
"n: <leader>vrn → Rename",
},
},
{
category = "DAP (Debugging, Normal Mode)",
items = {
"n: <leader>db → Toggle breakpoint",
"n: <leader>dc → Continue",
"n: <leader>ds → Step over",
"n: <leader>di → Step into",
"n: <leader>do → Step out",
},
},
-- ... more categories
}
return M
Several aspects of the format are worth noting:
- Mode prefixes (
n:,v:,i:,x:) clarify which mode a binding applies to. - Section headers (
## Normal Mode) within a category’s items work as visual dividers in the Telescope results. They are just strings—no special handling is needed. - Arrow separator (
→) provides a consistent visual rhythm. Telescope’s fuzzy matching works on the full string, so searches can match by key or description.
The full data file in a typical config has 30+ categories covering everything from standard Vim bindings to plugin-specific shortcuts (Fugitive, Gitsigns, Obsidian, DAP, Zen Mode, etc.).
Usage
Press <leader>cs or run :Cheatsheet. Telescope opens with a sorted list of categories:
1
2
3
4
5
6
7
8
9
10
11
12
> Buffer Management (Normal Mode)
Completion (nvim-cmp, Insert Mode)
Core Neovim (Normal Mode)
DAP (Debugging, Normal Mode)
Fugitive (Git, Normal Mode)
Gitsigns (Git, Normal Mode)
Harpoon (Normal Mode)
LSP (Normal Mode)
Telescope (Normal Mode)
Window Management (Normal Mode)
Zen Mode (Normal Mode)
...
Type to fuzzy-filter. Select a category to see its bindings. Select “Back” (or press <Esc> and reopen) to return to categories.
Since it uses Telescope, all the usual features are available: fuzzy matching, <C-n>/<C-p> navigation, and instant filtering. Searching “harp” from the category list jumps straight to “Harpoon”. Inside a category, searching “break” highlights the breakpoint binding.
Comparison with Alternatives
vs. :map / :verbose map — These dump raw Vim mapping internals. Useful for debugging, not for quick reference. No descriptions, no organization.
vs. which-key.nvim — which-key shows available continuations after pressing a leader key. It is effective for discovery but only shows one prefix at a time. It cannot show all Git bindings across different prefixes, or enable searching “how to stage a hunk?”
vs. a markdown file — Requires leaving the editor (or splitting it) and manually searching. It goes stale because updating it is a separate chore from updating the config.
vs. comments in config files — Scattered across files. No single view. Cannot fuzzy-search across all of them.
The cheatsheet approach centralizes everything in one searchable place. The tradeoff is that the data file must be maintained alongside the keymaps. In practice this is not burdensome—when adding a new binding, a line is added to data.lua. It is the same file every time.
Extension Possibilities
Several ideas for taking this further:
- Auto-generate from keymaps: Walk
vim.api.nvim_get_keymap()and build sections programmatically. This sacrifices curated descriptions but gains zero-maintenance accuracy. - Preview window: Show the source file and line number where each binding is defined. Telescope supports custom previewers.
- Subcategories: Add a third level of nesting for configs with even more bindings.
- Export: Generate a markdown reference from
data.luafor documentation outside the editor.
Source
The full implementation is three files totaling roughly 70 lines of logic plus the data. Drop the lua/cheatsheet/ directory into the Neovim config, add the plugin spec, and populate data.lua with the appropriate bindings.