The display line, squared up
One global 'quickfixtextfunc' lays out every list that
doesn't bring its own: path:lnum padded to the widest location in the
batch, so the entry text starts in the same column on every row — plus a one-letter
type-flag column when the list carries types.
An illustration, not a screenshot. Row 1 is the current
entry — it keeps its selected band and its segment colors. Row 2 is
expanded with <Tab>, showing two lines of file context either
side.
: — punctuation, recedesThe formatter is polite about territory. It installs itself only when
'quickfixtextfunc' is empty, so a formatter you already set is never
clobbered; another plugin's per-list formatter still wins for its own lists. Rows
that aren't real locations — headings, invalid matches — render as bare text and
recede via QfProInvalid.
Nothing about this is a new window. :cnext, :cdo,
:colder, :cc and the rest operate on exactly the list they
always did. Location lists get the same treatment as quickfix lists.
Color that fits the scheme you already chose
Stock qf syntax only parses Vim's
file|lnum col|text shape. Give it any other layout and it paints the
whole row one color. quickfix-pro highlights each segment itself, as extmarks — and
picks the colors by measurement rather than by hopeful linking.
Why fixed links don't work
Colorschemes disagree wildly about which groups carry a color at all. Neovim's
stock scheme defines Number, Constant,
Delimiter, Type and Statement as
exactly Normal's foreground. Link a line number to
Number there and it comes out the same white as the entry text — a row
that is technically four-color and visibly one.
So each adaptive group names an ordered list of candidates, semantically best first, and resolution walks it until something is genuinely its own color:
The two tests
- Not plain text. A candidate whose foreground is within 16
levels per channel of
Normal's is rejected. Equality alone would let an off-white through. - Not the neighbor's hue. A candidate within 30° of hue of a segment already claimed is rejected. Cream text beside an amber line number is about one degree apart and reads as a single color. Greys are exempt — they have no hue to clash with.
Segments resolve in order — file, line number, text, then the separator — and
each one claims its color against the ones before it. Resolution reruns on
ColorScheme, and :checkhealth quickfix-pro prints what each
group actually landed on.
QfProLineNr tries Number, measures it as
Normal's foreground, rejects it, and takes
DiagnosticWarn instead — a real amber. Under a scheme where
Number is distinct, it keeps Number.The current entry
QuickFixLine is a window-level line highlight and outranks buffer
extmarks at any priority, so it would flatten the selected row back to one color.
quickfix-pro remaps it per-window to the background-only
QfProCurrent: the selected band stays, the segment colors show through.
A QuickFixLine entry already in your own 'winhighlight' is
left alone, and highlight = false disables the whole mechanism.
Overriding
Every group is a default link, so a colorscheme or your config wins
outright. Setting a group yourself pins it — adaptivity only applies to groups you
haven't touched.
vim.api.nvim_set_hl(0, "QfProLineNr", { fg = "#7aa2f7" })
| Group | Paints | Default |
|---|---|---|
| QfProFile | the file path | adaptive — Directory first |
| QfProSeparator | the : between path and line number | adaptive — Comment first |
| QfProLineNr | the line number | adaptive — Number first |
| QfProText | the entry text | adaptive — String first |
| QfProTypeE | type flag E | DiagnosticError |
| QfProTypeW | type flag W | DiagnosticWarn |
| QfProTypeI | type flag I | DiagnosticInfo |
| QfProTypeN | type flag N | DiagnosticHint |
| QfProTypeH | type flag H | DiagnosticHint |
| QfProCurrent | the current entry's band | CursorLine |
| QfProInvalid | rows that aren't real locations | Comment |
| QfProExpandLnum | line numbers inside an expansion | LineNr |
| QfProExpandContext | context lines inside an expansion | Comment |
| QfProExpandFocus | the entry's own line(s) in an expansion | Normal |
QfProCurrent wants to be background-only. If your
scheme gives CursorLine a foreground, override it with a group that
doesn't.
An editing grammar, in a window you can't edit
The quickfix buffer isn't modifiable, so its most obvious keys were sitting idle. quickfix-pro gives them the meanings you'd guess — with counts, with visual mode, and with an undo that actually restores.
| Key | Does |
|---|---|
| dd | Delete the entry under the cursor from the list, in place. |
| 3dd | Counts work. Three entries, starting at the cursor row. |
| d (visual) | Delete the selected rows as one batch. |
| u | Restore the last deleted batch at its original position — repeatable back through the delete history. |
| <Tab> / za | Toggle the entry under the cursor open. |
| zR | Expand every entry. |
| zM | Collapse every entry. |
Deleting keeps everything else
The list is modified in place: same list id, same title, same
context, and every surviving entry's data — user_data included —
written back verbatim. Location lists behave identically.
Pruning is not a one-way door
u puts the batch back where it was, user_data
intact, and keeps going back through the history — up to 50 batches per list.
Not the same as :Cfilter
dd edits the current list;
:Cfilter pushes a new one onto the stack and you use
:colder to get back. Both are available —
the bundled cfilter plugin is :packadded for you.
Clients can object
A registered client is consulted before any delete and may veto it — useful when an entry owns something in flight. See the client API.
Every one of these maps is buffer-local to the quickfix window, so nothing you
press anywhere else changes meaning. Set any of them to false to skip
it.
Inline expansion
<Tab> on a row shows the entry's line plus
context_lines either side, as virtual lines under the row. No client
needed — it works on any grep or make list.
- Keyed to identity, not to a row number. Expansion state
carries a
{bufnr, text}fingerprint, so it survives deleting rows above it — and collapses cleanly, rather than decorating the wrong row, when the list is rebuilt into something else. - Never cached. The content is re-read on every repaint, so what you see is the file as it is now, not as it was when the list was built.
- Overridable per entry. A registered client can supply its own expansion content for the entries it owns.
:QfAdd — for when you are the search
Vim builds lists from greps and compilers. This covers the other case: you are reading code, you find something, and you want it on the list.
:QfAdd " the current line :QfAdd needs a docstring " ...with your own note as the entry text :5,9QfAdd " lines 5-9 as ONE entry spanning the region :'<,'>QfAdd! " one entry per line in the selection
A ranged :QfAdd records end_lnum and
end_col, so the entry is a region, not a point — a consumer
that reads it can act on the whole span. Without a range, or with the bang, entries
are single lines. The entry text defaults to a trimmed summary of the first line,
annotated with how many more the region covers.
It appends to the current quickfix list, keeping its id and every existing entry,
and creates one if there is none. Exact duplicates — same buffer, same
lnum, same end_lnum — are refused.
keymaps.add to opt
in, and you get both a normal-mode and a visual-mode binding.The client API
Other plugins can decorate and expand the entries they own. The
result is rendered as extmarks in the native window — quickfix-pro never writes the
list to do it, so changedtick stays put and anything else watching the
list is undisturbed.
local qfpro = require("quickfix-pro") local dispose = qfpro.register("myplugin", { -- Per-entry status: sign icon, highlight, whole-line hl, end-of-line text. status = function(entry) if not (entry.user_data and entry.user_data.myplugin) then return nil end return { icon = "●", hl = "DiagnosticOk", eol = "done" } end, -- Inline expansion content (overrides the builtin file-context expander). expand = function(entry) if not (entry.user_data and entry.user_data.myplugin) then return nil end return { "custom", "virtual", "lines" } end, -- Consulted before a delete; return false to veto. on_delete = function(entries) return true end, }) qfpro.refresh() -- client state changed: repaint, debounced qfpro.refresh({ id = list_id }) -- ...limited to windows showing that list qfpro.unregister("myplugin") -- or call dispose()
A client supplies the sign, its highlight, and the end-of-line text. The rows underneath are ordinary quickfix entries.
The contract
entryis the rawgetqflist()item plusidx(1-based position) andqf_id(the stable list id). Entries are shared tables — read them, don't mutate them.statusreturns{ icon, hl, line_hl, eol }, all optional: a 1–2 cell sign with its highlight, a whole-line highlight, and end-of-line virtual text (a string, or extmark chunks).expandreturns a list of virtual lines — each a string, or a list of{text, hl}chunks.statusandexpandresolve first-non-nil in registration order; returnnilfor entries you don't own.on_deleteconsults all clients and any explicitfalsevetoes.- Every callback is error-isolated. A throwing client degrades to stock rendering and warns once per callback, per session — it never breaks the window. Re-registering a name replaces it, so reloading a plugin is safe.
refresh()repaints decorations only. No'quickfixtextfunc're-run, no changedtick bump, nothing for other list-watching tools to react to. It's debounced — cheap to call from async callbacks.
The first client is conjurer.nvim, which
drives multi-site edits through a quickfix list: each entry shows its per-site status
live, expands to a before/after diff, and cancels its in-flight edit when you
dd it.
Configuration
Optional, in full. Nothing here needs setting for the plugin to work.
require("quickfix-pro").setup({ -- "auto": install the improved display line only if 'quickfixtextfunc' -- is empty. true forces it, false leaves stock, a function installs yours. format = "auto", -- Colorize file / : / lnum / type / text separately. highlight = true, -- Lines of file context shown above/below an expanded entry. context_lines = 2, -- Repaints are coalesced within this window (ms). debounce_ms = 30, -- Lists larger than this are left undecorated (a performance fuse). max_entries = 2000, -- Buffer-local to the quickfix window; false skips a map. keymaps = { delete = "dd", -- takes a count delete_visual = "d", undo = "u", -- restore the last deleted batch toggle = "<Tab>", toggle_alias = "za", expand_all = "zR", collapse_all = "zM", add = false, -- a GLOBAL key for :QfAdd; opt in }, })
format and
keymaps.add are read once, when the plugin is sourced at startup — they
decide whether to write 'quickfixtextfunc' and whether to claim a global
key. The rest (highlight, context_lines,
debounce_ms, max_entries, the buffer-local maps) are read
live, on every repaint or window attach.To skip the plugin entirely, set vim.g.quickfix_pro_disable = 1
before startup.
Fit and tolerances
What it costs, where it stops, and how to check it's seated properly.
One trigger
FileType qf is the only hook. It fires when a
quickfix or location window is filled — including on every list mutation while
it's open — so open, refill and update are all the same code path. Nothing heavy
loads until a quickfix window actually appears.
One repaint
Every trigger funnels into a single debounced flush, so a
:cdo or a burst of client updates costs one repaint rather than
one per change.
A fuse
Lists longer than max_entries are left as stock
quickfix. They still work — they just aren't decorated.
Graceful all the way down
Set highlight = false and the colors go. Set
format = false and the display line goes. Set
vim.g.quickfix_pro_disable and the plugin goes. Each step lands you
on plain quickfix, on the same lists.
:checkhealth quickfix-pro reports the Neovim version, whether the
bootstrap loaded, which formatter is active, what each adaptive highlight group
resolved to, whether :Cfilter is available, and how many clients are
registered.
The full manual is :h quickfix-pro.