Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fuzzy picker

btv.picker is bemtvi’s native fuzzy finder — a centered float with a prompt that grabs every key, a Rust fuzzy matcher that re-ranks as you type, and an optional preview pane. The server owns the widget: the prompt, the matcher, navigation, and a generation token that drops a stale response for a query you’ve already typed past. No input loop runs in Lua — a source is just a thin driver that streams candidates in and handles confirm.

It ships with a set of built-in sources — files, files_git, live_grep, live_grep_git, buffers, curbuf, diagnostics, keymaps, marks, jumplist, pickers — and registering your own is a few lines.

File finding and grepping come in pairs:

  • files_git / live_grep_git respect .gitignore (and .ignore / .rgignore) — rg --hidden, so a dotfile like .github/workflows/ci.yml is still found and only ignored paths drop out. Without rg they fall back to git ls-files / git grep inside a work tree, to plain find / grep outside every repository (no .git at or above the directory, so, as for rg, no .gitignore applies), and otherwise (git missing or refusing the repository, or the pure web client) to a btv.fs walk that reads the ignore files itself (btv.fs.walk{ gitignore = true }) — so the ignore rules hold everywhere. The git grep leg needs git 2.19 or later (--column); with an older git, live_grep_git says so instead of searching. A basic grep (busybox, stock Alpine: no --null, -I or --exclude-dir) still works, but a file name holding :<digits>: is cut short there. These are the everyday <leader>ff / <leader>fg.
  • files / live_grep search unrestricted — the equivalent of rg -uu (--no-ignore --hidden), so a .gitignored build artifact is still findable. The one exclusion is .git itself. These are <leader>fF / <leader>fG.

Either way, narrowing a search is the filter boxes’ job (<C-g>) — per-search, so hiding target/ this time never makes a file unfindable the next.

Using a picker

The built-in sources are bound out of the box, plus a resume map:

MapSource
<leader>fffiles_git — fuzzy file finder, .gitignore respected
<leader>fFfiles — fuzzy file finder over every file, ignored ones too
<leader>fglive_grep_git — live grep, .gitignore respected
<leader>fGlive_grep — live grep over every file, ignored ones too
<leader>fbbuffers — open buffers (scoped to the focused layer, like :ls)
<leader>f/curbuf — fuzzy find in the current buffer
<leader>fddiagnostics — diagnostics
<leader>fkkeymaps — keymaps
<leader>fmmarks — marks
<leader>fjjumplist — the jumplist
<leader>fipickers — the registered pickers themselves
<leader>frresume — reopen the last picker where you left off

These are overridable defaults — your own map for the same key wins, and you can disable one by binding it to an empty function. To open any registered source from your own keymap, call btv.picker.open:

btv.keymap.set("n", "<leader>o", function() btv.picker.open("files") end)

Resume — <leader>fr

<leader>fr (telescope’s resume) reopens the most-recently-closed picker restored to exactly where you left off — the same displayed rows, prompt text, highlighted row, and multi-select marks. The server replays a frozen snapshot it captured at close, so a live_grep picker comes back with its actual previous results rather than a fresh, differently-ordered search; editing the query from there re-runs the source as usual. It’s a no-op (with a gentle notice) before any picker has closed. Call it from your own map with btv.picker.resume().

Transient internal pickers (the command-line completion overlay, for instance) opt out by setting resumable = false on their source, so resume always points at the last real picker.

In the open picker (all of these are rebindable — see Keys):

KeyAction
(printable)Edit the query — the document is never touched
<C-n> / <Down>Next item
<C-p> / <Up>Previous item
<CR>Confirm — run the source’s action on the highlighted item
<C-t>Confirm in a new tab — open the highlighted item in a fresh tab
<C-x>Confirm in a horizontal split
<C-v>Confirm in a vertical split
<Esc>Cancel
<Tab> / <S-Tab>Multi-select — mark/unmark this row and advance (see Sending results to a list)
<C-q>Send the results (marked, else all filtered) to a named list <picker>:<query>
<C-d> / <C-u>Scroll the preview pane half-page down / up
<C-f> / <C-b>Scroll the preview pane a page down / up

Sending results to a list

<C-q> sends the picker’s current results to a named list keyed <picker>:<query> — bemtvi’s take on telescope’s send-to-loclist, and a fast way to turn a search into a working set you step through with <CR> in the list. Each distinct search is its own persistent dock tab (re-running the same search updates it in place); a named list never collides with the quickfix and survives closing the window you sent it from. See named lists.

  • Filtered, not everything. It sends the rows matching your live query — what you see — not every candidate the source streamed.
  • Multi-select. Mark individual rows with <Tab> (it marks and advances; <S-Tab> too). Marks are kept by item, so they survive further typing / re-ranking. When any rows are marked, <C-q> sends only the marked ones (in mark order); with none marked it sends the whole filtered list.

Where the list opens is governed by the 'qfdock' option (on by default, the bemtvi way): each send opens as a tab in the bottom dock, so several searches sit side by side, and <CR> on an entry jumps into the main editing area. Set :set noqfdock for a bottom split instead. See Quickfix & named dock lists for the full model and the btv.qf.list / show API the action builds on.

Writing a source

btv.picker.source{...} registers a source. The driver, items(ctx), streams candidates by calling ctx.push(item) per result and signals completion by returning. An item is a table with a text display field plus whatever data confirm (or the preview) needs — e.g. path / row / col.

A static source pushes a fixed set, fuzzy-matched in Rust as you type:

btv.picker.source({
  name = "colours",
  items = function(ctx)
    for _, c in ipairs({ "red", "green", "blue", "amber" }) do
      ctx.push({ text = c })
    end
  end,
  confirm = function(item) btv.notify("picked " .. item.text) end,
})

A source can be asynchronous — wrap items in btv.async and stream from a subprocess. bemtvi is promise-only, so an async source returns its promise and the engine awaits it; there is no done callback. Reap any spawned job on close via ctx.on_cancel. This is essentially how the built-in files source works (the shipped one adds a fallback chain — find, then an btv.fs walk — for when rg is missing):

btv.picker.source({
  name = "files",
  preview = "file",
  items = btv.async(function(ctx)
    local stream = btv.run_stream({ cmd = "rg", args = { "--files" }, cwd = ctx.cwd })
    ctx.on_cancel(function() stream:kill() end)
    for batch in btv.await_each(stream) do
      for _, l in ipairs(batch) do
        if l ~= "" then ctx.push({ text = l, path = l }) end
      end
    end
  end),
  confirm = function(item) btv.picker.edit(item) end,
})

Row shape: heads, tags and colors

A row can be more than one string. A source that knows its row has structure — a location, a classification, a matched fragment — declares it, and the widget fits the parts separately instead of truncating one long label:

ctx.push({
  tag = "E",                       -- pinned classification, never elided
  head = "src/main.rs:12:5 ",      -- the location column, aligned down the list
  text = "unused variable `x`",    -- the body
  match = { 17, 17 },              -- 1-based INCLUSIVE char range of a hit in `text`
  hl = "DiagnosticError",          -- the group the row is painted with
})
  • head makes the row two-column. The head keeps at least 40% of the row (a long body can never squeeze the file name off), and the body is windowed around match so a hit 200 columns into a line still shows. A head that doesn’t fit elides tail-first, keeping file:line — the part you scan for.
  • tag is a short classification prepended to the head and pinned: the elision happens after it, so E survives however narrow the column gets. Use it whenever the head’s leading characters say what the row is.
  • hl is an ordinary highlight group, resolved against the live colorscheme each frame (an undefined group simply doesn’t paint). It colors the head of a two-column row — leaving the body’s match highlight readable — and the whole label of a plain one.

The shipped diagnostics source is exactly this shape: E src/main.rs:12:5 in the severity’s color, then source: message folded onto one line.

Declaring filter = true gives the source the include/exclude boxes. Every candidate carrying a path is then tested against them at the single point they all cross, so a source gets the filter for free however it enumerates. A source that shells out to a ripgrep-compatible tool should also splice ctx.rg_globs (ready-made -g argument pairs) into its argv, so the tool prunes the tree instead of streaming paths that are about to be dropped — on a node_modules-sized directory that is the difference between the results arriving and the cap filling with noise. The patterns themselves reach the source as ctx.include / ctx.exclude.

items = btv.async(function(ctx)
  local args = { "--files", "--color=never" }
  for _, a in ipairs(ctx.rg_globs) do args[#args + 1] = a end
  local stream = btv.run_stream({ cmd = "rg", args = args, cwd = ctx.cwd })
  …
end),

btv.picker.edit(item, mode) is the common confirm action: it opens item.path and, if the item carries a 1-based row (and optional col), jumps the cursor there. The mode is the confirm gesture (the picker passes it to confirm(item, mode)): "current" opens in the focused window honoring 'switchbuf'; "tab" / "split" / "vsplit" (the defaults <C-t> / <C-x> / <C-v>) open in a new tab / horizontal split / vertical split. Forward it from a custom source’s confirm to support those keys: confirm = function(item, mode) btv.picker.edit(item, mode) end.

Switching to an open tab

Where a confirmed pick (and every jump — LSP go-to, quickfix, marks) lands is governed by 'switchbuf'. bemtvi defaults it to usetab: opening a buffer already shown in another tab focuses that tab instead of re-opening it in the current window. Set it like vim — btv.o.switchbuf = "useopen" (reuse a window in the current tab only) or btv.o.switchbuf = "" (classic: always open in the current window). <C-t> always makes a new tab regardless (an explicit tab gesture).

The widget windows its rendering and matches incrementally, so a source can stream 100k+ candidates and stay fast; max_results (default 100000) is only a runaway-source safety bound.

Dynamic (live) sources

Set dynamic = true and the source re-runs on every prompt edit with the local fuzzy matcher bypassed — the source itself does the filtering. It reads the live prompt from ctx.query and the working directory from ctx.cwd. This is essentially how live grep works (re-spawning rg per query; the shipped source falls back to grep, then an btv.fs match, when rg is missing):

btv.picker.source({
  name = "live_grep",
  dynamic = true,
  preview = "location",
  items = btv.async(function(ctx)
    if ctx.query == "" then return end
    local stream = btv.run_stream({
      cmd = "rg", args = { "--vimgrep", "--", ctx.query }, cwd = ctx.cwd,
    })
    ctx.on_cancel(function() stream:kill() end)
    for batch in btv.await_each(stream) do
      for _, l in ipairs(batch) do
        local file, lnum, col = l:match("^(.-):(%d+):(%d+):")
        if file then
          ctx.push({ text = l, path = file, row = tonumber(lnum), col = tonumber(col) })
        end
      end
    end
  end),
  confirm = function(item) btv.picker.edit(item) end,
})

A dynamic source is debounced: a query edit cancels the in-flight job and schedules the search debounce ms later, so a fast typist spawns one process per pause, not one per keystroke. While the new search runs the previous results stay on screen — the list never flashes empty; they swap out only when the first new result arrives (or clear if nothing matched). The prompt row’s spinner is what tells the two states apart. The delay defaults to btv.picker.debounce (250 ms), overridable per source (debounce = N) or per open; 0 disables it.

Preview pane

Add preview to show a side pane for the highlighted item:

  • "file" — shows the head of item.path.
  • "location" — shows item.path scrolled to item.row / item.col (1-based) with the match range highlighted.

Omitted means no preview pane. Preview content is tree-sitter-highlighted by the server, and works across the terminal, GUI, and web clients. Scroll it with the <C-d> / <C-u> / <C-f> / <C-b> keys above.

Open-time options

btv.picker.open(name, opts) — each opts field overrides the matching field on the source, which in turn overrides the picker default:

OptionMeaning
width / heightA fixed box size: a cell count (100) or a viewport fraction ("80vw" / "60vh" / "50%"). The picker is never content-sized.
align + marginPlacement, like a float ("top-left" … "center" … "bottom-right", default centered).
preview"file" / "location" / nil (no pane).
prompt_pos"top" (default) or "bottom" (telescope-style, input under the results).
queryInitial prompt text — the picker opens already filtered against it, caret at its end. Default "".
titleA title centered on the box’s top border (the shipped sources set their own); nil for none.
multiselectWhether <Tab> marks rows for a batch action (default true); false is a single-choice picker.
layerWhere a confirmed item opens: "main" crosses back to the main editor area first; "active" (the default) opens in the focused layer. The shipped files / live_grep set "main".
debounceMilliseconds before a dynamic source re-runs; 0 off.
include / excludePre-fill the filter boxes — a comma-separated string or a list of globs. A seed, not a lock: the boxes stay editable.
filters"open" reveals the filter rows immediately; "collapsed" (the default) keeps the picker’s single-line shape with a badge.
-- a snappier live grep, just for this map:
btv.keymap.set("n", "<leader>fG", function()
  btv.picker.open("live_grep", { debounce = 100 })
end)

Keys

Every picker key is an ordinary picker-mode keymap, not a hardcoded grab: while a picker owns input the server selects the picker bucket, so navigation, confirm, cancel, preview-scroll, and query-editing are all rebindable like any other mode:

btv.keymap.set("picker", "<C-j>", btv.picker.actions.next)
btv.keymap.set("picker", "<C-k>", btv.picker.actions.prev)
btv.keymap.set("picker", "<Tab>", btv.picker.actions.confirm)
-- disable a default binding by mapping it to an empty function:
btv.keymap.set("picker", "<C-n>", function() end)

The actions are next, prev, confirm, confirm_tab, confirm_split, confirm_vsplit, cancel, send_to_list, toggle_select, clear_select, preview_half_down, preview_half_up, preview_page_down, preview_page_up, backspace, delete, left, right, to_start, to_end, next_field, toggle_filters. The one key that is not a map is an arbitrary printable char — there is no way to enumerate every char, so an unmapped printable just inserts into whichever line has focus.

Progress — the count and the spinner

The right end of the prompt row is the picker’s own status line:

┌─ Live Grep ──────────────────────┐      ┌─ Live Grep ──────────────────────┐
│ > handler                  ⠹ 128 │      │ > handler                    247 │
├──────────────────────────────────┤ ───► ├──────────────────────────────────┤
│ src/net/handler.rs:12:5: …       │      │ src/net/handler.rs:12:5: …       │
└──────────────────────────────────┘      └──────────────────────────────────┘
    still searching (128 hits so far)          done — 247 hits
  • A spinner turns while the source is still working. It covers the whole in-flight window — the debounce, the process spawn, the scan — so a search over a big tree never looks like a picker that has stopped responding. It matters most for a dynamic source, where the rows on screen belong to the previous query until the new run’s first result lands (see Dynamic sources): with no spinner, “searching” and “broken” look identical.
  • The count is the number of rows the list is showing. Once the local matcher has narrowed the candidates it reads matched/total (12/3480 — twelve rows answer what you typed, out of 3480 the source found), and a bare number when everything found is on show — which is always the case for a dynamic source, since it does its own filtering. A finished search that found nothing says 0.

Results also appear as they stream: a partial batch crosses to the widget at the end of the tick it was pushed on, so a query with three hits shows them the moment rg prints them rather than when the scan of the whole tree finishes.

The readout is composed server-side, so the terminal, GUI and web clients all show the same thing, and a remote (daemon) or browser session animates exactly like a local one.

Include / exclude filters

files, files_git, live_grep and live_grep_git carry two glob boxes, the way VSCode’s search panel does — files to include and files to exclude. <C-g> reveals them and steps through the three editable lines (query → include → exclude); typing goes to whichever has focus.

┌─ Find Files ─────────────────────┐      ┌─ Find Files ─────────────────────┐
│ > handler        12/3480 [+1 -2] │      │ > handler                12/3480 │
├──────────────────────────────────┤ <C-g>│ include  src/**                  │
│ src/net/handler.rs               │ ───► │ exclude  target/, *.lock         │ ← focus
│ src/ui/handler.rs                │      ├──────────────────────────────────┤
└──────────────────────────────────┘      │ src/net/handler.rs               │
        collapsed, filters active         └──────────────────────────────────┘

Each box holds a comma-separated list of globs. A comma inside {…} belongs to the pattern, so **/{node_modules,target}/** is one glob, not two. Collapsing the boxes does not turn the filter off — the badge ([+1 -2]: one include, two exclude patterns) keeps an active filter visible, so a search that is quietly hiding files never looks like one that isn’t.

Defaults

btv.picker.setup sets the line every filterable picker opens with — the “stop showing me build output” knob:

btv.picker.setup({
  exclude = { "target/", "node_modules/", "*.min.js" },
  history = 20,  -- past lines kept per box for <C-Up>/<C-Down>; 0 disables
})

History — <C-Up> / <C-Down>

Each box remembers the lines you have used, most recent first, and persists them across restarts (via btv.shada.plugin, on the ordinary shada cadence). With a box focused, <C-Up> walks back into older lines and <C-Down> forward again, returning to the line you were composing — cmdline history. The two boxes keep separate lists, so an include pattern never surfaces in the exclude box where it would mean the opposite.

A filterable picker opens pre-filled with the most recent line for each box, so the filter you worked out yesterday is already applied rather than retyped.

btv.picker.history("exclude")   -- the stored lines, most recent first
btv.picker.forget_history()     -- drop them all

Precedence, lowest to highest: the source’s own default → btv.picker.setup → the most recent line used → an explicit btv.picker.open{ include = … }. History outranks the configured default because a line you actually typed is a stronger statement than one configured months ago; an explicit open option outranks everything, so a picker asked for a scope gets exactly that scope.

Two conveniences make a typed pattern mean what you meant:

You typeIt matches
*.lockany .lock file, at any depth
targetthe target entry and everything under it, at any depth
vendor/same — a trailing / just says “the directory”
*.egg-info/every *.egg-info directory and everything under it — the trailing / makes even a glob a directory
src/**taken as written; a pattern with a / is anchored at the search root

A picker can also open already scoped, which is what a dedicated map usually wants:

-- "find in sources"
btv.keymap.set("n", "<leader>fs", function()
  btv.picker.open("files", { include = { "src/**", "crates/**" } })
end)

-- grep everything except the vendored trees, with the boxes already showing
btv.picker.open("live_grep", { exclude = "vendor/, **/*.min.js", filters = "open" })

Any source whose items carry a path can have the boxes by declaring filter = true — see Writing a source. On a source without it, <C-g> says so rather than presenting boxes that would filter nothing.

Vendored paths last — deprioritize

A search over a real project hits node_modules/, build output, lockfiles and source maps by the thousand, and the hits you wanted — the project’s own sources — end up under them. So every filterable picker lists those paths after the rest. Nothing is hidden (that is the exclude box’s job): a yarn.lock hit is still there, below src/. Each group keeps its own order — the order found with no query, match order under one — so a better-matching vendored file never jumps a project file.

┌─ Live Grep ──────────────────────────────────┐
│ > useAuth                               412  │
├──────────────────────────────────────────────┤
│ src/composables/auth.ts:3:17: export func…   │  ← the project's own hits first
│ src/pages/login.vue:12:9: const { user } …   │
│ node_modules/@nuxt/auth/dist/index.mjs:88…   │  ← then the vendored ones
│ .output/server/chunks/auth.mjs.map:1:4410…   │
└──────────────────────────────────────────────┘

The shipped list is btv.picker.defaults.deprioritize: the JS/TS, Python and build-output trees (node_modules/, .nuxt/, .output/, dist/, build/, target/, .venv/, __pycache__/, vendor/, …), editor and toolchain directories, every common lockfile, and *.min.js / *.min.css / *.map. Test files are not on it — plenty of people grep their tests on purpose. btv.picker.setup replaces the list, in the same glob syntax as the exclude box; extend the shipped one rather than retyping it:

btv.picker.setup({
  deprioritize = btv.list.extend(
    btv.tbl.deepcopy(btv.picker.defaults.deprioritize),
    { "*test*", "*_spec.lua" }
  ),
})

btv.picker.setup({ deprioritize = {} })   -- turn it off: pure stream / match order

Ordering alone would not stop a node_modules-heavy tree from filling the result cap before the project’s hits were even found, so the shipped files / live_grep pickers search in two passes: rg first with the deprioritized globs excluded — it never enters node_modules/ — then a second run over just those paths. The btv.fs walk fallback (the web client) pushes its list the same way, normal paths first. Only the fallbacks on a machine without rg — find / grep, and git ls-files / git grep for the _git pickers inside a work tree — search in one pass; their rows are still ordered.

A source can use the same machinery. A row pushed with low = true lists after the rest; a source with filter = true gets its paths tested against the deprioritize list automatically, and can search in the same two passes through ctx.passes:

items = btv.async(function(ctx)
  for _, pass in ipairs(ctx.passes) do   -- normal paths first, then the low ones
    ctx.begin_pass(pass)                 -- the sink keeps only this pass's tier
    run_my_tool(pass.rg_globs)           -- the box globs plus this pass's own
  end
end),

A tool that honours .gitignore takes pass.rg_excludes instead of rg_globs: rg lets a positive -g override the ignore files, so the low pass’s includes would re-include an ignored node_modules/. The negations alone are safe, and the sink applies the include box and the tier; pass.keeps(path) says whether a path belongs to the pass, for a source that lists first and searches only its own.

btv.picker.scorer re-ranks within each group and never lifts a deprioritized row above the rest.

Try it

A runnable playground ships in examples/ui-picker:

BEMTVI_CONFIG=examples/ui-picker cargo run -p bemtvi -- examples/ui-picker/sample.txt

It maps the three built-in sources, registers a custom static source, and shows the box-size, preview, and debounce overrides.

For the filter boxes there is a second playground, examples/picker-filters:

BEMTVI_CONFIG=examples/picker-filters \
  cargo run -p bemtvi -- examples/picker-filters/sample.txt

It ships the mess a real project has — a target/ artifact, a vendor/ lock file, a dotfile — and walks through defaults, editing a box, what each pattern shape means, the <C-Up> history, pre-scoped pickers, and a custom filterable source.

Deprioritizing and the .gitignore pickers have a third, examples/picker-priority:

BEMTVI_CONFIG=examples/picker-priority \
  cargo run -p bemtvi -- examples/picker-priority/sample.txt

It shows the default ordering, extending the list with test files, and <leader>fg versus <leader>fG on an ignored file.

How it works (in brief)

The full item tables stay Lua-side; only a display label and an integer key cross the bridge per result (exactly like btv.ui.select), so an item’s arbitrary fields never need to serialize. Candidates are batched rather than crossing one at a time, which is what makes streaming 100k results fast: a batch crosses every ~1000 items, or at the end of the tick it was pushed on — whichever comes first, so a handful of hits is never held back waiting for a threshold it will not reach. A generation token stamps every run, so a push from a query you’ve typed past — or from a picker that has since closed — is dropped.

For the full design — the unified float-list widget, the Rust matcher, dynamic forwarding, and the preview cache — see the fuzzy-finder plan, the preview-pane plan, and the float-list widget spec.