Pin words you want to keep in your head.
pinwords.nvim is a Neovim-native, persistent (until cleared) word highlighter designed to help you externalize your focus while reading or reviewing code.
Unlike navigation-oriented word highlighters, pinwords lets you explicitly mark and keep multiple keywords visible until you decide to clear them.
- π’ Slot-based highlights (default: 1β9)
- π§· Auto allocation for single-key pinning
- β¨ Visual flash feedback on pin/unpin success
- π Persistent highlights (until cleared; global across all buffers, not saved across sessions)
- π§ Designed for thinking, reading, and reviewing, not navigation
- πͺ Correct behavior across split windows
- β‘ Pure Neovim native API (Lua-only, no Vim script)
- π Grep integration β search project-wide with pinned words via telescope/snacks/fzf-lua
- π§© Clean internal design for future extensions
Many word-highlighting plugins focus on navigation.
pinwords is different.
- This plugin is not about jumping to the next word.
- It is about marking concepts you want to keep in mind.
- Highlights are stable, intentional, and user-controlled.
If you liked t9md/vim-quickhl, this plugin follows the same spirit β
but is redesigned from scratch for modern Neovim.
Using lazy.nvim:
{
"mhiro2/pinwords.nvim",
event = "VeryLazy",
config = function()
local pinwords = require("pinwords")
local map = vim.keymap.set
-- Initialize plugin
pinwords.setup()
-- Auto pin/unpin word under cursor (auto allocation)
map("n", "<leader>p", function() pinwords.set() end, { desc = "Pin word toggle" })
-- Pin selected text in visual mode
map("x", "<leader>p", ":PinWord<cr>", { desc = "Pin selection" })
-- Toggle cursor-word highlight (follows cursor, works in insert mode)
map("n", "<leader>j", function() pinwords.cword_toggle() end, { desc = "Toggle cword highlight" })
end,
}If you want to target specific slots (1-9) instead of auto allocation:
vim.keymap.set("n", "<leader>1", "<cmd>PinWord 1<cr>", { desc = "Pin slot 1" })
vim.keymap.set("n", "<leader>u1", "<cmd>UnpinWord 1<cr>", { desc = "Unpin slot 1" })
vim.keymap.set("x", "<leader>1", ":PinWord 1<cr>", { desc = "Pin selection to slot 1" })Customize how slots are allocated when using auto pinning:
require("pinwords").setup({
auto_allocation = {
strategy = "first_empty", -- "first_empty" | "cycle" | "lru"
on_full = "replace_oldest", -- "replace_oldest" | "replace_last" | "no_op"
toggle_same = true,
},
})| Option | Values | Behavior |
|---|---|---|
strategy |
first_empty, cycle, lru |
Slot selection policy (lru replaces least-recently used when full). |
on_full |
replace_oldest, replace_last, no_op |
What to do when no empty slot is found (applies to first_empty/cycle). |
toggle_same |
true, false |
If the same raw text with the same match semantics is already pinned, unpin it instead of adding a new slot. |
Customize highlight colors per slot:
require("pinwords").setup({
colors = {
[1] = "#ff6b6b", -- hex string
[2] = { bg = "#1dd1a1", fg = "#000000" }, -- with foreground color
[3] = { bg = "#54a0ff", bold = true }, -- with style attributes
[4] = { sp = "#ff6b6b", undercurl = true }, -- colored undercurl (no background)
cword = "#ffd166", -- cursor word color
},
})Available style attributes: bold, italic, underline, undercurl, underdouble, underdotted, underdashed, strikethrough, sp, ctermbg, ctermfg.
Show a short flash when pin/unpin operations succeed:
require("pinwords").setup({
flash = {
enabled = true,
timeout_ms = 120,
hl_group = "PinWordFlash",
priority = 250,
},
})| Option | Type | Default | Behavior |
|---|---|---|---|
enabled |
boolean | true |
Enables/disables flash feedback. |
timeout_ms |
integer | 120 |
Flash duration in milliseconds. 0 clears on the next event loop tick. |
hl_group |
string | PinWordFlash |
Highlight group used for flash. |
priority |
integer | 250 |
Match priority for flash highlighting. |
Default PinWordFlash is linked to IncSearch.
Jump between pinned word occurrences:
-- Recommended key mappings
vim.keymap.set("n", "]p", function() require("pinwords").jump_next() end, { desc = "Next pinned word" })
vim.keymap.set("n", "[p", function() require("pinwords").jump_prev() end, { desc = "Prev pinned word" })
-- Jump to specific slot (optional)
vim.keymap.set("n", "]1", function() require("pinwords").jump_next(1) end, { desc = "Next slot 1" })
vim.keymap.set("n", "[1", function() require("pinwords").jump_prev(1) end, { desc = "Prev slot 1" })Search your project using pinned words while preserving each slot's match semantics:
vim.keymap.set("n", "<leader>pg", function() require("pinwords").grep() end, { desc = "Grep pinned words" })
vim.keymap.set("n", "<leader>pl", function() require("pinwords").live_grep() end, { desc = "Live grep pinned words" })See the Grep Integration section for details.
Enable auto-loading of optional picker extensions (disabled by default to avoid forcing dependencies at startup):
require("pinwords").setup({
-- Telescope.nvim integration
telescope = {
enabled = true,
},
-- Snacks.nvim integration
snacks = {
enabled = true,
},
-- fzf-lua integration
fzf_lua = {
enabled = true,
},
})See the Picker Integrations section for usage details.
| Command | Description |
|---|---|
:PinWord |
Auto pin (toggle same). In visual mode, pins selection. |
:PinWord {slot} |
Pin word under cursor (visual mode uses selection). |
:PinWordSymbol |
Pin symbol at cursor using Treesitter (auto allocation). |
:PinWordSymbol {slot} |
Pin symbol at cursor to specific slot. Falls back to cword when no parser or no symbol found. |
:UnpinWord |
Unpin word under cursor (warns when multiple slots share the same raw text) |
:UnpinWord {slot} |
Clear slot |
:UnpinAllWords |
Clear all |
:PinWordList |
Interactive picker for global pinned words (falls back to vim.ui.select) |
:PinWordCwordToggle |
Toggle cursor-word highlight (current window, follows cursor incl. insert) |
:PinWordNext [slot] |
Jump to next pinned word occurrence |
:PinWordPrev [slot] |
Jump to previous pinned word occurrence |
:PinWordGrep [slot] |
Grep pinned words across project |
:PinWordLiveGrep [slot] |
Live grep pinned words across project |
Run :checkhealth pinwords to validate current config values and optional picker dependencies.
require("pinwords").set() -- auto allocation
require("pinwords").set(slot)
require("pinwords").set(nil, { source = "symbol" }) -- Treesitter symbol pin
require("pinwords").toggle() -- alias for auto set
require("pinwords").cword_toggle() -- toggle cursor-word highlight
require("pinwords").unpin() -- unpin word under cursor
require("pinwords").clear(slot)
require("pinwords").clear_all()
require("pinwords").list()
require("pinwords").pick() -- open interactive picker
require("pinwords").jump_next() -- jump to next pinned word
require("pinwords").jump_next(slot) -- jump to next occurrence of specific slot
require("pinwords").jump_prev() -- jump to previous pinned word
require("pinwords").jump_prev(slot)
require("pinwords").grep() -- grep all pinned words (OR combined, per-slot semantics preserved)
require("pinwords").grep({ slot = 1 }) -- grep specific slot
require("pinwords").live_grep() -- live grep with pinned words as initial query
require("pinwords").live_grep({ slot = 1 })The second argument to set() accepts a table with the following fields:
| Field | Type | Default | Description |
|---|---|---|---|
raw |
string |
word under cursor | Explicit text to pin (bypasses <cword>). |
whole_word |
boolean |
config value | Match whole words only (\<...\>). |
case_sensitive |
boolean |
config value | Case-sensitive matching. |
source |
"cword"|"symbol" |
"cword" |
Use Treesitter symbol instead of <cword>. |
-- Pin an arbitrary string (raw mode)
require("pinwords").set(1, { raw = "TODO", whole_word = true })
-- Pin case-sensitively
require("pinwords").set(nil, { case_sensitive = true })pinwords.nvim provides optional integrations with popular picker plugins for browsing and managing pinned words through a fuzzy finder interface.
Note
All picker integrations are completely optional. pinwords.nvim works perfectly without them β when no picker is enabled, :PinWordList uses vim.ui.select as a built-in fallback.
All picker integrations provide:
- List all pinned words: Shows slot number, word, and actual highlight color
- Unpin individual words: Select and unpin entries
- Clear all: Clear all pinned words at once
- Fuzzy search: Filter by slot number or word text
- Multi-select: Select multiple entries and unpin them together
| Key | Action |
|---|---|
<Tab> / <Shift-Tab> |
Toggle multi-selection |
<CR> / <Enter> |
Unpin selected word(s) - supports multi-select |
<C-d> |
Unpin current word |
<C-x> |
Clear all pinned words |
<Esc> / <C-c> |
Close picker |
Telescope.nvim extension for pinwords.
Setup (optional auto-loading):
require("pinwords").setup({
telescope = {
enabled = true,
},
})Usage:
:Telescope pinwordsvim.keymap.set("n", "<leader>fp", function()
require("telescope").extensions.pinwords.pinwords()
end, { desc = "Telescope: Pinned words" })Snacks.nvim picker integration for pinwords.
Setup (optional auto-loading):
require("pinwords").setup({
snacks = {
enabled = true,
},
})Usage:
vim.keymap.set("n", "<leader>fp", function()
require("pinwords.snacks").picker()
end, { desc = "Snacks: Pinned words" })Note
To keep multi-select stable in Snacks, pinwords assigns a unique id to each entry.
fzf-lua integration for pinwords.
Setup (optional auto-loading):
require("pinwords").setup({
fzf_lua = {
enabled = true,
},
})Usage:
vim.keymap.set("n", "<leader>fp", function()
require("pinwords.fzf_lua").picker()
end, { desc = "fzf-lua: Pinned words" })Search your entire project using pinned words. All pinned words are combined with OR into a single search pattern, while each slot keeps its own whole_word / case_sensitive behavior.
| Command | Description |
|---|---|
:PinWordGrep |
Grep all pinned words (OR combined) |
:PinWordGrep {slot} |
Grep a specific slot's word only |
:PinWordLiveGrep |
Open live grep with pinned words as the initial query |
:PinWordLiveGrep {slot} |
Open live grep with a specific slot's word |
Grep uses the same priority as other picker features: snacks β telescope β fzf-lua β vimgrep (quickfix) fallback.
No additional configuration is needed β if you have a picker enabled for :PinWordList, grep will use the same backend automatically.
Note
Grep backends are line-oriented, so multi-line pinned words are skipped.
When no picker backend is available, both commands fall back to one-shot quickfix grep via rg when available, then vimgrep.
PinWord1 .. PinWord9
PinWordCword
PinWordFlashFully customizable via standard highlight overrides.
- Neovim >= 0.12
- No external dependencies
- Optional: telescope.nvim for telescope integration
- Optional: snacks.nvim for snacks picker integration
- Optional: fzf-lua for fzf-lua picker integration
MIT License. See LICENSE.
