fff README

The project's README from github.com/dmtrKovalenko/fff. As one markdown file: README.md.

FFF

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.

dmtrKovalenko%2Ffff | Trendshift


Sponsors

fff is MIT and open source forever. Development is supported by these companies:

💎
DIAMOND
Anomaly Anomaly
The team behind opencode.
🥇
GOLD
Mango Proxy Mango Proxy
Fast, secure proxies for all the needs.

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.

Benchmark chart comparing FFF against the built-in AI file-search tools

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.

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. IsOffTheRecord finds 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.

Source: 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. Accepts path, 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.

Source: 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 <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

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 cwd matches the currently indexed root, the call returns immediately against the existing index.
  • If cwd differs, 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 pass wait_for_index_ms = N to block for up to N ms regardless of whether cwd triggered the swap. Pass 0 to skip waiting entirely (useful for fire-and-forget calls where partial results are acceptable).
  • Invalid or non-existent cwd paths return an empty result and emit an error via vim.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 = '<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>',
    cycle_forward_query = '<C-Down>',
    -- unbound by default, wipes the whole input line
    -- clear_query = '<C-u>', -- overrides preview_scroll_up in insert mode
    toggle_select = '<Tab>',
    send_to_quickfix = '<C-q>',
    focus_list = '<leader>l',
    focus_preview = '<leader>p',
  },
  -- extra keymaps for the picker input, keyed by mode, applied over the built-ins
  mappings = {
    -- i = { ['<A-BS>'] = function() vim.api.nvim_input('<C-w>') 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
    -- `<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,
  },
})

Live grep modes

<S-Tab> 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 of modified, staged, deleted, renamed, untracked, ignored.
  • test/. Any deeply nested children of test/.
  • !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.

Grep-only:

  • *.md, *.{c,h}. Extension filter.
  • src/main.rs. Grep inside a single file.

fff (dmtrKovalenko/fff)

Searching Database…

11.1K ·GitHub
这是什么?一个面向人类用户和 AI 智能体的 Rust 文件搜索工具包:路径和内容搜索能容错拼写错误,并按你打开文件的频繁程度排序,以 SDK、MCP 服务器和 Neovim 插件三种形式发布。opencode 和 nushell 里的搜索功能就是用它驱动的。
它是一个产品还是好几个——库、服务器、插件?同一个 Rust 内核的三种形态:面向 Rust、C、Python、Bun 和 NodeJS 的 SDK,面向 AI 智能体的独立 MCP 服务器可执行文件(fff-mcp),以及 fff.nvim 文件选择器插件。它最初只是个插件,后来逐渐发展成一个库。
怎么把它接入我的编程智能体?在 Linux/macOS 上运行一行安装脚本,在 Windows 上运行 PowerShell 版本,或者使用 Homebrew formula——它会安装 fff-mcp 可执行文件,并打印出针对你所用客户端的确切接入步骤,比如在 ~/.codex/config.toml 里加一条配置。
哪些编程智能体能真正用上它?任何支持 MCP 的客户端都行——文档里点名了 Claude Code、Codex、OpenCode、Cursor 和 Cline。连接之后,只要让智能体“用 fff”,它就会调用 ffgrep、fffind 和 fff-multi-grep 这几个工具。
配置上有什么麻烦的地方吗?在 Codex 里,要用绝对路径来注册这个可执行文件,因为桌面版会话可能不会继承你 shell 的 PATH;然后重启 Codex 或开一个新任务,服务器才会真正加载。
支持哪些平台?Linux 和 macOS 有一行安装脚本外加一个 Homebrew formula;Windows 由 PowerShell 安装脚本覆盖。预编译的 fff-mcp 可执行文件通过 GitHub releases 发布。
接入之后,我的智能体能得到哪些工具?三个 MCP 工具:ffgrep 用于内容搜索(自动识别正则,零匹配时回退到模糊搜索,支持上下文行和游标分页),fffind 用于按完整路径搜索文件,还有 fff-multi-grep。弱的模糊匹配会被标记出来,以免塞满智能体的上下文。
它能记住我常处理哪些文件吗?能——frecency 记忆会在下次把你真正打开过的文件排得更靠前,而且会自动根据你的 git 操作历史做预热。搜索结果还带 git 标注,已修改、未跟踪和已暂存的文件一眼就能看出来。
在 Neovim 里怎么用?通过 lazy.nvim 或 vim.pack 安装 fff.nvim。你会得到一个支持普通/正则/模糊 grep 模式的选择器,还有 :FFFScan、:FFFHealth 之类的命令,以及 filesearch() 和 contentsearch() 这两个 Lua API,可以传入 git:modified、扩展名过滤和 glob 匹配等约束条件。
为什么不直接让我的智能体跑 grep 或 fzf?README 声称,在任何需要多次搜索的长期运行进程中,fff 比 ripgrep、fzf 这类命令行工具快得多;而且 MCP 服务器减少了 grep 的来回往返,让智能体少浪费上下文、回答更快。
Size
10.2 MB
Stars
11.1K
Age
1y
Commits · 90 Days