feat(cursor): honor pi_grep's context and limit

`pi_grep` carries a context width and a total match cap. Neither is
expressible in the model-facing `grep` schema — context comes from
`grep.contextBefore`/`grep.contextAfter`, fixed when the shared tool is
constructed — so both were dropped.

`GrepTool` now takes them as constructor options. The model-facing
schema is unchanged: this is a seam for wire bridges whose protocol
supplies the values, mirroring `GlobTool`'s existing options bag. The
bridge builds a per-call `grep` only for frames that supply them;
everything else keeps the shared instance and session defaults.

`pi_ls`'s `limit` stays unmapped, now deliberately and documented. It
caps directory entries, while the local `read` renders a depth-2 tree
and slices rendered lines — nested rows, headers and elision summaries
all count — so `:1+K` would cap a different unit while looking honored.

Verified against real files in a temp dir, not captured arguments:
match counts and context lines are asserted from actual search output.
Mutation-checked — ignoring either option, or dropping the scoped tool
in the bridge, fails a test.

(cherry picked from commit 299ded5a274427c2c2d5de27c00a2056a709581c)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 09:49:34 -03:00
committed by can1357
parent 7b62fef366
commit 9436fb5640
7 changed files with 175 additions and 21 deletions
+53 -1
View File
@@ -20,7 +20,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { GrepTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { GrepTool, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import { AdviseTool } from "../src/advisor/advise-tool";
@@ -82,6 +82,58 @@ describe("CursorExecHandlers.grep bridge", () => {
} as any);
expect((sensitiveResult.details as { matchCount?: number } | undefined)?.matchCount).toBe(1);
});
it("honors pi_grep's requested match limit against real files", async () => {
// The frame's `limit` caps total surfaced matches. The model-facing schema
// has no such parameter, so without a per-call tool the cap is dropped and
// the search returns everything it found.
await Bun.write(path.join(cwd, "many.txt"), Array.from({ length: 10 }, (_, i) => `needle ${i}`).join("\n"));
const scopedHandlers = new CursorExecHandlers({
cwd,
tools: new Map<string, Tool>([["grep", searchTool]]),
createGrepTool: options => new GrepTool(createTestSession(cwd), options),
});
const capped = await scopedHandlers.piGrep({
toolCallId: "c1",
args: { pattern: "needle", path: cwd, limit: 3 },
} as never);
expect((capped.details as { matchCount?: number } | undefined)?.matchCount).toBe(3);
const uncapped = await scopedHandlers.piGrep({
toolCallId: "c2",
args: { pattern: "needle", path: cwd },
} as never);
expect((uncapped.details as { matchCount?: number } | undefined)?.matchCount).toBe(10);
});
it("honors pi_grep's requested context width against real files", async () => {
// `context` has no schema parameter either: the width is read from
// settings fixed at tool construction, so the frame's value only lands
// through a per-call instance.
await Bun.write(path.join(cwd, "ctx.txt"), "before line\nneedle here\nafter line\n");
const scopedHandlers = new CursorExecHandlers({
cwd,
tools: new Map<string, Tool>([["grep", searchTool]]),
createGrepTool: options => new GrepTool(createTestSession(cwd), options),
});
const noContext = await scopedHandlers.piGrep({
toolCallId: "c1",
args: { pattern: "needle here", path: path.join(cwd, "ctx.txt"), context: 0 },
} as never);
const noContextText = noContext.content.map(c => (c.type === "text" ? c.text : "")).join("");
expect(noContextText).not.toContain("before line");
expect(noContextText).not.toContain("after line");
const withContext = await scopedHandlers.piGrep({
toolCallId: "c2",
args: { pattern: "needle here", path: path.join(cwd, "ctx.txt"), context: 1 },
} as never);
const withContextText = withContext.content.map(c => (c.type === "text" ? c.text : "")).join("");
expect(withContextText).toContain("before line");
expect(withContextText).toContain("after line");
});
});
describe("CursorExecHandlers error results", () => {