fff
About
The fastest and the most accurate file search toolkit for AI agents
Details
- Author
- dmtrkovalenko
- Categories
- File Management, Search, Productivity
Jump to
Setup
Install fff in your MCP client (Claude Desktop, Cursor, Windsurf, and others).
Repository: https://github.com/dmtrkovalenko/fff.nvim
Follow the installation instructions in the repository README, then restart your MCP client.
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 inopencode,nushell, and many more amazing projects!
Originally started asNeovim pluginpeople 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.
Works with Claude Code, Codex, OpenCode, Cursor, Cline, and any MCP-capable client. Fewer grep roundtrips, less wasted context, faster answers.
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
irm https://raw.githubusercontent.com/dmtrKovalenko/fff/main/install-mcp.ps1 | iex
The scripts live atinstall-mcp.shandinstall-mcp.ps1if you want to read them first. They print the exact wiring instructions for your client.
brew install dmtrKovalenko/fff/fff-mcp brew upgrade fff-mcp # after new stable releases
Formula lives inFormula/fff-mcp.rbin this repo and isauto-bumped on every stable release(seebump-homebrew-formulain.github/workflows/release.yaml). Installs the prebuiltfff-mcpbinary fromGitHub releases.
Register the installed binary using its absolute path, since Codex desktop sessions may not inherit your interactive shell'sPATH.
codex mcp add fff -- "$(brew --prefix)/bin/fff-mcp"
codex mcp add fff -- "$HOME/.local/bin/fff-mcp"
This creates an entry in~/.codex/config.tomlsimilar 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 theffgrep,fffind, andfff-multi-greptools.
Drop this into your project'sCLAUDE.mdor equivalent:
For any file search or grep in the current git-indexed directory, use fff tools.
- 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.
The MCP server gives any agent a file search tool that is faster and more token-efficient than the built-in one.
Three operating modes, switchable at runtime with/fff-mode:
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/.
- 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.
- /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.
The Pi extension swaps pi's native tools for FFF implementations and feeds the interactive editor's@-mention autocomplete from the frecency-ranked index.
Demo on the Linux kernel repo (100k files, 8GB):
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
-- 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', }, }, }
-- 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' })
require('fff').find_files() -- find files in current repo require('fff').live_grep() -- live content grep require('fff').live_grep_under_cursor() -- grep <cword> 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
Returns a structured result{ items, scores, total_matched, total_files?, total_dirs?, location? }. Each item has atypefield ("file"or"directory") andname/relative_path. File items also exposesize,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
Returns aGrepResult{ items, total_matched, total_files_searched, total_files, filtered_file_count, next_file_offset, regex_fallback_error? }. Each match item hasrelative_path,name,line_number,col,line_content,match_ranges, plus the same file metadata asfile_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, 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).
Bothfile_searchandcontent_searchhonour an optionalcwdfield. The first call to either function lazily initialises the picker atconfig.base_path(your Neovim cwd by default).
- Ifcwdmatches the currently indexed root, the call returns immediately against the existing index.
- Ifcwddiffers, the picker is re-indexed at the new root and the callblocks(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 achange_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-existentcwdpaths return an empty result and emit an error viavim.notify.
- :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.
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, 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_number', -- 'middle_number' | 'middle' | 'end' | 'start' anchor = 'center', }, 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 = '<Esc>', select = '<CR>', select_split = '<C-s>', select_vsplit = '<C-v>', select_tab = '<C-t>', move_up = { '<Up>', '<C-p>' }, move_down = { '<Down>', '<C-n>' }, preview_scroll_up = '<C-u>', preview_scroll_down = '<C-d>', toggle_debug = '<F2>', cycle_grep_modes = '<S-Tab>', insert_newline_escape = '<C-CR>', -- grep mode only: jump cursor to first match of next/prev file group grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' }, grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' }, cycle_previous_query = '<C-Up>', toggle_select = '<Tab>', send_to_quickfix = '<C-q>', focus_list = '<leader>l', focus_preview = '<leader>p', }, 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 }, file_picker = { fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results }, 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, 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 = { -- logs will be written in a parent directory of this file path in files like -- <stem>+<UTC-timestamp>+<pid>.<ext>. Run :FFFOpenLog to open current one log_file = vim.fn.stdpath('log') .. '/fff.log', log_level = 'info', retain_runs = 20, }, })
<S-Tab>cycles betweenplain,regex, andfuzzy. The list is configurable viagrep.modes, and single-mode setups hide the indicator entirely.
require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) require('fff').live_grep({ query = 'search term' }) -- pre-fill
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 byzlob.
- .md,.{c,h}. Extension filter.
- src/main.rs. Grep inside a single file.
Mix freely:git:modified src//.rs !src//mod.rs user controller.
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.winfixbufwindows still fall back to:splitto avoidE1513.
- <Tab>. Toggle selection (shows a thick▊in the signcolumn).
- <C-q>. Send selected files to the quickfix list and close the picker.
Sign-column indicators are on by default. To color filename text by git status, setgit.status_text_color = trueand adjust thehl.git_groups. See:help fff.nvimfor the full list.
The picker maps its float content toNormalFloat(viahl.normal) and the border toFloatBorder. DefaultFloatBorderlinks toNormalFloat, so border and content share a background out of the box and the picker reads as a single popup. Overridehl.normal = 'Normal'to make the picker blend with the editor instead.
For finer control, sethl.winhlto override the per-windowwinhighlight. It accepts either a single string applied to every picker window, or a table with optionalprompt,list,preview, andfile_infokeys. Missing keys fall back to the default built fromhl.normal,hl.border, andhl.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', }, }
Enable withdebug.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 viadebug.show_file_info.
FFF honours.gitignore. For picker-only ignores that do not touch git, add a sibling.ignorefile:
- :FFFHealthverifies picker init, optional dependencies, and DB connectivity.
- :FFFOpenLogopens the current session's log file.
- Historical log files are stored near the main log file<state>/log/fff+<UTC-timestamp>+<pid>.log(up to 20 files)
- For a crash backtrace, runlldb -- nvimorgdb -- nvimand reproduce
The best file search picker for neovim. Period. Faster and more intuitive queries, frecency ranking, definition classification and much more.
npm install @ff-labs/fff-node # or bun add @ff-labs/fff-node
import { FileFinder } from "@ff-labs/fff-node"; const finder = FileFinder.create({ basePath: process.cwd(), aiMode: true }); if (!finder.ok) throw new Error(finder.error); await finder.value.waitForScan(10_000); const files = finder.value.fileSearch("incognito profile", { pageSize: 20 }); const hits = finder.value.grep("GetOffTheRecordProfile", { mode: "plain", smartCase: true, beforeContext: 1, afterContext: 1, classifyDefinitions: true, }); // Run extremely fast glob matching which is significantly (10-100 times) faster than Bun's and Node implementation const rustFiles = finder.value.glob("/.rs", { pageSize: 100 }); finder.value.destroy();
Every method returns aResult<T>({ ok: true, value } | { ok: false, error }). Full type reference:packages/fff-node/src/types.ts.
TypeScript wrapper over the C library for nodejs and bun. Build custom agent tools, CLIs, or IDE integrations on top of FFF.
FFF is written in Rust, so this is the lowest-overhead way to use it.
[dependencies] fff-search = "0.6"
Full API documentation:docs.rs/fff-search.
Native rust crate that is performing all the search. Stable and well documented.
# Builds only the C cdylib (fastest): make build-c-lib # or directly with cargo: cargo build --release -p fff-c --features zlob
Thezlobfeature (requires theZigtoolchain) switches both glob matchingandfilesystem traversal tozlob's native parallel walker. Without it, the default build uses the pure-Rustignore(ripgrep) walker andglobset.
The output is acdylib(libfff_c.so/libfff_c.dylib/fff_c.dll). The header lives atcrates/fff-c/include/fff.h.
Prebuilt binaries for every version, including every commit on main, are on thereleases page. The same binaries also ship inside the@ff-labs/fff-bin-npm packages.
# System-wide (needs sudo): sudo make install # User-local, no sudo: make install PREFIX=$HOME/.local # Staged install for packagers: make install DESTDIR=/tmp/pkgroot PREFIX=/usr
Dropslibfff_c.{so,dylib,dll}into$(PREFIX)/liband the header into$(PREFIX)/include/fff.h. Remove withmake uninstall, which honours the samePREFIXandDESTDIR.
Ensure$(PREFIX)/libis on your runtime library search path (LD_LIBRARY_PATHon Linux,DYLD_LIBRARY_PATHon macOS, or an entry in/etc/ld.so.conf.d/).
#include <fff.h> #include <stdio.h> int main(void) { FffResult res = fff_create_instance( ".", // base_path "", // frecency_db_path (empty = default) "", // history_db_path false, // use_unsafe_no_lock true, // enable_mmap_cache true, // enable_content_indexing true, // watch false // ai_mode ); if (!res->success) { fprintf(stderr, "init failed: %s\n", res->error); fff_free_result(res); return 1; } void handle = res->handle; fff_free_result(res); // Search FffResult search = fff_search(handle, "main.rs", "", 0, 0, 20, 100, 3); // ... read FffSearchResult from search->handle, then fff_free_search_result() fff_destroy(handle); return 0; }
For instance creation useFffCreateOptions— a versioned struct that evolves without ABI breaks. C99 designated initializers keep call sites readable and zero-init unspecified fields:
FffResult res = fff_create_instance_with(&(FffCreateOptions){ .version = FFF_CREATE_OPTIONS_VERSION, .base_path = "/path/to/repo", .ai_mode = true, .watch = true, .enable_fs_root_scanning = false, // off by default .enable_home_dir_scanning = false, // off by default });
fff_globfilters indexed files by a single glob pattern, ranks by frecency, paginates — bypasses the regular query parser entirely. Use this when you already have a literal glob (.rs,/.test.ts,src/) and don't want fuzzy matching layered on top.
FffResult res = fff_glob(handle, "*/.rs", "", 0, 0, 100); // FffSearchResult in res->handle, free with fff_free_search_result.
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.

