@xynogen/pix-pretty 1.2.0 → 1.3.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.
package/src/index.ts CHANGED
@@ -1,26 +1,28 @@
1
1
  /**
2
2
  * pi-pretty — Pretty terminal output for pi built-in tools.
3
3
  *
4
- * Entry point: wraps SDK factory tools (read/bash/ls/find/grep + multi_grep),
5
- * delegates execute() unchanged, and attaches custom renderCall/renderResult.
4
+ * Entry point: boots shared state, registers all tool overrides and commands.
6
5
  *
7
6
  * Modules:
8
- * types.ts shared interfaces/types
9
- * config.ts theme + thresholds
10
- * ansi.ts ANSI codes, low-contrast fix
11
- * utils.ts helpers + renderToolError
12
- * lang.ts language detection
13
- * image.ts terminal image protocols
14
- * icons.ts Nerd Font file-type icons
15
- * highlight.ts cli-highlight engine + ANSI cache
16
- * renderers.ts renderFileContent/Bash/Tree/Find/Grep
17
- * fff.ts Fast File Finder + cursor store + multi-grep fallback
7
+ * types.ts shared interfaces/types
8
+ * config.ts theme + thresholds
9
+ * ansi.ts ANSI codes, low-contrast fix
10
+ * utils.ts helpers + renderToolError
11
+ * lang.ts language detection
12
+ * image.ts terminal image protocols
13
+ * icons.ts Nerd Font file-type icons
14
+ * highlight.ts cli-highlight engine + ANSI cache
15
+ * renderers.ts renderFileContent/Bash/Tree/Find/Grep
16
+ * fff.ts Fast File Finder + cursor store + multi-grep fallback
17
+ * diff.ts unified diff parser
18
+ * diff-render.ts split/word-level diff renderer
19
+ * tools/ per-tool registrars (read/bash/ls/find/grep/multi-grep/edit/write)
20
+ * commands/ slash command registrars (fff)
18
21
  */
19
22
 
20
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
+ import { mkdirSync } from "node:fs";
21
24
  import { join } from "node:path";
22
25
  import type {
23
- AgentToolUpdateCallback,
24
26
  BashToolInput,
25
27
  EditToolInput,
26
28
  ExtensionContext,
@@ -28,97 +30,64 @@ import type {
28
30
  GrepToolInput,
29
31
  LsToolInput,
30
32
  ReadToolInput,
31
- ToolRenderResultOptions,
32
33
  WriteToolInput,
33
34
  } from "@earendil-works/pi-coding-agent";
34
- import type { FileItem, GrepResult, SearchResult } from "@ff-labs/fff-node";
35
-
36
- import { FG_DIM, RST, resolveBaseBackground } from "./ansi.js";
37
- import {
38
- getDefaultAgentDir,
39
- MAX_PREVIEW_LINES,
40
- MAX_RENDER_LINES,
41
- setPrettyTheme,
42
- } from "./config.js";
43
- import { parseDiff } from "./diff.js";
44
- import {
45
- diffThemeCacheKey,
46
- renderSplit,
47
- resolveDiffColors,
48
- summarize,
49
- } from "./diff-render.js";
35
+ import { registerFffCommands } from "./commands/fff.js";
36
+ import { getDefaultAgentDir, setPrettyTheme } from "./config.js";
50
37
  import {
51
38
  CursorStore,
52
39
  fffDestroy,
53
40
  fffEnsureFinder,
54
- fffFormatGrepText,
55
41
  fffState,
56
42
  getPiPrettyFffDir,
57
43
  runMultiGrepRipgrepFallback,
58
44
  } from "./fff.js";
59
- import { clearHighlightCache, hlBlock } from "./highlight.js";
60
- import { fileIcon } from "./icons.js";
61
- import { lang } from "./lang.js";
62
- import {
63
- renderBashOutput,
64
- renderFileContent,
65
- renderFindResults,
66
- renderGrepResults,
67
- renderTree,
68
- } from "./renderers.js";
45
+ import { clearHighlightCache } from "./highlight.js";
46
+ import { registerBashTool } from "./tools/bash.js";
47
+ import type { ToolContext } from "./tools/context.js";
48
+ import { registerEditTool } from "./tools/edit.js";
49
+ import { registerFindTool } from "./tools/find.js";
50
+ import { registerGrepTool } from "./tools/grep.js";
51
+ import { registerLsTool } from "./tools/ls.js";
52
+ import { registerMultiGrepTool } from "./tools/multi-grep.js";
53
+ import { registerReadTool } from "./tools/read.js";
54
+ import { registerWriteTool } from "./tools/write.js";
69
55
  import type {
70
- BashParams,
71
- CommandContextLike,
72
- EditOperation,
73
- EditParams,
74
- EditRenderState,
75
- FindParams,
76
- FindResultDetails,
77
- GrepParams,
78
- GrepRenderState,
79
- GrepResultDetails,
80
- LsParams,
81
- MultiGrepParams,
82
- MultiGrepRenderState,
83
56
  PiPrettyApi,
84
57
  PiPrettyDeps,
85
58
  PiPrettySdk,
86
- ReadParams,
87
- RenderContextLike,
88
- RenderDetails,
89
59
  TextComponentCtor,
90
- ThemeLike,
91
60
  ToolFactory,
92
- ToolResultLike,
93
- WriteParams,
94
- WriteRenderState,
95
61
  } from "./types.js";
96
- import {
97
- appendNotices,
98
- buildLiteralAlternationPattern,
99
- countRipgrepMatches,
100
- fillToolBackground,
101
- getConstraintBackedPath,
102
- getErrorMessage,
103
- getTextContent,
104
- humanSize,
105
- isImageContent,
106
- isTextContent,
107
- makeTextResult,
108
- normalizeLineEndings,
109
- renderToolError,
110
- rule,
111
- setResultDetails,
112
- shortPath,
113
- shouldIgnoreCaseForPatterns,
114
- termW,
115
- trimToUndefined,
116
- } from "./utils.js";
62
+ import { getErrorMessage, shortPath } from "./utils.js";
63
+
64
+ // ── Resize invalidation registry ───────────────────────────────────────
65
+ // Diff/write renderResults register their ctx.invalidate keyed by toolCallId
66
+ // so terminal resize triggers re-render at the correct width.
67
+
68
+ const _resizeInvalidators = new Map<string, () => void>();
69
+ let _resizeListenerAttached = false;
70
+
71
+ function attachResizeListener(): void {
72
+ if (_resizeListenerAttached) return;
73
+ _resizeListenerAttached = true;
74
+ process.stdout.on("resize", () => {
75
+ for (const inv of _resizeInvalidators.values()) inv();
76
+ });
77
+ }
78
+
79
+ function trackInvalidator(toolCallId: string, inv: () => void): void {
80
+ _resizeInvalidators.set(toolCallId, inv);
81
+ }
82
+
83
+ // ── Extension entry point ──────────────────────────────────────────────
117
84
 
118
85
  export default function piPrettyExtension(
119
86
  pi: PiPrettyApi,
120
87
  deps?: PiPrettyDeps,
121
88
  ): void {
89
+ attachResizeListener();
90
+
122
91
  let createReadTool: ToolFactory<ReadToolInput> | undefined;
123
92
  let createBashTool: ToolFactory<BashToolInput> | undefined;
124
93
  let createLsTool: ToolFactory<LsToolInput> | undefined;
@@ -127,13 +96,11 @@ export default function piPrettyExtension(
127
96
  let createEditTool: ToolFactory<EditToolInput> | undefined;
128
97
  let createWriteTool: ToolFactory<WriteToolInput> | undefined;
129
98
  let TextComponent: TextComponentCtor;
130
-
131
99
  let sdk: PiPrettySdk;
132
100
 
133
- const _cursorStore = new CursorStore();
101
+ const cursorStore = new CursorStore();
134
102
 
135
103
  if (deps) {
136
- // Test path: use injected dependencies, reset module state
137
104
  sdk = deps.sdk;
138
105
  createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
139
106
  createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
@@ -162,7 +129,7 @@ export default function piPrettyExtension(
162
129
  return;
163
130
  }
164
131
  }
165
- if (!createReadTool || !TextComponent) return;
132
+ if (!createReadTool || !TextComponent!) return;
166
133
 
167
134
  const cwd = process.cwd();
168
135
  const home = process.env.HOME ?? "";
@@ -170,20 +137,17 @@ export default function piPrettyExtension(
170
137
  const multiGrepRipgrepFallback =
171
138
  deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
172
139
 
173
- // Parse PRETTY_DISABLE_TOOLS comma-separated tool names to skip
140
+ // Respect PRETTY_DISABLE_TOOLS env var
174
141
  const disabledTools = new Set(
175
142
  (process.env.PRETTY_DISABLE_TOOLS ?? "")
176
143
  .split(",")
177
144
  .map((s) => s.trim().toLowerCase())
178
145
  .filter(Boolean),
179
146
  );
180
- function isToolEnabled(name: string): boolean {
181
- return !disabledTools.has(name.toLowerCase());
182
- }
147
+ const isToolEnabled = (name: string) =>
148
+ !disabledTools.has(name.toLowerCase());
183
149
 
184
- // ===================================================================
185
- // FFF initialization (optional — graceful fallback to SDK)
186
- // ===================================================================
150
+ // ── Theme + FFF init ────────────────────────────────────────────────
187
151
 
188
152
  const getAgentDir = sdk.getAgentDir;
189
153
  setPrettyTheme(
@@ -196,8 +160,8 @@ export default function piPrettyExtension(
196
160
  })(),
197
161
  );
198
162
  clearHighlightCache();
163
+
199
164
  if (!deps) {
200
- // Only try require() in production — tests inject fffModule via deps
201
165
  try {
202
166
  fffState.module = require("@ff-labs/fff-node");
203
167
  if (getAgentDir) {
@@ -216,8 +180,7 @@ export default function piPrettyExtension(
216
180
  } catch {}
217
181
  }
218
182
 
219
- pi.on("session_start", async (_event, ctx) => {
220
- // Try dynamic import if sync require failed (ESM-only package)
183
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
221
184
  if (!fffState.module) {
222
185
  try {
223
186
  const imported = await import("@ff-labs/fff-node");
@@ -242,9 +205,6 @@ export default function piPrettyExtension(
242
205
  "warning",
243
206
  );
244
207
  } else {
245
- // Confirm indexing via a transient toast instead of a footer status
246
- // segment — the footer sorts extension statuses by key, and "fff"
247
- // sorting ahead of other extensions shifted their indicators.
248
208
  ctx.ui?.notify?.("FFF indexed", "info");
249
209
  }
250
210
  } catch (error: unknown) {
@@ -256,1371 +216,47 @@ export default function piPrettyExtension(
256
216
  fffDestroy();
257
217
  });
258
218
 
259
- // ===================================================================
260
- // read — syntax-highlighted file content
261
- // ===================================================================
262
-
263
- const origRead = createReadTool(cwd);
264
-
265
- if (isToolEnabled("read")) {
266
- pi.registerTool({
267
- ...origRead,
268
- name: "read",
269
-
270
- async execute(
271
- tid: string,
272
- params: ReadParams,
273
- sig: AbortSignal | undefined,
274
- upd: AgentToolUpdateCallback<unknown> | undefined,
275
- ctx: ExtensionContext,
276
- ) {
277
- const result = (await origRead.execute(
278
- tid,
279
- params,
280
- sig,
281
- upd,
282
- ctx,
283
- )) as ToolResultLike;
284
-
285
- const fp = params.path ?? "";
286
- const offset = params.offset ?? 1;
219
+ // ── Build shared tool context ───────────────────────────────────────
287
220
 
288
- const imageBlock = result.content?.find(isImageContent);
289
- if (imageBlock) {
290
- setResultDetails(result, {
291
- _type: "readImage",
292
- filePath: fp,
293
- data: imageBlock.data,
294
- mimeType: imageBlock.mimeType ?? "image/png",
295
- });
296
- return result;
297
- }
221
+ const toolCtx: ToolContext = {
222
+ cwd,
223
+ sp,
224
+ TextComponent: TextComponent!,
225
+ fffState,
226
+ cursorStore,
227
+ multiGrepRipgrepFallback,
228
+ };
298
229
 
299
- const textContent = getTextContent(result);
300
- if (textContent && fp) {
301
- const normalizedContent = normalizeLineEndings(textContent);
302
- const lineCount = normalizedContent.split("\n").length;
303
- setResultDetails(result, {
304
- _type: "readFile",
305
- filePath: fp,
306
- content: normalizedContent,
307
- offset,
308
- lineCount,
309
- });
310
- }
230
+ // ── Register tools ──────────────────────────────────────────────────
311
231
 
312
- return result;
313
- },
314
-
315
- renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
316
- resolveBaseBackground(theme);
317
- const fp = args.path ?? "";
318
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
319
- const offset = args.offset
320
- ? ` ${theme.fg("muted", `from line ${args.offset}`)}`
321
- : "";
322
- const limit = args.limit
323
- ? ` ${theme.fg("muted", `(${args.limit} lines)`)}`
324
- : "";
325
- text.setText(
326
- fillToolBackground(
327
- `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
328
- ),
329
- );
330
- return text;
331
- },
332
-
333
- renderResult(
334
- result: ToolResultLike,
335
- _opt: unknown,
336
- theme: ThemeLike,
337
- ctx: RenderContextLike,
338
- ) {
339
- resolveBaseBackground(theme);
340
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
341
-
342
- if (ctx.isError) {
343
- text.setText(
344
- renderToolError(getTextContent(result) || "Error", theme),
345
- );
346
- return text;
347
- }
348
-
349
- const d = result.details as RenderDetails | undefined;
350
-
351
- // Image reads keep the original image content so Pi's native TUI renderer
352
- // can display it exactly once. pi-pretty only renders metadata here;
353
- // rendering another inline image caused duplicate previews.
354
- if (d?._type === "readImage") {
355
- const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
356
- const sizeStr = humanSize(byteSize);
357
- const mimeStr = d.mimeType ?? "image";
358
-
359
- text.setText(
360
- fillToolBackground(
361
- ` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`,
362
- ),
363
- );
364
- return text;
365
- }
366
-
367
- if (d?._type === "readFile" && d.content) {
368
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
369
- if (ctx.state._rk !== key) {
370
- ctx.state._rk = key;
371
- const info = `${FG_DIM}${d.lineCount} lines${RST}`;
372
- ctx.state._rt = fillToolBackground(` ${info}`);
373
-
374
- const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
375
- renderFileContent(d.content, d.filePath, d.offset, maxShow)
376
- .then((rendered: string) => {
377
- if (ctx.state._rk !== key) return;
378
- ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`);
379
- ctx.invalidate();
380
- })
381
- .catch(() => {});
382
- }
383
- text.setText(
384
- ctx.state._rt ??
385
- fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`),
386
- );
387
- return text;
388
- }
389
-
390
- // Fallback
391
- const fallback = result.content?.[0];
392
- const fallbackText =
393
- fallback && isTextContent(fallback) ? fallback.text : "read";
394
- text.setText(
395
- fillToolBackground(
396
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
397
- ),
398
- );
399
- return text;
400
- },
401
- });
232
+ if (isToolEnabled("read") && createReadTool) {
233
+ registerReadTool(pi, createReadTool, toolCtx);
402
234
  }
403
-
404
- // ===================================================================
405
- // bash — colored exit status
406
- // ===================================================================
407
-
408
- if (createBashTool) {
409
- const origBash = createBashTool(cwd);
410
-
411
- pi.registerTool({
412
- ...origBash,
413
- name: "bash",
414
-
415
- async execute(
416
- tid: string,
417
- params: BashParams,
418
- sig: AbortSignal | undefined,
419
- upd: AgentToolUpdateCallback<unknown> | undefined,
420
- ctx: ExtensionContext,
421
- ) {
422
- const result = (await origBash.execute(
423
- tid,
424
- params,
425
- sig,
426
- upd,
427
- ctx,
428
- )) as ToolResultLike;
429
- const textContent = getTextContent(result);
430
-
431
- let exitCode: number | null = 0;
432
- if (textContent) {
433
- const exitMatch = textContent.match(
434
- /(?:exit code|exited with|exit status)[:\s]*(\d+)/i,
435
- );
436
- if (exitMatch) exitCode = Number(exitMatch[1]);
437
- if (
438
- textContent.includes("command not found") ||
439
- textContent.includes("No such file")
440
- ) {
441
- exitCode = 1;
442
- }
443
- }
444
-
445
- setResultDetails(result, {
446
- _type: "bashResult",
447
- text: textContent ?? "",
448
- exitCode,
449
- command: params.command ?? "",
450
- });
451
-
452
- return result;
453
- },
454
-
455
- renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
456
- resolveBaseBackground(theme);
457
- const cmd = args.command ?? "";
458
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
459
- const timeout = args.timeout
460
- ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}`
461
- : "";
462
- const displayCmd =
463
- ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
464
- text.setText(
465
- fillToolBackground(
466
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
467
- ),
468
- );
469
- return text;
470
- },
471
-
472
- renderResult(
473
- result: ToolResultLike,
474
- _opt: unknown,
475
- theme: ThemeLike,
476
- ctx: RenderContextLike,
477
- ) {
478
- resolveBaseBackground(theme);
479
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
480
-
481
- if (ctx.isError) {
482
- text.setText(
483
- renderToolError(getTextContent(result) || "Error", theme),
484
- );
485
- return text;
486
- }
487
-
488
- const d = result.details as RenderDetails | undefined;
489
- if (d?._type === "bashResult") {
490
- const { summary } = renderBashOutput(d.text, d.exitCode);
491
- const lines = d.text.split("\n");
492
- const lineCount = lines.length;
493
- const lineInfo =
494
- lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
495
- const header = ` ${summary}${lineInfo}`;
496
-
497
- if (d.text.trim()) {
498
- const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
499
- const show = lines.slice(0, maxShow);
500
- const tw = termW();
501
- const out: string[] = [header, rule(tw)];
502
- for (const line of show) {
503
- out.push(` ${line}`);
504
- }
505
- out.push(rule(tw));
506
- if (lineCount > maxShow) {
507
- out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
508
- }
509
- text.setText(fillToolBackground(out.join("\n")));
510
- } else {
511
- text.setText(fillToolBackground(header));
512
- }
513
- return text;
514
- }
515
-
516
- const fallback = result.content?.[0];
517
- const fallbackText =
518
- fallback && isTextContent(fallback) ? fallback.text : "done";
519
- text.setText(
520
- fillToolBackground(
521
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
522
- ),
523
- );
524
- return text;
525
- },
526
- });
235
+ if (isToolEnabled("bash") && createBashTool) {
236
+ registerBashTool(pi, createBashTool, toolCtx);
527
237
  }
528
-
529
- // ===================================================================
530
- // ls — tree view with icons
531
- // ===================================================================
532
-
533
- if (createLsTool) {
534
- const origLs = createLsTool(cwd);
535
-
536
- pi.registerTool({
537
- ...origLs,
538
- name: "ls",
539
-
540
- async execute(
541
- tid: string,
542
- params: LsParams,
543
- sig: AbortSignal | undefined,
544
- upd: AgentToolUpdateCallback<unknown> | undefined,
545
- ctx: ExtensionContext,
546
- ) {
547
- const result = (await origLs.execute(
548
- tid,
549
- params,
550
- sig,
551
- upd,
552
- ctx,
553
- )) as ToolResultLike;
554
- const textContent = getTextContent(result);
555
- const fp = params.path ?? cwd;
556
- const entryCount = textContent
557
- ? textContent.trim().split("\n").filter(Boolean).length
558
- : 0;
559
-
560
- setResultDetails(result, {
561
- _type: "lsResult",
562
- text: textContent ?? "",
563
- path: fp,
564
- entryCount,
565
- });
566
-
567
- return result;
568
- },
569
-
570
- renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
571
- resolveBaseBackground(theme);
572
- const fp = args.path ?? ".";
573
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
574
- text.setText(
575
- fillToolBackground(
576
- `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`,
577
- ),
578
- );
579
- return text;
580
- },
581
-
582
- renderResult(
583
- result: ToolResultLike,
584
- _opt: unknown,
585
- theme: ThemeLike,
586
- ctx: RenderContextLike,
587
- ) {
588
- resolveBaseBackground(theme);
589
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
590
-
591
- if (ctx.isError) {
592
- text.setText(
593
- renderToolError(getTextContent(result) || "Error", theme),
594
- );
595
- return text;
596
- }
597
-
598
- const d = result.details as RenderDetails | undefined;
599
- if (d?._type === "lsResult" && d.text) {
600
- const tree = renderTree(d.text, d.path);
601
- const info = `${FG_DIM}${d.entryCount} entries${RST}`;
602
- text.setText(fillToolBackground(` ${info}\n${tree}`));
603
- return text;
604
- }
605
-
606
- const fallback = result.content?.[0];
607
- const fallbackText =
608
- fallback && isTextContent(fallback) ? fallback.text : "listed";
609
- text.setText(
610
- fillToolBackground(
611
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
612
- ),
613
- );
614
- return text;
615
- },
616
- });
238
+ if (isToolEnabled("ls") && createLsTool) {
239
+ registerLsTool(pi, createLsTool, toolCtx);
617
240
  }
618
-
619
- // ===================================================================
620
- // find — grouped file list with icons
621
- // ===================================================================
622
-
623
- if (createFindTool) {
624
- const origFind = createFindTool(cwd);
625
-
626
- pi.registerTool({
627
- ...origFind,
628
- name: "find",
629
-
630
- async execute(
631
- tid: string,
632
- params: FindParams,
633
- sig: AbortSignal | undefined,
634
- upd: unknown,
635
- ctx: ExtensionContext,
636
- ) {
637
- // Try FFF first (frecency-ranked, SIMD-accelerated)
638
- if (fffState.finder && !fffState.finder.isDestroyed) {
639
- try {
640
- const effectiveLimit = Math.max(1, params.limit ?? 200);
641
- let query = params.pattern;
642
- if (params.path) query = `${params.path} ${query}`;
643
-
644
- const searchResult = fffState.finder.fileSearch(query, {
645
- pageSize: effectiveLimit,
646
- });
647
- if (searchResult.ok) {
648
- const search: SearchResult = searchResult.value;
649
- const items: FileItem[] = search.items.slice(0, effectiveLimit);
650
- const notices: string[] = [];
651
- if (fffState.partialIndex)
652
- notices.push("Warning: partial file index");
653
- if (items.length >= effectiveLimit)
654
- notices.push(`${effectiveLimit} limit reached`);
655
- if (search.totalMatched > items.length)
656
- notices.push(`${search.totalMatched} total matches`);
657
-
658
- const textContent = appendNotices(
659
- items.map((item) => item.relativePath).join("\n"),
660
- notices,
661
- );
662
- return makeTextResult<FindResultDetails>(textContent, {
663
- _type: "findResult",
664
- text: textContent,
665
- pattern: params.pattern,
666
- matchCount: items.length,
667
- });
668
- }
669
- } catch {
670
- /* fall through to SDK */
671
- }
672
- }
673
-
674
- // SDK fallback
675
- const result = await origFind.execute(
676
- tid,
677
- params,
678
- sig,
679
- upd as never,
680
- ctx,
681
- );
682
- const textContent = getTextContent(result);
683
- const matchCount = textContent
684
- ? textContent.trim().split("\n").filter(Boolean).length
685
- : 0;
686
-
687
- setResultDetails<FindResultDetails>(result, {
688
- _type: "findResult",
689
- text: textContent,
690
- pattern: params.pattern,
691
- matchCount,
692
- });
693
-
694
- return result;
695
- },
696
-
697
- renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
698
- resolveBaseBackground(theme);
699
- const pattern = args.pattern ?? "";
700
- const path = args.path
701
- ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
702
- : "";
703
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
704
- text.setText(
705
- fillToolBackground(
706
- `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`,
707
- ),
708
- );
709
- return text;
710
- },
711
-
712
- renderResult(
713
- result: ToolResultLike<FindResultDetails>,
714
- _opt: ToolRenderResultOptions,
715
- theme: ThemeLike,
716
- ctx: RenderContextLike,
717
- ) {
718
- resolveBaseBackground(theme);
719
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
720
-
721
- if (ctx.isError) {
722
- text.setText(
723
- renderToolError(getTextContent(result) || "Error", theme),
724
- );
725
- return text;
726
- }
727
-
728
- const d = result.details;
729
- if (d?._type === "findResult" && d.text) {
730
- const rendered = renderFindResults(d.text);
731
- const info = `${FG_DIM}${d.matchCount} files${RST}`;
732
- text.setText(fillToolBackground(` ${info}\n${rendered}`));
733
- return text;
734
- }
735
-
736
- const fallback = result.content?.[0];
737
- const fallbackText =
738
- fallback && isTextContent(fallback) ? fallback.text : "found";
739
- text.setText(
740
- fillToolBackground(
741
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
742
- ),
743
- );
744
- return text;
745
- },
746
- });
241
+ if (isToolEnabled("find") && createFindTool) {
242
+ registerFindTool(pi, createFindTool, toolCtx);
747
243
  }
748
-
749
- // ===================================================================
750
- // grep — highlighted matches with line numbers
751
- // ===================================================================
752
-
753
- if (createGrepTool) {
754
- const origGrep = createGrepTool(cwd);
755
-
756
- pi.registerTool({
757
- ...origGrep,
758
- name: "grep",
759
-
760
- async execute(
761
- tid: string,
762
- params: GrepParams,
763
- sig: AbortSignal | undefined,
764
- upd: unknown,
765
- ctx: ExtensionContext,
766
- ) {
767
- // Try FFF first (SIMD-accelerated, frecency-ranked).
768
- // FFF 0.5.2 can abort the process when path/glob constraints meet
769
- // Unicode filenames, so constrained searches use the SDK fallback.
770
- if (
771
- fffState.finder &&
772
- !fffState.finder.isDestroyed &&
773
- !params.path &&
774
- !params.glob
775
- ) {
776
- try {
777
- const effectiveLimit = Math.max(1, params.limit ?? 100);
778
- const query = params.pattern;
779
-
780
- const grepResult = fffState.finder.grep(query, {
781
- mode: params.literal ? "plain" : "regex",
782
- smartCase: !params.ignoreCase,
783
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
784
- cursor: null,
785
- beforeContext: params.context ?? 0,
786
- afterContext: params.context ?? 0,
787
- });
788
-
789
- if (grepResult.ok) {
790
- const grep: GrepResult = grepResult.value;
791
- const notices: string[] = [];
792
- if (fffState.partialIndex)
793
- notices.push("Warning: partial file index");
794
- if (grep.items.length >= effectiveLimit)
795
- notices.push(`${effectiveLimit} limit reached`);
796
- if (grep.regexFallbackError)
797
- notices.push(
798
- `Regex failed: ${grep.regexFallbackError}, used literal match`,
799
- );
800
- if (grep.nextCursor) {
801
- const cursorId = _cursorStore.store(grep.nextCursor);
802
- notices.push(
803
- `More results available. Use cursor="${cursorId}" to continue`,
804
- );
805
- }
806
-
807
- const textContent = appendNotices(
808
- fffFormatGrepText(grep.items, effectiveLimit),
809
- notices,
810
- );
811
- return makeTextResult<GrepResultDetails>(textContent, {
812
- _type: "grepResult",
813
- text: textContent,
814
- pattern: params.pattern,
815
- matchCount: Math.min(grep.items.length, effectiveLimit),
816
- });
817
- }
818
- } catch {
819
- /* fall through to SDK */
820
- }
821
- }
822
-
823
- // SDK fallback
824
- const result = await origGrep.execute(
825
- tid,
826
- params,
827
- sig,
828
- upd as never,
829
- ctx,
830
- );
831
- const textContent = normalizeLineEndings(getTextContent(result));
832
- if (result.content) {
833
- for (const content of result.content) {
834
- if (isTextContent(content))
835
- content.text = normalizeLineEndings(content.text || "");
836
- }
837
- }
838
- const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
839
-
840
- setResultDetails<GrepResultDetails>(result, {
841
- _type: "grepResult",
842
- text: textContent,
843
- pattern: params.pattern,
844
- matchCount,
845
- });
846
-
847
- return result;
848
- },
849
-
850
- renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
851
- resolveBaseBackground(theme);
852
- const pattern = args.pattern ?? "";
853
- const path = args.path
854
- ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
855
- : "";
856
- const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
857
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
858
- text.setText(
859
- fillToolBackground(
860
- `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
861
- ),
862
- );
863
- return text;
864
- },
865
-
866
- renderResult(
867
- result: ToolResultLike<GrepResultDetails>,
868
- _opt: ToolRenderResultOptions,
869
- theme: ThemeLike,
870
- ctx: RenderContextLike<GrepRenderState>,
871
- ) {
872
- resolveBaseBackground(theme);
873
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
874
-
875
- if (ctx.isError) {
876
- text.setText(
877
- renderToolError(getTextContent(result) || "Error", theme),
878
- );
879
- return text;
880
- }
881
-
882
- const d = result.details;
883
- if (d?._type === "grepResult" && d.text) {
884
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
885
- if (ctx.state._gk !== key) {
886
- ctx.state._gk = key;
887
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
888
- ctx.state._gt = fillToolBackground(` ${info}`);
889
-
890
- renderGrepResults(d.text, d.pattern)
891
- .then((rendered: string) => {
892
- if (ctx.state._gk !== key) return;
893
- ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`);
894
- ctx.invalidate();
895
- })
896
- .catch(() => {});
897
- }
898
- text.setText(
899
- ctx.state._gt ??
900
- fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`),
901
- );
902
- return text;
903
- }
904
-
905
- const fallback = result.content?.[0];
906
- const fallbackText =
907
- fallback && isTextContent(fallback) ? fallback.text : "searched";
908
- text.setText(
909
- fillToolBackground(
910
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
911
- ),
912
- );
913
- return text;
914
- },
915
- });
916
- }
917
-
918
- // ===================================================================
919
- // multi_grep — OR-logic multi-pattern search (FFF when available,
920
- // SDK grep fallback otherwise)
921
- // ===================================================================
922
-
923
- if (fffState.module || createGrepTool) {
924
- const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
925
-
926
- pi.registerTool({
927
- name: "multi_grep",
928
- label: "multi_grep",
929
- description: [
930
- "Search file contents for lines matching ANY of multiple patterns (OR logic).",
931
- "Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
932
- "Falls back to ripgrep while preserving literal OR semantics and file constraints when needed.",
933
- "Patterns are literal text — never escape special characters.",
934
- "Use path to scope a directory/file and constraints for file filtering ('*.rs', 'src/', '!test/').",
935
- ].join(" "),
936
- promptSnippet:
937
- "Multi-pattern OR search across file contents (FFF-accelerated with grep fallback)",
938
- promptGuidelines: [
939
- "Use multi_grep when you need to find multiple identifiers at once (OR logic).",
940
- "Include all naming conventions: snake_case, PascalCase, camelCase variants.",
941
- "Patterns are literal text. Never escape special characters.",
942
- "Use path to scope a directory or file when you need fresh on-disk results.",
943
- "Use the constraints parameter for additional file filtering, not inside patterns.",
944
- ],
945
-
946
- parameters: {
947
- type: "object",
948
- properties: {
949
- patterns: {
950
- type: "array",
951
- items: { type: "string" },
952
- description:
953
- "Patterns to search for (OR logic — matches lines containing ANY pattern).",
954
- },
955
- path: {
956
- type: "string",
957
- description:
958
- "Directory or file path to search (default: current directory)",
959
- },
960
- constraints: {
961
- type: "string",
962
- description:
963
- "File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
964
- },
965
- context: {
966
- type: "number",
967
- description:
968
- "Number of context lines before and after each match (default: 0)",
969
- },
970
- limit: {
971
- type: "number",
972
- description: "Maximum number of matches to return (default: 100)",
973
- },
974
- },
975
- required: ["patterns"],
976
- },
977
-
978
- async execute(
979
- tid: string,
980
- params: MultiGrepParams,
981
- sig: AbortSignal | undefined,
982
- upd: unknown,
983
- ctx: ExtensionContext,
984
- ) {
985
- if (sig?.aborted) return makeTextResult("Aborted", {});
986
-
987
- if (!params.patterns || params.patterns.length === 0) {
988
- return makeTextResult(
989
- "Error: patterns array must have at least 1 element",
990
- { error: "empty patterns" },
991
- );
992
- }
993
-
994
- const effectiveLimit = Math.max(1, params.limit ?? 100);
995
- const pattern = buildLiteralAlternationPattern(params.patterns);
996
- const requestedPath = trimToUndefined(params.path);
997
- const requestedConstraints = trimToUndefined(params.constraints);
998
- const effectivePath =
999
- requestedPath ?? getConstraintBackedPath(requestedConstraints);
1000
- const hasNativeConstraints = Boolean(
1001
- requestedPath || requestedConstraints,
1002
- );
1003
-
1004
- if (
1005
- fffState.finder &&
1006
- !fffState.finder.isDestroyed &&
1007
- !hasNativeConstraints
1008
- ) {
1009
- try {
1010
- const grepResult = fffState.finder.multiGrep({
1011
- patterns: params.patterns,
1012
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
1013
- smartCase: true,
1014
- cursor: null,
1015
- beforeContext: params.context ?? 0,
1016
- afterContext: params.context ?? 0,
1017
- });
1018
-
1019
- if (!grepResult.ok) {
1020
- return makeTextResult(`multi_grep error: ${grepResult.error}`, {
1021
- error: grepResult.error,
1022
- });
1023
- }
1024
-
1025
- const grep: GrepResult = grepResult.value;
1026
- const notices: string[] = [];
1027
- if (fffState.partialIndex)
1028
- notices.push("Warning: partial file index");
1029
- if (grep.items.length >= effectiveLimit)
1030
- notices.push(`${effectiveLimit} limit reached`);
1031
- if (grep.nextCursor) {
1032
- const cursorId = _cursorStore.store(grep.nextCursor);
1033
- notices.push(`More results: cursor="${cursorId}"`);
1034
- }
1035
-
1036
- const textContent = appendNotices(
1037
- fffFormatGrepText(grep.items, effectiveLimit),
1038
- notices,
1039
- );
1040
- return makeTextResult<GrepResultDetails>(textContent, {
1041
- _type: "grepResult",
1042
- text: textContent,
1043
- pattern,
1044
- matchCount: Math.min(grep.items.length, effectiveLimit),
1045
- });
1046
- } catch {
1047
- /* fall through to SDK */
1048
- }
1049
- }
1050
-
1051
- if (requestedConstraints || !multiGrepFallback) {
1052
- try {
1053
- const pathBackedConstraint = Boolean(
1054
- requestedConstraints &&
1055
- !requestedPath &&
1056
- requestedConstraints === effectivePath,
1057
- );
1058
- const constraintsForRipgrep = pathBackedConstraint
1059
- ? undefined
1060
- : requestedConstraints;
1061
- const notices: string[] = [];
1062
-
1063
- if (!fffState.finder || fffState.finder.isDestroyed)
1064
- notices.push("FFF unavailable, used ripgrep fallback");
1065
- else if (hasNativeConstraints)
1066
- notices.push("Used ripgrep fallback for constrained search");
1067
- else notices.push("Used ripgrep fallback");
1068
-
1069
- const rgResult = await multiGrepRipgrepFallback({
1070
- cwd,
1071
- patterns: params.patterns,
1072
- path: effectivePath,
1073
- constraints: constraintsForRipgrep,
1074
- ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
1075
- context: params.context,
1076
- limit: effectiveLimit,
1077
- signal: sig,
1078
- });
1079
- const textContent =
1080
- normalizeLineEndings(rgResult.text) || "No matches found";
1081
- if (rgResult.limitReached)
1082
- notices.push(`${effectiveLimit} limit reached`);
1083
- const finalText = appendNotices(textContent, notices);
1084
-
1085
- return makeTextResult<GrepResultDetails>(finalText, {
1086
- _type: "grepResult",
1087
- text: finalText,
1088
- pattern,
1089
- matchCount: rgResult.matchCount,
1090
- });
1091
- } catch (error: unknown) {
1092
- const message = getErrorMessage(error);
1093
- return makeTextResult(`multi_grep error: ${message}`, {
1094
- error: message,
1095
- });
1096
- }
1097
- }
1098
-
1099
- try {
1100
- const notices: string[] = [];
1101
- if (!fffState.finder || fffState.finder.isDestroyed)
1102
- notices.push("FFF unavailable, used SDK grep fallback");
1103
-
1104
- const result = await multiGrepFallback.execute(
1105
- tid,
1106
- {
1107
- pattern,
1108
- path: effectivePath,
1109
- ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
1110
- context: params.context,
1111
- limit: params.limit,
1112
- },
1113
- sig,
1114
- upd as never,
1115
- ctx,
1116
- );
1117
- const textContent =
1118
- normalizeLineEndings(getTextContent(result)) || "No matches found";
1119
- const finalText = appendNotices(textContent, notices);
1120
-
1121
- return makeTextResult<GrepResultDetails>(finalText, {
1122
- _type: "grepResult",
1123
- text: finalText,
1124
- pattern,
1125
- matchCount: textContent ? countRipgrepMatches(textContent) : 0,
1126
- });
1127
- } catch (error: unknown) {
1128
- const message = getErrorMessage(error);
1129
- return makeTextResult(`multi_grep error: ${message}`, {
1130
- error: message,
1131
- });
1132
- }
1133
- },
1134
-
1135
- renderCall(
1136
- args: MultiGrepParams,
1137
- theme: ThemeLike,
1138
- ctx: RenderContextLike,
1139
- ) {
1140
- resolveBaseBackground(theme);
1141
- const patterns = args.patterns ?? [];
1142
- const path = args.path
1143
- ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
1144
- : "";
1145
- const constraints = args.constraints;
1146
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1147
- let content =
1148
- theme.fg("toolTitle", theme.bold("multi_grep")) +
1149
- " " +
1150
- theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
1151
- content += path;
1152
- if (constraints) content += theme.fg("muted", ` (${constraints})`);
1153
- text.setText(fillToolBackground(content));
1154
- return text;
1155
- },
1156
-
1157
- renderResult(
1158
- result: ToolResultLike<GrepResultDetails | { error?: string }>,
1159
- _opt: ToolRenderResultOptions,
1160
- theme: ThemeLike,
1161
- ctx: RenderContextLike<MultiGrepRenderState>,
1162
- ) {
1163
- resolveBaseBackground(theme);
1164
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1165
-
1166
- if (ctx.isError) {
1167
- text.setText(
1168
- `\n${theme.fg("error", getTextContent(result) || "Error")}`,
1169
- );
1170
- return text;
1171
- }
1172
-
1173
- const d = result.details;
1174
- if (d && "_type" in d && d._type === "grepResult" && d.text) {
1175
- const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
1176
- if (ctx.state._mgk !== key) {
1177
- ctx.state._mgk = key;
1178
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1179
- ctx.state._mgt = ` ${info}`;
1180
-
1181
- renderGrepResults(d.text, d.pattern)
1182
- .then((rendered: string) => {
1183
- if (ctx.state._mgk !== key) return;
1184
- ctx.state._mgt = ` ${info}\n${rendered}`;
1185
- ctx.invalidate();
1186
- })
1187
- .catch(() => {});
1188
- }
1189
- text.setText(
1190
- ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`,
1191
- );
1192
- return text;
1193
- }
1194
-
1195
- const fallback = result.content?.[0];
1196
- const fallbackText =
1197
- fallback && isTextContent(fallback) ? fallback.text : "searched";
1198
- text.setText(
1199
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
1200
- );
1201
- return text;
1202
- },
1203
- });
1204
- }
1205
-
1206
- // ===================================================================
1207
- // edit — split/unified/word-level diff preview
1208
- // ===================================================================
1209
-
1210
- function getEditOperations(input: EditParams): EditOperation[] {
1211
- if (Array.isArray(input?.edits)) {
1212
- return input.edits
1213
- .map((e) => ({
1214
- oldText:
1215
- typeof e?.oldText === "string"
1216
- ? e.oldText
1217
- : typeof e?.old_text === "string"
1218
- ? e.old_text
1219
- : "",
1220
- newText:
1221
- typeof e?.newText === "string"
1222
- ? e.newText
1223
- : typeof e?.new_text === "string"
1224
- ? e.new_text
1225
- : "",
1226
- }))
1227
- .filter((e) => e.oldText && e.oldText !== e.newText);
1228
- }
1229
- const oldText =
1230
- typeof input?.oldText === "string"
1231
- ? input.oldText
1232
- : typeof input?.old_text === "string"
1233
- ? input.old_text
1234
- : "";
1235
- const newText =
1236
- typeof input?.newText === "string"
1237
- ? input.newText
1238
- : typeof input?.new_text === "string"
1239
- ? input.new_text
1240
- : "";
1241
- return oldText && oldText !== newText ? [{ oldText, newText }] : [];
244
+ if (isToolEnabled("grep") && createGrepTool) {
245
+ registerGrepTool(pi, createGrepTool, toolCtx);
1242
246
  }
1243
-
1244
- function summarizeEditOperations(operations: EditOperation[]) {
1245
- const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
1246
- const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
1247
- const totalRemoved = diffs.reduce((sum, d) => sum + d.removed, 0);
1248
- return {
1249
- diffs,
1250
- totalAdded,
1251
- totalRemoved,
1252
- summary: summarize(totalAdded, totalRemoved),
1253
- };
247
+ if (isToolEnabled("multi_grep") && (fffState.module || createGrepTool)) {
248
+ registerMultiGrepTool(pi, createGrepTool ?? null, toolCtx);
1254
249
  }
1255
-
1256
- if (createEditTool) {
1257
- const origEdit = createEditTool(cwd);
1258
-
1259
- pi.registerTool({
1260
- ...origEdit,
1261
- name: "edit",
1262
-
1263
- async execute(
1264
- tid: string,
1265
- params: EditParams,
1266
- sig: AbortSignal | undefined,
1267
- upd: AgentToolUpdateCallback<unknown> | undefined,
1268
- ctx: ExtensionContext,
1269
- ) {
1270
- const fp = params.path ?? params.file_path ?? "";
1271
- const operations = getEditOperations(params);
1272
- // params is the live tool input (upstream EditToolInput shape); we
1273
- // type it loosely as EditParams for defensive legacy-field reads, so
1274
- // cast back to the upstream input when delegating to the real tool.
1275
- const result = (await origEdit.execute(
1276
- tid,
1277
- params as unknown as Parameters<typeof origEdit.execute>[1],
1278
- sig,
1279
- upd,
1280
- ctx,
1281
- )) as ToolResultLike;
1282
-
1283
- if (operations.length === 0) return result;
1284
-
1285
- const { diffs, summary } = summarizeEditOperations(operations);
1286
- if (operations.length === 1) {
1287
- let editLine = 0;
1288
- try {
1289
- if (fp && existsSync(fp)) {
1290
- const f = readFileSync(fp, "utf-8");
1291
- const idx = f.indexOf(operations[0].newText);
1292
- if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
1293
- }
1294
- } catch {
1295
- editLine = 0;
1296
- }
1297
- setResultDetails(result, {
1298
- _type: "editInfo",
1299
- summary,
1300
- editLine,
1301
- });
1302
- return result;
1303
- }
1304
-
1305
- setResultDetails(result, {
1306
- _type: "multiEditInfo",
1307
- summary,
1308
- editCount: operations.length,
1309
- diffLineCount: diffs.reduce((sum, d) => sum + d.lines.length, 0),
1310
- });
1311
- return result;
1312
- },
1313
-
1314
- renderCall(
1315
- args: EditParams,
1316
- theme: ThemeLike,
1317
- ctx: RenderContextLike<EditRenderState>,
1318
- ) {
1319
- resolveBaseBackground(theme);
1320
- const fp = args?.path ?? args?.file_path ?? "";
1321
- const operations = getEditOperations(args);
1322
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1323
- const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1324
-
1325
- if (operations.length === 0) {
1326
- text.setText(fillToolBackground(hdr));
1327
- return text;
1328
- }
1329
-
1330
- const { summary } = summarizeEditOperations(operations);
1331
- const suffix =
1332
- operations.length === 1
1333
- ? summary
1334
- : `${operations.length} edits ${summary}`;
1335
- text.setText(
1336
- fillToolBackground(`${hdr} ${theme.fg("muted", suffix)}`),
1337
- );
1338
- return text;
1339
- },
1340
-
1341
- renderResult(
1342
- result: ToolResultLike,
1343
- _opt: ToolRenderResultOptions,
1344
- theme: ThemeLike,
1345
- ctx: RenderContextLike<EditRenderState>,
1346
- ) {
1347
- resolveBaseBackground(theme);
1348
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1349
- if (ctx.isError) {
1350
- text.setText(
1351
- renderToolError(getTextContent(result) || "Error", theme),
1352
- );
1353
- return text;
1354
- }
1355
- const d = result.details as RenderDetails | undefined;
1356
- if (d?._type === "editInfo") {
1357
- const loc =
1358
- d.editLine > 0
1359
- ? ` ${theme.fg("muted", `at line ${d.editLine}`)}`
1360
- : "";
1361
- text.setText(fillToolBackground(` ${d.summary}${loc}`));
1362
- return text;
1363
- }
1364
- if (d?._type === "multiEditInfo") {
1365
- const extra =
1366
- typeof d.diffLineCount === "number"
1367
- ? ` ${theme.fg("muted", `(${d.diffLineCount} diff lines)`)}`
1368
- : "";
1369
- text.setText(
1370
- fillToolBackground(` ${d.editCount} edits ${d.summary}${extra}`),
1371
- );
1372
- return text;
1373
- }
1374
- const fallback = result.content?.[0];
1375
- const fallbackText =
1376
- fallback && isTextContent(fallback) ? fallback.text : "edited";
1377
- text.setText(
1378
- fillToolBackground(
1379
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
1380
- ),
1381
- );
1382
- return text;
1383
- },
1384
- });
250
+ if (isToolEnabled("edit") && createEditTool) {
251
+ registerEditTool(pi, createEditTool, toolCtx, trackInvalidator);
1385
252
  }
1386
-
1387
- // ===================================================================
1388
- // write — new-file preview + overwrite diff
1389
- // ===================================================================
1390
-
1391
- if (createWriteTool) {
1392
- const origWrite = createWriteTool(cwd);
1393
-
1394
- pi.registerTool({
1395
- ...origWrite,
1396
- name: "write",
1397
-
1398
- async execute(
1399
- tid: string,
1400
- params: WriteParams,
1401
- sig: AbortSignal | undefined,
1402
- upd: AgentToolUpdateCallback<unknown> | undefined,
1403
- ctx: ExtensionContext,
1404
- ) {
1405
- const fp = params.path ?? params.file_path ?? "";
1406
- let old: string | null = null;
1407
- try {
1408
- if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
1409
- } catch {
1410
- old = null;
1411
- }
1412
-
1413
- const result = (await origWrite.execute(
1414
- tid,
1415
- params as unknown as Parameters<typeof origWrite.execute>[1],
1416
- sig,
1417
- upd,
1418
- ctx,
1419
- )) as ToolResultLike;
1420
- const content = params.content ?? "";
1421
-
1422
- if (old !== null && old !== content) {
1423
- const diff = parseDiff(old, content);
1424
- setResultDetails(result, {
1425
- _type: "diff",
1426
- summary: summarize(diff.added, diff.removed),
1427
- oldContent: old,
1428
- newContent: content,
1429
- language: lang(fp),
1430
- });
1431
- } else if (old === null) {
1432
- setResultDetails(result, {
1433
- _type: "new",
1434
- lines: content ? content.split("\n").length : 0,
1435
- content,
1436
- filePath: fp,
1437
- });
1438
- } else {
1439
- setResultDetails(result, { _type: "noChange" });
1440
- }
1441
- return result;
1442
- },
1443
-
1444
- renderCall(
1445
- args: WriteParams,
1446
- theme: ThemeLike,
1447
- ctx: RenderContextLike<WriteRenderState>,
1448
- ) {
1449
- resolveBaseBackground(theme);
1450
- const fp = args?.path ?? args?.file_path ?? "";
1451
- const isNew = !fp || !existsSync(fp);
1452
- const label = isNew ? "create" : "write";
1453
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1454
- const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
1455
-
1456
- if (args?.content && isNew) {
1457
- const previewKey = `create:${diffThemeCacheKey(theme)}:${fp}:${String(args.content).length}`;
1458
- if (ctx.state._previewKey !== previewKey) {
1459
- ctx.state._previewKey = previewKey;
1460
- ctx.state._previewText = hdr;
1461
- const lg = lang(fp);
1462
- hlBlock(String(args.content), lg)
1463
- .then((lines) => {
1464
- if (ctx.state._previewKey !== previewKey) return;
1465
- const maxShow = ctx.expanded ? lines.length : 16;
1466
- const preview = lines.slice(0, maxShow).join("\n");
1467
- const rem = lines.length - maxShow;
1468
- let out = `${hdr}\n\n${preview}`;
1469
- if (rem > 0)
1470
- out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
1471
- ctx.state._previewText = out;
1472
- ctx.invalidate();
1473
- })
1474
- .catch(() => {});
1475
- }
1476
- text.setText(ctx.state._previewText ?? hdr);
1477
- return text;
1478
- }
1479
-
1480
- text.setText(fillToolBackground(hdr));
1481
- return text;
1482
- },
1483
-
1484
- renderResult(
1485
- result: ToolResultLike,
1486
- _opt: ToolRenderResultOptions,
1487
- theme: ThemeLike,
1488
- ctx: RenderContextLike<WriteRenderState>,
1489
- ) {
1490
- resolveBaseBackground(theme);
1491
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1492
- if (ctx.isError) {
1493
- text.setText(
1494
- renderToolError(getTextContent(result) || "Error", theme),
1495
- );
1496
- return text;
1497
- }
1498
- const d = result.details as RenderDetails | undefined;
1499
-
1500
- if (d?._type === "diff") {
1501
- const key = `wd:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.newContent.length}:${d.language ?? ""}`;
1502
- if (ctx.state._wdk !== key) {
1503
- ctx.state._wdk = key;
1504
- ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
1505
- const dc = resolveDiffColors(theme);
1506
- const diff = parseDiff(d.oldContent, d.newContent);
1507
- renderSplit(diff, d.language, MAX_RENDER_LINES, dc)
1508
- .then((rendered) => {
1509
- if (ctx.state._wdk !== key) return;
1510
- ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1511
- ctx.invalidate();
1512
- })
1513
- .catch(() => {
1514
- if (ctx.state._wdk !== key) return;
1515
- ctx.state._wdt = ` ${d.summary}`;
1516
- ctx.invalidate();
1517
- });
1518
- }
1519
- text.setText(ctx.state._wdt ?? ` ${d.summary}`);
1520
- return text;
1521
- }
1522
- if (d?._type === "noChange") {
1523
- text.setText(
1524
- fillToolBackground(` ${theme.fg("muted", "✓ no changes")}`),
1525
- );
1526
- return text;
1527
- }
1528
- if (d?._type === "new") {
1529
- const { lines: lineCount, content: rawContent, filePath: fp } = d;
1530
- const base = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
1531
- const pk = `nf:${diffThemeCacheKey(theme)}:${fp}:${lineCount}`;
1532
- if (ctx.state._nfk !== pk) {
1533
- ctx.state._nfk = pk;
1534
- ctx.state._nft = base;
1535
- if (rawContent) {
1536
- hlBlock(rawContent, lang(fp))
1537
- .then((hlLines) => {
1538
- if (ctx.state._nfk !== pk) return;
1539
- const maxShow = ctx.expanded ? hlLines.length : 12;
1540
- const preview = hlLines.slice(0, maxShow).join("\n");
1541
- const rem = hlLines.length - maxShow;
1542
- let out = `${base}\n${preview}`;
1543
- if (rem > 0)
1544
- out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
1545
- ctx.state._nft = out;
1546
- ctx.invalidate();
1547
- })
1548
- .catch(() => {});
1549
- }
1550
- }
1551
- text.setText(ctx.state._nft ?? base);
1552
- return text;
1553
- }
1554
- const fallback = result.content?.[0];
1555
- const fallbackText =
1556
- fallback && isTextContent(fallback) ? fallback.text : "written";
1557
- text.setText(
1558
- fillToolBackground(
1559
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
1560
- ),
1561
- );
1562
- return text;
1563
- },
1564
- });
253
+ if (isToolEnabled("write") && createWriteTool) {
254
+ registerWriteTool(pi, createWriteTool, toolCtx, trackInvalidator);
1565
255
  }
1566
256
 
1567
- // ===================================================================
1568
- // FFF commands
1569
- // ===================================================================
257
+ // ── Register FFF commands ───────────────────────────────────────────
1570
258
 
1571
259
  if (fffState.module) {
1572
- pi.registerCommand("fff-health", {
1573
- description: "Show FFF file finder health and indexer status",
1574
- handler: async (_args: string, ctx: CommandContextLike) => {
1575
- if (!fffState.finder || fffState.finder.isDestroyed) {
1576
- ctx.ui?.notify?.("FFF not initialized", "warning");
1577
- return;
1578
- }
1579
-
1580
- const health = fffState.finder.healthCheck();
1581
- if (!health.ok) {
1582
- ctx.ui?.notify?.(`Health check failed: ${health.error}`, "error");
1583
- return;
1584
- }
1585
-
1586
- const h = health.value;
1587
- const lines = [
1588
- `FFF v${h.version}`,
1589
- `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
1590
- `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
1591
- `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
1592
- `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
1593
- `Partial index: ${fffState.partialIndex ? "yes (scan timed out)" : "no"}`,
1594
- ];
1595
-
1596
- const progress = fffState.finder.getScanProgress();
1597
- if (progress.ok) {
1598
- lines.push(
1599
- `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
1600
- );
1601
- }
1602
-
1603
- ctx.ui?.notify?.(lines.join("\n"), "info");
1604
- },
1605
- });
1606
-
1607
- pi.registerCommand("fff-rescan", {
1608
- description: "Trigger FFF to rescan files",
1609
- handler: async (_args: string, ctx: CommandContextLike) => {
1610
- if (!fffState.finder || fffState.finder.isDestroyed) {
1611
- ctx.ui?.notify?.("FFF not initialized", "warning");
1612
- return;
1613
- }
1614
-
1615
- const result = fffState.finder.scanFiles();
1616
- if (!result.ok) {
1617
- ctx.ui?.notify?.(`Rescan failed: ${result.error}`, "error");
1618
- return;
1619
- }
1620
-
1621
- fffState.partialIndex = false;
1622
- ctx.ui?.notify?.("FFF rescan triggered", "info");
1623
- },
1624
- });
260
+ registerFffCommands(pi, fffState);
1625
261
  }
1626
262
  }