pi-hashline-edit-pro 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { readConfig, type Config } from "./config";
4
+
5
+ export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode";
6
+
7
+ export interface ConfigRow {
8
+ key: ConfigToggleKey;
9
+ label: string;
10
+ hint: string;
11
+ enabled: boolean;
12
+ mode?: string;
13
+ cycle?: string[];
14
+ }
15
+
16
+ export function configRows(config: Config): ConfigRow[] {
17
+ return [
18
+ { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
19
+ { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
20
+ { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
21
+ { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
22
+ { key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "on") !== "off", mode: config.boundaryDedupMode ?? "on", cycle: ["on", "strict", "off"] },
23
+ ];
24
+ }
25
+
26
+ function padRow(theme: Theme, innerWidth: number, content: string): string {
27
+ const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
28
+ return theme.fg("border", "│") + padded + theme.fg("border", "│");
29
+ }
30
+
31
+ function modeBox(theme: Theme, mode: string): string {
32
+ if (mode === "off") return theme.fg("dim", `[${mode}]`);
33
+ if (mode === "strict") return theme.fg("accent", `[${mode}]`);
34
+ return theme.fg("success", `[${mode}]`);
35
+ }
36
+
37
+ export class HashlineConfigOverlay {
38
+ private rows: ConfigRow[];
39
+ private selected = 0;
40
+
41
+ constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey) => Promise<void> }) {
42
+ this.rows = [];
43
+ }
44
+
45
+ async load(): Promise<void> {
46
+ this.rows = configRows(await readConfig());
47
+ }
48
+
49
+ private toggleSelected(): void {
50
+ const row = this.rows[this.selected];
51
+ if (!row) return;
52
+ if (row.cycle && row.mode !== undefined) {
53
+ const next = row.cycle[(row.cycle.indexOf(row.mode) + 1) % row.cycle.length] ?? row.cycle[0];
54
+ if (next === undefined) return;
55
+ row.mode = next;
56
+ row.enabled = next !== "off";
57
+ } else {
58
+ row.enabled = !row.enabled;
59
+ }
60
+ this.opts.tui.requestRender(true);
61
+ void this.opts.onToggle(row.key).then(async () => {
62
+ this.rows = configRows(await readConfig());
63
+ this.opts.tui.requestRender(true);
64
+ }).catch((error: unknown) => {
65
+ console.error("Failed to toggle hashline setting:", error);
66
+ });
67
+ }
68
+
69
+ handleInput(data: string): void {
70
+ if (matchesKey(data, Key.up) || data === "k") {
71
+ this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
72
+ return;
73
+ }
74
+ if (matchesKey(data, Key.down) || data === "j") {
75
+ this.selected = (this.selected + 1) % this.rows.length;
76
+ return;
77
+ }
78
+ if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
79
+ this.toggleSelected();
80
+ return;
81
+ }
82
+ if (matchesKey(data, Key.escape) || data === "q") {
83
+ this.opts.done();
84
+ }
85
+ }
86
+
87
+ invalidate(): void {
88
+ }
89
+
90
+ render(width: number): string[] {
91
+ const theme = this.opts.theme;
92
+ const innerWidth = width - 2;
93
+ const lines: string[] = [];
94
+ lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
95
+ lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Hashline Config"))}`));
96
+ lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
97
+ this.rows.forEach((row, index) => {
98
+ const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
99
+ const box = row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
100
+ const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
101
+ lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
102
+ });
103
+ lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
104
+ lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
105
+ lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
106
+ return lines;
107
+ }
108
+ }
package/src/config.ts CHANGED
@@ -3,16 +3,33 @@ import { configPath } from "./paths";
3
3
  import { errCode, isRec } from "./utils";
4
4
  import { writeAtomic } from "./fs-write";
5
5
 
6
+ export type BoundaryDedupMode = "on" | "off" | "strict";
7
+
6
8
  export interface Config {
7
9
  autoRead: boolean;
8
10
  anchorGrepEnabled: boolean;
11
+ requirePath?: boolean;
12
+ strictInput?: boolean;
13
+ boundaryDedupMode?: BoundaryDedupMode;
9
14
  }
10
15
 
11
16
  const DEFAULT_CONFIG: Config = {
12
17
  autoRead: true,
13
- anchorGrepEnabled: false
18
+ anchorGrepEnabled: true,
19
+ requirePath: false,
20
+ strictInput: false,
21
+ boundaryDedupMode: "on"
14
22
  };
15
23
 
24
+ const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
25
+
26
+ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
27
+ if (mode === "on" || mode === "strict" || mode === "off") return mode;
28
+ if (legacy === true) return "on";
29
+ if (legacy === false) return "off";
30
+ return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
31
+ }
32
+
16
33
  function parseConfig(content: string): Config {
17
34
  const parsed = JSON.parse(content) as unknown;
18
35
  const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
@@ -20,9 +37,16 @@ function parseConfig(content: string): Config {
20
37
  throw new Error("config.json must be an object with a boolean autoRead field");
21
38
  }
22
39
  const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
40
+ const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
41
+ const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
42
+ const boundaryDedupMode = isRec(parsed) ? parsed.boundaryDedupMode : undefined;
43
+ const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
23
44
  return {
24
45
  autoRead,
25
46
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
47
+ requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
48
+ strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
49
+ boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
26
50
  };
27
51
  }
28
52
 
@@ -56,3 +80,26 @@ export async function toggleAnchorGrep(): Promise<boolean> {
56
80
  await writeConfig(config);
57
81
  return config.anchorGrepEnabled;
58
82
  }
83
+
84
+ export async function toggleRequirePath(): Promise<boolean> {
85
+ const config = await readConfig();
86
+ config.requirePath = !config.requirePath;
87
+ await writeConfig(config);
88
+ return config.requirePath;
89
+ }
90
+
91
+ export async function toggleStrictInput(): Promise<boolean> {
92
+ const config = await readConfig();
93
+ config.strictInput = !(config.strictInput === true);
94
+ await writeConfig(config);
95
+ return config.strictInput === true;
96
+ }
97
+
98
+ export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
99
+ const config = await readConfig();
100
+ const current = config.boundaryDedupMode ?? "on";
101
+ const next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
102
+ config.boundaryDedupMode = next;
103
+ await writeConfig(config);
104
+ return next;
105
+ }
@@ -3,11 +3,84 @@ import { resolveInCwd } from "./fs-write";
3
3
  import { abortIf, makePrepareArguments } from "./utils";
4
4
  import { ownerOf } from "./anchor-registry";
5
5
  import { parseHashRef, stripAnchorRow } from "./hashline";
6
+ import { readConfig, type BoundaryDedupMode } from "./config";
6
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
7
8
  import type { ReplaceDetails } from "./replace";
8
-
9
9
  export const editPrepare = makePrepareArguments();
10
10
 
11
+ export interface EditToolFlags {
12
+ requirePath: boolean;
13
+ strictInput: boolean;
14
+ boundaryDedupMode: BoundaryDedupMode;
15
+ autoRead: boolean;
16
+ }
17
+
18
+ export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
19
+ requirePath: false,
20
+ strictInput: false,
21
+ boundaryDedupMode: "on",
22
+ autoRead: true
23
+ };
24
+
25
+ export async function currentEditFlags(): Promise<EditToolFlags> {
26
+ const config = await readConfig();
27
+ return {
28
+ requirePath: config.requirePath === true,
29
+ strictInput: config.strictInput === true,
30
+ boundaryDedupMode: config.boundaryDedupMode ?? "on",
31
+ autoRead: config.autoRead !== false
32
+ };
33
+ }
34
+
35
+ export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
36
+ let description = base.description;
37
+ const snippetParts = [base.snippet];
38
+ let guidelines = [...base.guidelines];
39
+ if (!flags.autoRead) {
40
+ description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
41
+ guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one batch per file per turn; verify each result before the next edit on that file." : guideline);
42
+ }
43
+ const descriptionParts = [description];
44
+ if (flags.requirePath) {
45
+ descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
46
+ snippetParts.push("; include `path` (required)");
47
+ guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
48
+ }
49
+ if (flags.strictInput) {
50
+ descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
51
+ guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
52
+ }
53
+ if (flags.boundaryDedupMode === "off") {
54
+ descriptionParts.push("Boundary dedup is off: edits apply literally.");
55
+ guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
56
+ } else if (flags.boundaryDedupMode === "strict") {
57
+ descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
58
+ guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
59
+ }
60
+ return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
61
+ }
62
+
63
+ export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
64
+ if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
65
+ const guidelines = base.guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
66
+ return { description: base.description, snippet: base.snippet, guidelines };
67
+ }
68
+
69
+ export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
70
+ const descriptionParts = [base.description];
71
+ const snippetParts = [base.snippet];
72
+ const guidelines = [...base.guidelines];
73
+ if (flags.requirePath) {
74
+ descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
75
+ snippetParts.push("; include `path` (required)");
76
+ guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
77
+ }
78
+ if (flags.strictInput) {
79
+ descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
80
+ guidelines.push("`insert`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
81
+ }
82
+ return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
83
+ }
11
84
  export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
12
85
  const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
13
86
  const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
@@ -35,6 +108,47 @@ export function tryResolveEditTarget(removeFrom: string | undefined, removeTo?:
35
108
  }
36
109
  }
37
110
 
111
+ export interface PathRequirementInput {
112
+ removeFrom?: string;
113
+ removeTo?: string;
114
+ anchor?: string;
115
+ providedPath?: unknown;
116
+ cwd: string;
117
+ }
118
+
119
+ export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
120
+ const { requirePath } = await readConfig();
121
+ if (!requirePath && input.providedPath !== undefined) {
122
+ throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
123
+ }
124
+ if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
125
+ throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
126
+ }
127
+ const anchorTarget = typeof input.anchor === "string"
128
+ ? resolveEditTarget(input.anchor)
129
+ : resolveEditTarget(input.removeFrom as string, input.removeTo);
130
+ if (requirePath) {
131
+ const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
132
+ if (resolved !== anchorTarget) {
133
+ throw new Error(`[E_BAD_SHAPE] Provided "path" "${input.providedPath}" does not match anchor ownership "${anchorTarget}".`);
134
+ }
135
+ }
136
+ return anchorTarget;
137
+ }
138
+
139
+ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
140
+ const fixes = warnings.filter((warning) => warning.startsWith("[W_"));
141
+ if (fixes.length === 0) return;
142
+ const { strictInput } = await readConfig();
143
+ if (strictInput === true) {
144
+ throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input:\n${fixes.join("\n")}`);
145
+ }
146
+ }
147
+
148
+ export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
149
+ return (await readConfig()).boundaryDedupMode ?? "on";
150
+ }
151
+
38
152
  export function editRenderCallWrapper(
39
153
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
40
154
  getInput?: (args: unknown) => { path?: string } | null,
package/src/fs-write.ts CHANGED
@@ -101,12 +101,18 @@ const TEMP_PREFIX = ".tmp-";
101
101
  const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
102
102
  const STALE_TEMP_MS = 60 * 60 * 1000;
103
103
  const sweptDirs = new Map<string, number>();
104
+ const SWEPT_DIRS_LIMIT = 256;
104
105
 
105
106
  async function sweepStaleTemps(dir: string): Promise<void> {
106
107
  const sweepNow = Date.now();
107
108
  const lastSweep = sweptDirs.get(dir);
108
109
  if (lastSweep !== undefined && sweepNow - lastSweep < STALE_TEMP_MS) return;
110
+ sweptDirs.delete(dir);
109
111
  sweptDirs.set(dir, sweepNow);
112
+ if (sweptDirs.size > SWEPT_DIRS_LIMIT) {
113
+ const oldest = sweptDirs.keys().next().value;
114
+ if (oldest !== undefined) sweptDirs.delete(oldest);
115
+ }
110
116
  try {
111
117
  const entries = await readdir(dir, { withFileTypes: true });
112
118
  for (const entry of entries) {
package/src/grep.ts CHANGED
@@ -14,6 +14,8 @@ import { normReq } from "./payload-contract";
14
14
  import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
15
15
  import { markServed as markServedScoped } from "./anchor-registry";
16
16
  import { buildServedMap } from "./served";
17
+ import { Text } from "@earendil-works/pi-tui";
18
+ import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
17
19
  const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
18
20
 
19
21
  function cmp(a: string, b: string): number {
@@ -452,6 +454,79 @@ const grepToolSchema = Type.Object(
452
454
  { additionalProperties: false },
453
455
  );
454
456
 
457
+
458
+ const GREP_PREVIEW_LINES = 16;
459
+ const GREP_PREVIEW_LINES_EXPANDED = 40;
460
+
461
+ export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: unknown; literal?: unknown; ignoreCase?: unknown; context?: unknown; limit?: unknown } | undefined, theme: CallT): string {
462
+ const pattern = typeof args?.pattern === "string" && args.pattern.length > 0 ? theme.fg("accent", args.pattern) : theme.fg("toolOutput", "...");
463
+ const qualifiers: string[] = [];
464
+ if (typeof args?.path === "string" && args.path.length > 0) qualifiers.push(args.path);
465
+ if (typeof args?.glob === "string" && args.glob.length > 0) qualifiers.push(args.glob);
466
+ if (args?.literal === true) qualifiers.push("literal");
467
+ if (args?.ignoreCase === true) qualifiers.push("case-insensitive");
468
+ if (typeof args?.context === "number") qualifiers.push(`context ${args.context}`);
469
+ if (typeof args?.limit === "number") qualifiers.push(`limit ${args.limit}`);
470
+ let text = `${theme.fg("toolTitle", theme.bold("anchor_grep"))} ${pattern}`;
471
+ if (qualifiers.length > 0) text += ` ${theme.fg("dim", qualifiers.join(" "))}`;
472
+ return text;
473
+ }
474
+
475
+ function highlightRegex(args: unknown): RegExp | undefined {
476
+ if (!isRec(args) || typeof args.pattern !== "string" || args.pattern.length === 0) return undefined;
477
+ try {
478
+ const validated = buildRegex(args.pattern, args.literal === true, args.ignoreCase === true);
479
+ return new RegExp(validated.source, validated.flags.includes("i") ? "giu" : "gu");
480
+ } catch {
481
+ return undefined;
482
+ }
483
+ }
484
+
485
+ function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
486
+ let out = "";
487
+ let last = 0;
488
+ regex.lastIndex = 0;
489
+ for (;;) {
490
+ const match = regex.exec(text);
491
+ if (!match || match[0].length === 0) break;
492
+ out += text.slice(last, match.index) + theme.fg("accent", match[0]);
493
+ last = match.index + match[0].length;
494
+ }
495
+ return out + text.slice(last);
496
+ }
497
+
498
+ const ANCHORED_ROW_RE = /^[A-Za-z0-9]{4}│/;
499
+
500
+ function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
501
+ const anchored = row.match(ANCHORED_ROW_RE);
502
+ if (!anchored) return highlightMatches(row, highlight, theme);
503
+ return anchored[0] + highlightMatches(row.slice(anchored[0].length), highlight, theme);
504
+ }
505
+
506
+ function styleGrepLine(line: string, highlight: RegExp | undefined, theme: FgT): string {
507
+ if (line.startsWith("=== ")) return theme.fg("accent", line);
508
+ if (line.startsWith("[grep:") || line.startsWith("... ")) return theme.fg("dim", line);
509
+ if (!highlight) return line;
510
+ const gutter = line.indexOf(" │ ");
511
+ if (gutter < 0) return highlightHitRow(line, highlight, theme);
512
+ const cut = gutter + " │ ".length;
513
+ return line.slice(0, cut) + highlightHitRow(line.slice(cut), highlight, theme);
514
+ }
515
+
516
+ export function renderGrepResult(result: { content?: Array<{ type: string; text?: string }> }, options: { isPartial: boolean; expanded?: boolean } | boolean, theme: FgT, context: any): Text {
517
+ const isPartial = typeof options === "boolean" ? options : options.isPartial;
518
+ const expanded = typeof options === "boolean" ? context.expanded === true : options.expanded === true || context.expanded === true;
519
+ if (isPartial) return reuseText(context, theme.fg("warning", "Searching..."));
520
+ const raw = getResultText(result);
521
+ if (context.isError) return raw ? reuseText(context, `\n${theme.fg("error", raw)}`) : new Text("", 0, 0);
522
+ if (!raw) return new Text("", 0, 0);
523
+ const maxLines = expanded ? GREP_PREVIEW_LINES_EXPANDED : GREP_PREVIEW_LINES;
524
+ const lines = raw.split("\n");
525
+ const highlight = highlightRegex((context as { args?: unknown }).args);
526
+ const shown = lines.slice(0, maxLines).map((line) => styleGrepLine(line, highlight, theme));
527
+ if (lines.length > maxLines) shown.push(theme.fg("muted", `... ${lines.length - maxLines} more grep lines (${expandHint()})`));
528
+ return reuseText(context, shown.join("\n"));
529
+ }
455
530
  export function regGrep(pi: ExtensionAPI): void {
456
531
  pi.registerTool({
457
532
  name: "anchor_grep",
@@ -462,6 +537,14 @@ export function regGrep(pi: ExtensionAPI): void {
462
537
  prepareArguments: makePrepareArguments(),
463
538
  parameters: grepToolSchema,
464
539
  executionMode: "sequential",
540
+ renderCall(args: any, theme: CallT, context: any) {
541
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
542
+ text.setText(fmtGrepCall(args as { pattern?: unknown; path?: unknown } | undefined, theme));
543
+ return text;
544
+ },
545
+ renderResult(result, opts, theme, context) {
546
+ return renderGrepResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
547
+ },
465
548
 
466
549
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
467
550
  const canonical = normReq(params);
@@ -594,7 +677,7 @@ export function regGrep(pi: ExtensionAPI): void {
594
677
  if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
595
678
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
596
679
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
597
- if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; free anchors with /clear-anchors or narrow the search.]`);
680
+ if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
598
681
  const truncated = limitTruncated || rowTruncated;
599
682
  const truncation: TruncationResult | undefined = rowTruncated
600
683
  ? {
@@ -12,7 +12,7 @@ export const ALPH_RE = new RegExp(`^[${ALNUM}]+$`);
12
12
 
13
13
  export const HASH_CLASS = `[${ALNUM}]{${HASH_LEN}}`;
14
14
 
15
- export const HASH_RUN = `[${ALNUM}]{1,${HASH_LEN * 2}}`;
15
+ export const HASH_RUN = `[${ALNUM}]{${HASH_LEN},${HASH_LEN + 1}}`;
16
16
 
17
17
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
18
18