Environment
@ff-labs/pi-fff: 0.10.5
@ff-labs/fff-node / @ff-labs/fff-bun: 0.10.5
@earendil-works/pi-coding-agent: 0.84.2
- macOS arm64
- Reproduced with Node
22.23.1 and Bun 1.4.0
Summary
ffgrep uses the requested/default limit as both pageSize and maxMatchesPerFile. Grep cursors advance by file offset, so matches beyond maxMatchesPerFile in a file are permanently unreachable: following every returned cursor still does not retrieve them.
With 33 indexed matches—30 in one file and one in each of three other files—the default limit=20 retrieves only 23. Matches 21–30 in the noisy file are never returned. A limit=50 control call returns all 33, proving the files and lines were indexed before pagination was tested.
Reproduction
Run in a fresh temporary directory:
npm init -y
npm install --ignore-scripts \
@ff-labs/pi-fff@0.10.5 \
@ff-labs/fff-node@0.10.5 \
@ff-labs/fff-bun@0.10.5 \
@earendil-works/pi-coding-agent@0.84.2 \
@earendil-works/pi-tui@0.84.2 \
@sinclair/typebox@0.34.49
Save as repro.mjs:
import assert from "node:assert/strict";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
const root = process.cwd();
const fixture = join(root, "fixture");
const state = join(root, ".fff-repro-state");
await rm(fixture, { recursive: true, force: true });
await rm(state, { recursive: true, force: true });
await mkdir(join(fixture, "src"), { recursive: true });
await writeFile(
join(fixture, "noise.ts"),
Array.from({ length: 30 }, (_, i) =>
`TODO fix ${String(i + 1).padStart(2, "0")}`,
).join("\n") + "\n",
);
await writeFile(join(fixture, "src/app.ts"), "TODO app\n");
await writeFile(join(fixture, "src/utils.ts"), "TODO utils\n");
await writeFile(join(fixture, "README.md"), "TODO readme\n");
// Avoid reusing an existing fff.nvim or pi database.
process.env.FFF_FRECENCY_DB = join(state, "frecency");
process.env.FFF_HISTORY_DB = join(state, "history");
const loaderPath = resolve(
root,
"node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js",
);
const extensionPath = resolve(root, "node_modules/@ff-labs/pi-fff/src/index.ts");
const { loadExtensions } = await import(pathToFileURL(loaderPath));
const loaded = await loadExtensions([extensionPath], root);
assert.equal(loaded.errors.length, 0, JSON.stringify(loaded.errors));
const extension = loaded.extensions[0];
const ffgrep = extension.tools.get("ffgrep")?.definition;
assert.ok(ffgrep, "ffgrep was not registered");
const ui = {
notify() {},
setStatus() {},
addAutocompleteProvider() {},
};
for (const handler of extension.handlers.get("session_start") ?? []) {
await handler(
{ type: "session_start", reason: "startup" },
{ cwd: fixture, ui, sessionManager: { getEntries: () => [] } },
);
}
async function grep(params) {
const result = await ffgrep.execute(
"repro",
params,
new AbortController().signal,
);
const text = result.content.find((item) => item.type === "text")?.text ?? "";
return {
details: result.details,
matches: [...text.matchAll(/^\s*\d+:\s+(TODO[^\n]*)$/gm)].map(
(match) => match[1],
),
cursor: text.match(/cursor="([^"]+)"/)?.[1],
};
}
// Control: prove all 33 matches are indexed before exercising pagination.
let control;
for (let attempt = 0; attempt < 50; attempt++) {
control = await grep({ pattern: "TODO", path: fixture, limit: 50 });
if (control.matches.length === 33) break;
await delay(100);
}
assert.equal(control.matches.length, 33, "index did not become ready");
let cursor;
const pages = [];
const matches = [];
for (let pageNumber = 1; pageNumber <= 10; pageNumber++) {
const page = await grep({
pattern: "TODO",
path: fixture,
...(cursor ? { cursor } : {}),
});
pages.push({
page: pageNumber,
matches: page.matches.length,
cursor: page.cursor ?? null,
details: page.details,
});
matches.push(...page.matches);
cursor = page.cursor;
if (!cursor) break;
}
const expected = [
...Array.from(
{ length: 30 },
(_, i) => `TODO fix ${String(i + 1).padStart(2, "0")}`,
),
"TODO app",
"TODO utils",
"TODO readme",
];
const missing = expected.filter((match) => !matches.includes(match));
console.log(
JSON.stringify(
{
runtime: process.versions.bun
? `bun ${process.versions.bun}`
: `node ${process.version}`,
controlMatches: control.matches.length,
pages,
retrieved: matches.length,
missing,
},
null,
2,
),
);
for (const handler of extension.handlers.get("session_shutdown") ?? []) {
await handler({ type: "session_shutdown" });
}
assert.equal(matches.length, 33, "cursor pagination did not retrieve all matches");
Run either runtime:
node repro.mjs
# or
bun repro.mjs
Actual behavior
Both runtimes consistently produce:
{
"controlMatches": 33,
"pages": [
{ "page": 1, "matches": 21, "cursor": "fff_c1" },
{ "page": 2, "matches": 2, "cursor": null }
],
"retrieved": 23,
"missing": [
"TODO fix 21",
"TODO fix 22",
"TODO fix 23",
"TODO fix 24",
"TODO fix 25",
"TODO fix 26",
"TODO fix 27",
"TODO fix 28",
"TODO fix 29",
"TODO fix 30"
]
}
The exact per-page distribution may vary with frecency ordering, but the ten matches beyond the per-file cap remain unreachable after cursor exhaustion.
Expected behavior
Walking ffgrep cursors until no cursor remains should retrieve all indexed exact matches, or the tool should explicitly report that per-file matches were discarded and cannot appear on later pages.
This is especially important because pi-fff offers an override mode for Pi's built-in grep.
Apparent cause
Current pi-fff passes the same value for both limits:
const pageSize = Math.min(effectiveLimit, GREP_PAGE_SIZE_MAX);
picker.grep(query, {
maxMatchesPerFile: pageSize,
pageSize,
cursor: ...,
});
FFF grep cursors store a file offset rather than an offset within a file. Once the first 20 matches from noise.ts are collected, the next cursor advances to later files and can never return matches 21–30 from noise.ts.
This appears related to:
Current main still passes pageSize as maxMatchesPerFile, and packages/pi-fff/test does not appear to exercise ffgrep execution or cursor completeness.
Possible directions
- Use a cursor that can resume within a file, or otherwise decouple page size from a destructive per-file cap.
- Add an explicit exhaustive mode if ranked preview behavior is intentional.
- At minimum, expose a clear truncation notice when matches beyond
maxMatchesPerFile cannot be retrieved.
- Add a regression test with more than
limit matches in one file and consume cursors to exhaustion.
Environment
@ff-labs/pi-fff:0.10.5@ff-labs/fff-node/@ff-labs/fff-bun:0.10.5@earendil-works/pi-coding-agent:0.84.222.23.1and Bun1.4.0Summary
ffgrepuses the requested/defaultlimitas bothpageSizeandmaxMatchesPerFile. Grep cursors advance by file offset, so matches beyondmaxMatchesPerFilein a file are permanently unreachable: following every returned cursor still does not retrieve them.With 33 indexed matches—30 in one file and one in each of three other files—the default
limit=20retrieves only 23. Matches 21–30 in the noisy file are never returned. Alimit=50control call returns all 33, proving the files and lines were indexed before pagination was tested.Reproduction
Run in a fresh temporary directory:
Save as
repro.mjs:Run either runtime:
node repro.mjs # or bun repro.mjsActual behavior
Both runtimes consistently produce:
{ "controlMatches": 33, "pages": [ { "page": 1, "matches": 21, "cursor": "fff_c1" }, { "page": 2, "matches": 2, "cursor": null } ], "retrieved": 23, "missing": [ "TODO fix 21", "TODO fix 22", "TODO fix 23", "TODO fix 24", "TODO fix 25", "TODO fix 26", "TODO fix 27", "TODO fix 28", "TODO fix 29", "TODO fix 30" ] }The exact per-page distribution may vary with frecency ordering, but the ten matches beyond the per-file cap remain unreachable after cursor exhaustion.
Expected behavior
Walking
ffgrepcursors until no cursor remains should retrieve all indexed exact matches, or the tool should explicitly report that per-file matches were discarded and cannot appear on later pages.This is especially important because
pi-fffoffers anoverridemode for Pi's built-ingrep.Apparent cause
Current
pi-fffpasses the same value for both limits:FFF grep cursors store a file offset rather than an offset within a file. Once the first 20 matches from
noise.tsare collected, the next cursor advances to later files and can never return matches 21–30 fromnoise.ts.This appears related to:
pageSizealongsidemaxMatchesPerFileand states that excess remains retrievable via cursor, but did not add a same-file overflow regression test.Current
mainstill passespageSizeasmaxMatchesPerFile, andpackages/pi-fff/testdoes not appear to exerciseffgrepexecution or cursor completeness.Possible directions
maxMatchesPerFilecannot be retrieved.limitmatches in one file and consume cursors to exhaustion.