@xynogen/pix-pretty 1.0.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 ADDED
@@ -0,0 +1,1623 @@
1
+ /**
2
+ * pi-pretty — Pretty terminal output for pi built-in tools.
3
+ *
4
+ * Entry point: wraps SDK factory tools (read/bash/ls/find/grep + multi_grep),
5
+ * delegates execute() unchanged, and attaches custom renderCall/renderResult.
6
+ *
7
+ * 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
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import type {
23
+ AgentToolUpdateCallback,
24
+ BashToolInput,
25
+ EditToolInput,
26
+ ExtensionContext,
27
+ FindToolInput,
28
+ GrepToolInput,
29
+ LsToolInput,
30
+ ReadToolInput,
31
+ ToolRenderResultOptions,
32
+ WriteToolInput,
33
+ } 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";
50
+ import {
51
+ CursorStore,
52
+ fffDestroy,
53
+ fffEnsureFinder,
54
+ fffFormatGrepText,
55
+ fffState,
56
+ getPiPrettyFffDir,
57
+ runMultiGrepRipgrepFallback,
58
+ } 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";
69
+ 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
+ PiPrettyApi,
84
+ PiPrettyDeps,
85
+ PiPrettySdk,
86
+ ReadParams,
87
+ RenderContextLike,
88
+ RenderDetails,
89
+ TextComponentCtor,
90
+ ThemeLike,
91
+ ToolFactory,
92
+ ToolResultLike,
93
+ WriteParams,
94
+ WriteRenderState,
95
+ } 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";
117
+
118
+ export default function piPrettyExtension(
119
+ pi: PiPrettyApi,
120
+ deps?: PiPrettyDeps,
121
+ ): void {
122
+ let createReadTool: ToolFactory<ReadToolInput> | undefined;
123
+ let createBashTool: ToolFactory<BashToolInput> | undefined;
124
+ let createLsTool: ToolFactory<LsToolInput> | undefined;
125
+ let createFindTool: ToolFactory<FindToolInput> | undefined;
126
+ let createGrepTool: ToolFactory<GrepToolInput> | undefined;
127
+ let createEditTool: ToolFactory<EditToolInput> | undefined;
128
+ let createWriteTool: ToolFactory<WriteToolInput> | undefined;
129
+ let TextComponent: TextComponentCtor;
130
+
131
+ let sdk: PiPrettySdk;
132
+
133
+ const _cursorStore = new CursorStore();
134
+
135
+ if (deps) {
136
+ // Test path: use injected dependencies, reset module state
137
+ sdk = deps.sdk;
138
+ createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
139
+ createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
140
+ createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
141
+ createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
142
+ createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
143
+ createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
144
+ createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
145
+ TextComponent = deps.TextComponent;
146
+ fffState.module = deps.fffModule ?? null;
147
+ fffState.finder = null;
148
+ fffState.partialIndex = false;
149
+ fffState.dbDir = null;
150
+ } else {
151
+ try {
152
+ sdk = require("@earendil-works/pi-coding-agent");
153
+ createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
154
+ createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
155
+ createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
156
+ createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
157
+ createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
158
+ createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
159
+ createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
160
+ TextComponent = require("@earendil-works/pi-tui").Text;
161
+ } catch {
162
+ return;
163
+ }
164
+ }
165
+ if (!createReadTool || !TextComponent) return;
166
+
167
+ const cwd = process.cwd();
168
+ const home = process.env.HOME ?? "";
169
+ const sp = (p: string) => shortPath(cwd, home, p);
170
+ const multiGrepRipgrepFallback =
171
+ deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
172
+
173
+ // Parse PRETTY_DISABLE_TOOLS — comma-separated tool names to skip
174
+ const disabledTools = new Set(
175
+ (process.env.PRETTY_DISABLE_TOOLS ?? "")
176
+ .split(",")
177
+ .map((s) => s.trim().toLowerCase())
178
+ .filter(Boolean),
179
+ );
180
+ function isToolEnabled(name: string): boolean {
181
+ return !disabledTools.has(name.toLowerCase());
182
+ }
183
+
184
+ // ===================================================================
185
+ // FFF initialization (optional — graceful fallback to SDK)
186
+ // ===================================================================
187
+
188
+ const getAgentDir = sdk.getAgentDir;
189
+ setPrettyTheme(
190
+ (() => {
191
+ try {
192
+ return getAgentDir?.() ?? getDefaultAgentDir();
193
+ } catch {
194
+ return getDefaultAgentDir();
195
+ }
196
+ })(),
197
+ );
198
+ clearHighlightCache();
199
+ if (!deps) {
200
+ // Only try require() in production — tests inject fffModule via deps
201
+ try {
202
+ fffState.module = require("@ff-labs/fff-node");
203
+ if (getAgentDir) {
204
+ fffState.dbDir = getPiPrettyFffDir(getAgentDir());
205
+ try {
206
+ mkdirSync(fffState.dbDir, { recursive: true });
207
+ } catch {}
208
+ }
209
+ } catch {
210
+ /* FFF not installed — SDK tools will be used */
211
+ }
212
+ } else if (fffState.module && getAgentDir) {
213
+ fffState.dbDir = getPiPrettyFffDir(getAgentDir());
214
+ try {
215
+ mkdirSync(fffState.dbDir, { recursive: true });
216
+ } catch {}
217
+ }
218
+
219
+ pi.on("session_start", async (_event, ctx) => {
220
+ // Try dynamic import if sync require failed (ESM-only package)
221
+ if (!fffState.module) {
222
+ try {
223
+ const imported = await import("@ff-labs/fff-node");
224
+ fffState.module = { FileFinder: imported.FileFinder };
225
+ } catch {}
226
+ }
227
+ if (!fffState.module) return;
228
+
229
+ if (!fffState.dbDir) {
230
+ const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
231
+ fffState.dbDir = getPiPrettyFffDir(agentDir);
232
+ try {
233
+ mkdirSync(fffState.dbDir, { recursive: true });
234
+ } catch {}
235
+ }
236
+
237
+ try {
238
+ await fffEnsureFinder(ctx.cwd);
239
+ if (fffState.partialIndex) {
240
+ ctx.ui?.notify?.(
241
+ "FFF: scan timed out — using partial index. Run /fff-rescan when ready.",
242
+ "warning",
243
+ );
244
+ } 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
+ ctx.ui?.notify?.("FFF indexed", "info");
249
+ }
250
+ } catch (error: unknown) {
251
+ ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
252
+ }
253
+ });
254
+
255
+ pi.on("session_shutdown", async () => {
256
+ fffDestroy();
257
+ });
258
+
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;
287
+
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
+ }
298
+
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
+ }
311
+
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
+ });
402
+ }
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
+ });
527
+ }
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
+ });
617
+ }
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
+ });
747
+ }
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 }] : [];
1242
+ }
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
+ };
1254
+ }
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
+ const result = (await origEdit.execute(
1273
+ tid,
1274
+ params,
1275
+ sig,
1276
+ upd,
1277
+ ctx,
1278
+ )) as ToolResultLike;
1279
+
1280
+ if (operations.length === 0) return result;
1281
+
1282
+ const { diffs, summary } = summarizeEditOperations(operations);
1283
+ if (operations.length === 1) {
1284
+ let editLine = 0;
1285
+ try {
1286
+ if (fp && existsSync(fp)) {
1287
+ const f = readFileSync(fp, "utf-8");
1288
+ const idx = f.indexOf(operations[0].newText);
1289
+ if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
1290
+ }
1291
+ } catch {
1292
+ editLine = 0;
1293
+ }
1294
+ setResultDetails(result, {
1295
+ _type: "editInfo",
1296
+ summary,
1297
+ editLine,
1298
+ });
1299
+ return result;
1300
+ }
1301
+
1302
+ setResultDetails(result, {
1303
+ _type: "multiEditInfo",
1304
+ summary,
1305
+ editCount: operations.length,
1306
+ diffLineCount: diffs.reduce((sum, d) => sum + d.lines.length, 0),
1307
+ });
1308
+ return result;
1309
+ },
1310
+
1311
+ renderCall(
1312
+ args: EditParams,
1313
+ theme: ThemeLike,
1314
+ ctx: RenderContextLike<EditRenderState>,
1315
+ ) {
1316
+ resolveBaseBackground(theme);
1317
+ const fp = args?.path ?? args?.file_path ?? "";
1318
+ const operations = getEditOperations(args);
1319
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1320
+ const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1321
+
1322
+ if (operations.length === 0) {
1323
+ text.setText(fillToolBackground(hdr));
1324
+ return text;
1325
+ }
1326
+
1327
+ const { summary } = summarizeEditOperations(operations);
1328
+ const suffix =
1329
+ operations.length === 1
1330
+ ? summary
1331
+ : `${operations.length} edits ${summary}`;
1332
+ text.setText(
1333
+ fillToolBackground(`${hdr} ${theme.fg("muted", suffix)}`),
1334
+ );
1335
+ return text;
1336
+ },
1337
+
1338
+ renderResult(
1339
+ result: ToolResultLike,
1340
+ _opt: ToolRenderResultOptions,
1341
+ theme: ThemeLike,
1342
+ ctx: RenderContextLike<EditRenderState>,
1343
+ ) {
1344
+ resolveBaseBackground(theme);
1345
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1346
+ if (ctx.isError) {
1347
+ text.setText(
1348
+ renderToolError(getTextContent(result) || "Error", theme),
1349
+ );
1350
+ return text;
1351
+ }
1352
+ const d = result.details as RenderDetails | undefined;
1353
+ if (d?._type === "editInfo") {
1354
+ const loc =
1355
+ d.editLine > 0
1356
+ ? ` ${theme.fg("muted", `at line ${d.editLine}`)}`
1357
+ : "";
1358
+ text.setText(fillToolBackground(` ${d.summary}${loc}`));
1359
+ return text;
1360
+ }
1361
+ if (d?._type === "multiEditInfo") {
1362
+ const extra =
1363
+ typeof d.diffLineCount === "number"
1364
+ ? ` ${theme.fg("muted", `(${d.diffLineCount} diff lines)`)}`
1365
+ : "";
1366
+ text.setText(
1367
+ fillToolBackground(` ${d.editCount} edits ${d.summary}${extra}`),
1368
+ );
1369
+ return text;
1370
+ }
1371
+ const fallback = result.content?.[0];
1372
+ const fallbackText =
1373
+ fallback && isTextContent(fallback) ? fallback.text : "edited";
1374
+ text.setText(
1375
+ fillToolBackground(
1376
+ ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
1377
+ ),
1378
+ );
1379
+ return text;
1380
+ },
1381
+ });
1382
+ }
1383
+
1384
+ // ===================================================================
1385
+ // write — new-file preview + overwrite diff
1386
+ // ===================================================================
1387
+
1388
+ if (createWriteTool) {
1389
+ const origWrite = createWriteTool(cwd);
1390
+
1391
+ pi.registerTool({
1392
+ ...origWrite,
1393
+ name: "write",
1394
+
1395
+ async execute(
1396
+ tid: string,
1397
+ params: WriteParams,
1398
+ sig: AbortSignal | undefined,
1399
+ upd: AgentToolUpdateCallback<unknown> | undefined,
1400
+ ctx: ExtensionContext,
1401
+ ) {
1402
+ const fp = params.path ?? params.file_path ?? "";
1403
+ let old: string | null = null;
1404
+ try {
1405
+ if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
1406
+ } catch {
1407
+ old = null;
1408
+ }
1409
+
1410
+ const result = (await origWrite.execute(
1411
+ tid,
1412
+ params,
1413
+ sig,
1414
+ upd,
1415
+ ctx,
1416
+ )) as ToolResultLike;
1417
+ const content = params.content ?? "";
1418
+
1419
+ if (old !== null && old !== content) {
1420
+ const diff = parseDiff(old, content);
1421
+ setResultDetails(result, {
1422
+ _type: "diff",
1423
+ summary: summarize(diff.added, diff.removed),
1424
+ oldContent: old,
1425
+ newContent: content,
1426
+ language: lang(fp),
1427
+ });
1428
+ } else if (old === null) {
1429
+ setResultDetails(result, {
1430
+ _type: "new",
1431
+ lines: content ? content.split("\n").length : 0,
1432
+ content,
1433
+ filePath: fp,
1434
+ });
1435
+ } else {
1436
+ setResultDetails(result, { _type: "noChange" });
1437
+ }
1438
+ return result;
1439
+ },
1440
+
1441
+ renderCall(
1442
+ args: WriteParams,
1443
+ theme: ThemeLike,
1444
+ ctx: RenderContextLike<WriteRenderState>,
1445
+ ) {
1446
+ resolveBaseBackground(theme);
1447
+ const fp = args?.path ?? args?.file_path ?? "";
1448
+ const isNew = !fp || !existsSync(fp);
1449
+ const label = isNew ? "create" : "write";
1450
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1451
+ const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
1452
+
1453
+ if (args?.content && isNew) {
1454
+ const previewKey = `create:${diffThemeCacheKey(theme)}:${fp}:${String(args.content).length}`;
1455
+ if (ctx.state._previewKey !== previewKey) {
1456
+ ctx.state._previewKey = previewKey;
1457
+ ctx.state._previewText = hdr;
1458
+ const lg = lang(fp);
1459
+ hlBlock(String(args.content), lg)
1460
+ .then((lines) => {
1461
+ if (ctx.state._previewKey !== previewKey) return;
1462
+ const maxShow = ctx.expanded ? lines.length : 16;
1463
+ const preview = lines.slice(0, maxShow).join("\n");
1464
+ const rem = lines.length - maxShow;
1465
+ let out = `${hdr}\n\n${preview}`;
1466
+ if (rem > 0)
1467
+ out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
1468
+ ctx.state._previewText = out;
1469
+ ctx.invalidate();
1470
+ })
1471
+ .catch(() => {});
1472
+ }
1473
+ text.setText(ctx.state._previewText ?? hdr);
1474
+ return text;
1475
+ }
1476
+
1477
+ text.setText(fillToolBackground(hdr));
1478
+ return text;
1479
+ },
1480
+
1481
+ renderResult(
1482
+ result: ToolResultLike,
1483
+ _opt: ToolRenderResultOptions,
1484
+ theme: ThemeLike,
1485
+ ctx: RenderContextLike<WriteRenderState>,
1486
+ ) {
1487
+ resolveBaseBackground(theme);
1488
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1489
+ if (ctx.isError) {
1490
+ text.setText(
1491
+ renderToolError(getTextContent(result) || "Error", theme),
1492
+ );
1493
+ return text;
1494
+ }
1495
+ const d = result.details as RenderDetails | undefined;
1496
+
1497
+ if (d?._type === "diff") {
1498
+ const key = `wd:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.newContent.length}:${d.language ?? ""}`;
1499
+ if (ctx.state._wdk !== key) {
1500
+ ctx.state._wdk = key;
1501
+ ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
1502
+ const dc = resolveDiffColors(theme);
1503
+ const diff = parseDiff(d.oldContent, d.newContent);
1504
+ renderSplit(diff, d.language, MAX_RENDER_LINES, dc)
1505
+ .then((rendered) => {
1506
+ if (ctx.state._wdk !== key) return;
1507
+ ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1508
+ ctx.invalidate();
1509
+ })
1510
+ .catch(() => {
1511
+ if (ctx.state._wdk !== key) return;
1512
+ ctx.state._wdt = ` ${d.summary}`;
1513
+ ctx.invalidate();
1514
+ });
1515
+ }
1516
+ text.setText(ctx.state._wdt ?? ` ${d.summary}`);
1517
+ return text;
1518
+ }
1519
+ if (d?._type === "noChange") {
1520
+ text.setText(
1521
+ fillToolBackground(` ${theme.fg("muted", "✓ no changes")}`),
1522
+ );
1523
+ return text;
1524
+ }
1525
+ if (d?._type === "new") {
1526
+ const { lines: lineCount, content: rawContent, filePath: fp } = d;
1527
+ const base = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
1528
+ const pk = `nf:${diffThemeCacheKey(theme)}:${fp}:${lineCount}`;
1529
+ if (ctx.state._nfk !== pk) {
1530
+ ctx.state._nfk = pk;
1531
+ ctx.state._nft = base;
1532
+ if (rawContent) {
1533
+ hlBlock(rawContent, lang(fp))
1534
+ .then((hlLines) => {
1535
+ if (ctx.state._nfk !== pk) return;
1536
+ const maxShow = ctx.expanded ? hlLines.length : 12;
1537
+ const preview = hlLines.slice(0, maxShow).join("\n");
1538
+ const rem = hlLines.length - maxShow;
1539
+ let out = `${base}\n${preview}`;
1540
+ if (rem > 0)
1541
+ out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
1542
+ ctx.state._nft = out;
1543
+ ctx.invalidate();
1544
+ })
1545
+ .catch(() => {});
1546
+ }
1547
+ }
1548
+ text.setText(ctx.state._nft ?? base);
1549
+ return text;
1550
+ }
1551
+ const fallback = result.content?.[0];
1552
+ const fallbackText =
1553
+ fallback && isTextContent(fallback) ? fallback.text : "written";
1554
+ text.setText(
1555
+ fillToolBackground(
1556
+ ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
1557
+ ),
1558
+ );
1559
+ return text;
1560
+ },
1561
+ });
1562
+ }
1563
+
1564
+ // ===================================================================
1565
+ // FFF commands
1566
+ // ===================================================================
1567
+
1568
+ if (fffState.module) {
1569
+ pi.registerCommand("fff-health", {
1570
+ description: "Show FFF file finder health and indexer status",
1571
+ handler: async (_args: string, ctx: CommandContextLike) => {
1572
+ if (!fffState.finder || fffState.finder.isDestroyed) {
1573
+ ctx.ui?.notify?.("FFF not initialized", "warning");
1574
+ return;
1575
+ }
1576
+
1577
+ const health = fffState.finder.healthCheck();
1578
+ if (!health.ok) {
1579
+ ctx.ui?.notify?.(`Health check failed: ${health.error}`, "error");
1580
+ return;
1581
+ }
1582
+
1583
+ const h = health.value;
1584
+ const lines = [
1585
+ `FFF v${h.version}`,
1586
+ `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
1587
+ `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
1588
+ `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
1589
+ `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
1590
+ `Partial index: ${fffState.partialIndex ? "yes (scan timed out)" : "no"}`,
1591
+ ];
1592
+
1593
+ const progress = fffState.finder.getScanProgress();
1594
+ if (progress.ok) {
1595
+ lines.push(
1596
+ `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
1597
+ );
1598
+ }
1599
+
1600
+ ctx.ui?.notify?.(lines.join("\n"), "info");
1601
+ },
1602
+ });
1603
+
1604
+ pi.registerCommand("fff-rescan", {
1605
+ description: "Trigger FFF to rescan files",
1606
+ handler: async (_args: string, ctx: CommandContextLike) => {
1607
+ if (!fffState.finder || fffState.finder.isDestroyed) {
1608
+ ctx.ui?.notify?.("FFF not initialized", "warning");
1609
+ return;
1610
+ }
1611
+
1612
+ const result = fffState.finder.scanFiles();
1613
+ if (!result.ok) {
1614
+ ctx.ui?.notify?.(`Rescan failed: ${result.error}`, "error");
1615
+ return;
1616
+ }
1617
+
1618
+ fffState.partialIndex = false;
1619
+ ctx.ui?.notify?.("FFF rescan triggered", "info");
1620
+ },
1621
+ });
1622
+ }
1623
+ }