dmtrKovalenko/fff
The fastest and the most accurate file search SDK for AI agents, Neovim, Rust, C, Python, Bun and NodeJS
About dmtrKovalenko/fff
dmtrKovalenko/fff is an open-source project on GitHub, mainly written in Rust. The fastest and the most accurate file search SDK for AI agents, Neovim, Rust, C, Python, Bun and NodeJS It currently holds 10,777 stars and 448 forks with 82 open issues, and was last pushed on 2026-09-19 (repository created 2025-07-31).
Project Overview
AI Homed tracks it on the Today's Trending board, currently at rank #41 with 21 new stars today.
GitHub Repository Details
README
A file search toolkit for humans and AI agents. Really fast.
Typo-resistant path and content search, frequency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
Powers file search in opencode, nushell, and many more amazing projects!
Originally started as Neovim plugin people loved, but it turned out that plenty of AI harnesses and code editors need the same thing: accurate, fast file search as a library. That is what fff is.
---
Sponsors
fff is MIT and open source forever. Development is supported by these companies:
| 💎 DIAMOND |
![]() |
Anomaly The team behind opencode. |
| 🥇 GOLD |
![]() |
Mango Proxy Fast, secure proxies for all the needs. |
| 🥈 SILVER |
![]() |
RapidProxy Residential proxies built for scraping at scale. |
Use and enjoy fff? Become a sponsor to get your features/fixes the highest priority.
---
Pick what you are interested in:
MCP server
Works with Claude Code, Codex, OpenCode, Cursor, Cline, and any MCP-capable client. Fewer grep roundtrips, less wasted context, faster answers.
One-line install
Linux / macOS:
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
Windows (PowerShell):
irm https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.ps1 | iex
The scripts live at install-mcp.sh and install-mcp.ps1 if you want to read them first. They print the exact wiring instructions for your client.
Homebrew (macOS / Linux)
brew install dmtrKovalenko/fff/fff-mcp
brew upgrade fff-mcp # after new stable releases
Formula lives in Formula/fff-mcp.rb in this repo and is auto-bumped on every stable release (see bump-homebrew-formula in .github/workflows/release.yaml). Installs the prebuilt fff-mcp binary from GitHub releases.
Codex setup
Register the installed binary using its absolute path, since Codex desktop sessions may not inherit your interactive shell's PATH.
Homebrew:
codex mcp add fff -- "$(brew --prefix)/bin/fff-mcp"
One-line installer:
codex mcp add fff -- "$HOME/.local/bin/fff-mcp"
This creates an entry in ~/.codex/config.toml similar to:
[mcp_servers.fff]
command = "/opt/homebrew/bin/fff-mcp"
Use the actual installed path for your system, then restart Codex or start a new task so it loads the server.
Once the server is connected, ask the agent to "use fff" and it picks up the ffgrep, fffind, and fff-multi-grep tools.
Recommended agent prompt
Drop this into your project's CLAUDE.md or equivalent:
For any file search or grep in the current git-indexed directory, use fff tools.
What changes
- Frecency memory. Files you actually open rank higher next time. Warm-up from git touch history runs automatically.
- Definition-first hinting. Lines that look like code definitions are classified on the Rust side, no regex overhead in your prompt.
- Smart-case with auto-fuzzy fallback.
IsOffTheRecordfinds snake_case variants; zero-match queries retry as fuzzy and surface the best approximate hits. - Git-aware annotations. Modified, untracked, and staged files are tagged so the agent reaches for what you are actively changing.
crates/fff-mcp/.
The MCP server gives any agent a file search tool that is faster and more token-efficient than the built-in one.
Pi agent extension
Install
pi install npm:@ff-labs/pi-fff
Modes
Three operating modes, switchable at runtime with /fff-mode:
| Mode | What it does |
| ------------------------ | --------------------------------------------------------------------------------- |
| tools-and-ui (default) | Adds ffgrep and fffind tools, replaces @-mention autocomplete with FFF. |
| tools-only | Only tool injection. Keeps pi's native editor autocomplete. |
| override | Replaces pi's built-in grep, find, and multi_grep with FFF implementations. |
Env vars: PI_FFF_MODE, FFF_FRECENCY_DB, FFF_HISTORY_DB. Flags: --fff-mode, --fff-frecency-db, --fff-history-db. The databases default to your existing fff.nvim ones when present, otherwise ~/.pi/agent/fff/.
Agent-facing tools
ffgrep. Content search. Acceptspath,exclude(comma, space, or array; leading!optional),caseSensitive,context, and cursor pagination. Auto-detects regex, falls back to fuzzy on zero exact matches, rejects.*-style wildcard-only patterns up front.fffind. Path and filename search. Matches the whole repo-relative path, not just the filename. Frecency-aware. The weak-match detector flags scattered fuzzy noise before it floods the agent's context.
Commands
/fff-mode [tools-and-ui | tools-only | override]. Show or switch the mode./fff-health. Picker, frecency, and git integration status./fff-rescan. Force a rescan.
packages/pi-fff/.
The Pi extension swaps pi's native tools for FFF implementations and feeds the interactive editor's @-mention autocomplete from the frecency-ranked index.
fff.nvim
Demo on the Linux kernel repo (100k files, 8GB):
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
Installation
lazy.nvim
-- Package name changed from fff.nvim to fff. If you installed fff.nvim before, clean with :Lazy clean
{
'dmtrKovalenko/fff',
build = function()
-- downloads a prebuilt binary or falls back to cargo build
require("fff.download").download_or_build_binary()
end,
-- for nixos:
-- build = "nix run .#release",
opts = {
debug = {
enabled = true,
show_scores = true,
},
},
lazy = false, -- the plugin lazy-initialises itself
keys = {
{ "ff", function() require('fff').find_files() end, desc = 'FFFind files' },
{ "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' },
{ "fz",
function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end,
desc = 'Live fffuzy grep',
},
{ "fw",
function() require('fff').live_grep_under_cursor() end,
mode = { 'n', 'x' },
desc = 'Search current word / selection',
},
},
}
vim.pack
-- Package name changed from fff.nvim to fff. If you installed fff.nvim before, clean with :packdel fff.nvim
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff' })
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(ev)
local name, kind = ev.data.spec.name, ev.data.kind
if name == 'fff' and (kind == 'install' or kind == 'update') then
if not ev.data.active then vim.cmd.packadd('fff') end
require('fff.download').download_or_build_binary()
end
end,
})
vim.g.fff = {
lazy_sync = true,
debug = { enabled = true, show_scores = true },
}
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' })
Public API
require('fff').find_files() -- find files in current repo
require('fff').live_grep() -- live content grep
require('fff').live_grep_under_cursor() -- grep in normal, selection in visual
require('fff').scan_files() -- force rescan
require('fff').refresh_git_status() -- refresh git status
require('fff').find_files_in_dir(path) -- find in a specific dir
require('fff').change_indexing_directory(new_path) -- change root
-- Programmatic search (no UI). Useful for plugin integrations.
require('fff').file_search(query, opts) -- fuzzy search files / dirs / mixed
require('fff').content_search(query, opts) -- programmatic grep
file_search(query, opts)
Returns a structured result { items, scores, total_matched, total_files?, total_dirs?, location? }. Each item has a type field ("file" or "directory") and name / relative_path. File items also expose size, modified, git_status, is_binary, and frecency scores.
local r = require('fff').file_search('button', {
mode = 'mixed', -- 'files' (default) | 'directories' | 'mixed'
max_results = 50,
page = 0, -- 0-based pagination
current_file = nil, -- path to deprioritize for distance scoring
max_threads = 4,
cwd = nil, -- switch indexed root if different (see below)
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, item in ipairs(r.items) do
print(item.type, item.relative_path)
end
content_search(query, opts)
Returns a GrepResult { items, total_matched, total_files_searched, total_files, filtered_file_count, next_file_offset, regex_fallback_error? }. Each match item has relative_path, name, line_number, col, line_content, match_ranges, plus the same file metadata as file_search.
local r = require('fff').content_search('TODO', {
mode = 'plain', -- 'plain' (default) | 'regex' | 'fuzzy'
max_file_size = 10 1024 1024,
max_matches_per_file = 100,
smart_case = true,
page_size = 50,
file_offset = 0,
time_budget_ms = 0,
enforce_time_budget = false, -- also bound zero-match searches
trim_whitespace = false,
cwd = nil, -- switch indexed root if different
wait_for_index_ms = nil, -- override the default scan wait timeout
})
for _, m in ipairs(r.items) do
print(string.format('%s:%d %s', m.relative_path, m.line_number, m.line_content))
end
Both functions accept the same constraint syntax as the UI pickers (e.g. git:modified, *.rs, !test/, glob patterns).
cwd and indexing
Both file_search and content_search honour an optional cwd field. The first call to either function lazily initialises the picker at config.base_path (your Neovim cwd by default).
- If
cwdmatches the currently indexed root, the call returns immediately against the existing index. - If
cwddiffers, the picker is re-indexed at the new root and the call blocks (default up to 10 s) until the new picker is installed and its initial scan completes — so callers always get results from the right tree. - If the index is still warming up after a
change_indexing_directory, you can passwait_for_index_ms = Nto block for up toNms regardless of whethercwdtriggered the swap. Pass0to skip waiting entirely (useful for fire-and-forget calls where partial results are acceptable). - Invalid or non-existent
cwdpaths return an empty result and emit an error viavim.notify.
Autocmds
The picker fires User autocmds when it opens and closes. FFFOpen runs with the prompt window focused, FFFClose runs after every picker window is gone — so hide global UI, not window-local options of the picker itself:
vim.api.nvim_create_autocmd('User', {
pattern = { 'FFFOpen', 'FFFClose' },
callback = function(ev) vim.o.showtabline = ev.match == 'FFFOpen' and 0 or 2 end,
})
Commands
:FFFScan. Rescan files.:FFFRefreshGit. Refresh git status.:FFFClearCache [all|frecency|files]. Clear caches.:FFFHealth. Health check.:FFFDebug [on|off|toggle]. Toggle the scoring display.:FFFOpenLog. Open~/.local/state/nvim/log/fff.log.
Configuration
Defaults are sensible. Override only what you care about.
require('fff').setup({
base_path = vim.fn.getcwd(),
prompt = '🪿 ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true,
prompt_vim_mode = false,
wrap_around = false, -- true to wrap the cursor around when moving past the first/last item
follow_symlinks = false,
-- Allow indexing the user's $HOME directory. Enabled by default.
-- Disable if you strictly sure you don't want this, as it makes whole fff error hard
enable_home_dir_scanning = true,
-- Allow indexing a filesystem root (e.g. /, C:\). Disabled by default
enable_fs_root_scanning = false,
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- 'left' | 'right' | 'top' | 'bottom'
preview_size = 0.5,
-- Border style for the picker windows. Leave unset (nil) to follow the
-- global vim.o.winborder; set it to override fff's borders independently.
border = nil, -- 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'none'
-- border = {
-- { ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' },
-- { ' ', ' ', ' ', ' ', ' ' },
-- },
flex = { size = 130, wrap = 'top' },
min_list_height = 10, -- do not display anything except the list below this threshold
show_scrollbar = true,
path_shorten_strategy = 'middle', -- 'middle' | 'middle_number' | 'end' | 'start'
-- 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right'
anchor = 'center',
show_path_first = false, -- true renders results as path/to/file instead of file path/to
},
-- find_files specific rendering
file_picker = {
current_file_label = '(current)', -- virtual text marking the buffer the picker was opened from
fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches, not just the literal query
},
preview = {
enabled = true,
max_size = 10 1024 1024,
chunk_size = 8192,
binary_file_threshold = 1024,
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both',
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '',
select = '',
select_split = '',
select_vsplit = '',
select_tab = '',
move_up = { '', '' },
move_down = { '', '' },
preview_scroll_up = '',
preview_scroll_down = '',
toggle_debug = '',
cycle_grep_modes = '',
insert_newline_escape = '',
-- grep mode only: jump cursor to first match of next/prev file group
grep_jump_to_next_file = { '', '' },
grep_jump_to_prev_file = { '', '' },
cycle_previous_query = '',
cycle_forward_query = '',
-- unbound by default, wipes the whole input line
-- clear_query = '', -- overrides preview_scroll_up in insert mode
toggle_select = '',
send_to_quickfix = '',
focus_list = 'l',
focus_preview = 'p',
},
-- extra keymaps for the picker input, keyed by mode, applied over the built-ins
mappings = {
-- i = { [''] = function() vim.api.nvim_input('') end },
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3,
combo_boost_score_multiplier = 100,
},
git = {
status_text_color = false, -- true to color filenames by git status
},
select = {
-- Return winid to open the chosen file in, or nil to open in the original window
select_window = function(current_buf, action) --[[ default impl ]] end,
},
grep = {
max_file_size = 10 1024 1024,
max_matches_per_file = 100,
smart_case = true,
time_budget_ms = 150,
enforce_time_budget = false, -- apply time_budget_ms even before anything matched (off = zero-match queries scan everything)
modes = { 'plain', 'regex', 'fuzzy' },
trim_whitespace = false,
enable_filename_constraint = false, -- treat filename-like tokens (e.g. score.rs) in a grep query as a file-path filter scoping the search; off = searched as literal text
location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
},
debug = {
enabled = false, -- show the file info panel next to the preview
show_scores = false, -- inline scores in the file list
-- Per-section toggles for the file info panel. Accepts a boolean shorthand
-- (show_file_info = true|false) to flip everything at once. The panel
-- adapts to width: narrow renders sections vertically, wide renders them
-- as a two-column grid. Disable a section to also shrink the panel.
show_file_info = {
file_info = true, -- size, type, git status, frecency
score_breakdown = true, -- total + match type, bonuses, modifiers, penalty
-- modified + accessed timestamps; pass a table to hide individual rows:
-- timings = { modified = false, accessed = true }
timings = true,
full_path = true, -- relative path at the bottom (wraps if too long)
},
},
logging = {
enabled = true,
-- logs will be written in a parent directory of this file path in files like
-- ++.. Run :FFFOpenLog to open current one
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
retain_runs = 20,
},
})
Live grep modes
` cycles between plain, regex, and fuzzy. The list is configurable via grep.modes`, and single-mode setups hide the indicator entirely.
Per-call override:
require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } })
require('fff').live_grep({ query = 'search term' }) -- pre-fill
Constraints
Both find and grep accept these tokens to refine a query:
git:modified. One ofmodified,staged,deleted,renamed,untracked,ignored.test/. Any deeply nested children oftest/.!something,!test/,!git:modified. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like!=or!==work../**/*.{rs,lua}. Any valid glob, powered by zlob.
.md,.{c,h}. Extension filter.src/main.rs. Grep inside a single file.
git:modified src/**/*.rs !src/**/mod.rs user controller.
Open in invoking window
By default fff.nvim will try to open a file in the most suitable window, so any non-file buffers are not affected. You can customize or disable this by providing:
require('fff').setup({
select = {
select_window = function(_current_buf, _action) return nil end,
},
})
Caveat: the chosen file replaces the buffer in the invoking window even if it's a non-modifiable / special buftype. winfixbuf windows still fall back to :split to avoid E1513.
Multi-select and quickfix
- `
. Toggle selection (shows a thick▊` in the signcolumn). - ``. Send selected files to the quickfix list and close the picker.
Git status highlighting
Sign-column indicators are on by default. To color filename text by git status, set git.status_text_color = true and adjust the hl.git_* groups. See :help fff.nvim for the full list.
Float colors
The picker maps its float content to NormalFloat (via hl.normal) and the border to FloatBorder. Default FloatBorder links to NormalFloat, so border and content share a background out of the box and the picker reads as a single popup. Override hl.normal = 'Normal' to make the picker blend with the editor instead.
For finer control, set hl.winhl to override the per-window winhighlight. It accepts either a single string applied to every picker window, or a table with optional prompt, list, preview, and file_info keys. Missing keys fall back to the default built from hl.normal, hl.border, and hl.title.
-- Apply the same winhighlight to all picker windows
hl = { winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title' }
-- Or override specific windows only
hl = {
winhl = {
prompt = 'Normal:Pmenu,FloatBorder:FloatBorder',
list = 'Normal:NormalFloat,FloatBorder:FloatBorder',
preview = 'Normal:NormalFloat,FloatBorder:FloatBorder',
},
}
File info panel
Enable with debug.enabled = true. The panel sits above the preview and shows
file metadata, score breakdown, timestamps and the full absolute path. It
adapts to the panel width: at narrow widths sections stack vertically (B2),
at wide widths sections render as a two-column grid (H2). Each section can be
disabled individually via debug.show_file_info.
Customise the panel via hl:
| key | default | used for |
| ----------------------- | ----------------- | ---------------------------------- |
| file_info_section | Title | section header label |
| file_info_separator | FloatBorder | dashes that act as section borders |
| file_info_label | Comment | row labels (Size, Type, Git, ...) |
| file_info_value | Normal fg | plain values |
| file_info_value_dim | NonText | dim values, separators inside rows |
| file_info_size | Number | file size value |
| file_info_type | Type | filetype value |
| file_info_path | Directory | full path |
| file_info_total_score | bold + Number | total score (bold) |
| file_info_match_type | bold + Special | match type (bold) |
| file_info_score_pos | DiagnosticOk | positive score components |
| file_info_score_neg | DiagnosticError | negative score components |
File filtering
FFF honours



