@heyhuynhgiabuu/pi-pretty 0.5.3 → 0.6.1

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,1232 +1,78 @@
1
1
  /**
2
2
  * pi-pretty — Pretty terminal output for pi built-in tools.
3
3
  *
4
- * @module pi-pretty
5
- * @see https://github.com/buddingnewinsights/pi-pretty
6
- *
7
- * Enhances:
8
- * • read — syntax-highlighted file content with line numbers
9
- * • bash — colored exit status, stderr highlighting
10
- * • ls — tree-view directory listing with file-type icons
11
- * • find — grouped results with file-type icons
12
- * • grep — syntax-highlighted match context with line numbers
13
- *
14
- * Architecture:
15
- * 1. Wrap SDK factory tools (createReadTool, createBashTool, etc.)
16
- * 2. Delegate to original execute() — no behavior changes
17
- * 3. Attach metadata in result.details for custom renderCall/renderResult
18
- * 4. Async Shiki highlighting with ctx.invalidate() for non-blocking renders
19
- *
20
- * Performance:
21
- * • Shared Shiki singleton (managed by @shikijs/cli)
22
- * • LRU cache for highlighted blocks
23
- * • Large-file fallback (skip highlighting, still show line numbers)
24
- */
25
-
26
- import * as childProcess from "node:child_process";
27
- import { mkdirSync, readFileSync } from "node:fs";
28
- import { basename, dirname, extname, join, relative } from "node:path";
29
-
30
- import type { FileFinder, FileItem, GrepResult, SearchResult } from "@ff-labs/fff-node";
31
- import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
32
- import type {
33
- AgentToolResult,
34
- AgentToolUpdateCallback,
35
- BashToolInput,
36
- ExtensionCommandContext,
37
- ExtensionContext,
38
- FindToolInput,
39
- GrepToolInput,
40
- LsToolInput,
41
- ReadToolInput,
42
- ToolRenderResultOptions,
43
- } from "@earendil-works/pi-coding-agent";
44
- import { truncateToWidth } from "@earendil-works/pi-tui";
45
- import { codeToANSI } from "@shikijs/cli";
46
- import type { BundledLanguage, BundledTheme } from "shiki";
47
-
48
- import { CursorStore, fffFormatGrepText } from "./fff-helpers.js";
49
- import { type MultiGrepRipgrepFallback, runMultiGrepRipgrepFallback } from "./multi-grep-fallback.js";
50
-
51
- // ---------------------------------------------------------------------------
52
- // Config
53
- // ---------------------------------------------------------------------------
54
-
55
- const DEFAULT_THEME: BundledTheme = "github-dark";
56
-
57
- function getDefaultAgentDir(): string | undefined {
58
- const home = process.env.HOME ?? "";
59
- return home ? join(home, ".pi/agent") : undefined;
60
- }
61
-
62
- function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
63
- const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
64
- if (!resolvedAgentDir) return undefined;
65
-
66
- try {
67
- const settings = JSON.parse(readFileSync(join(resolvedAgentDir, "settings.json"), "utf8")) as {
68
- theme?: unknown;
69
- };
70
- return typeof settings.theme === "string" ? (settings.theme as BundledTheme) : undefined;
71
- } catch {
72
- return undefined;
73
- }
74
- }
75
-
76
- function resolvePrettyTheme(agentDir?: string): BundledTheme {
77
- return (process.env.PRETTY_THEME as BundledTheme | undefined) ?? readThemeFromSettings(agentDir) ?? DEFAULT_THEME;
78
- }
79
-
80
- let THEME: BundledTheme = resolvePrettyTheme();
81
-
82
- /** Stored agent directory for config lookups during render (set during init). */
83
- let _agentDir: string | undefined;
84
-
85
- function setPrettyTheme(agentDir?: string): void {
86
- _agentDir = agentDir;
87
- const resolvedTheme = resolvePrettyTheme(agentDir);
88
- if (resolvedTheme === THEME) return;
89
- THEME = resolvedTheme;
90
- _cache.clear();
91
- codeToANSI("", "typescript", THEME).catch(() => {});
92
- }
93
-
94
- function envInt(name: string, fallback: number): number {
95
- const v = Number.parseInt(process.env[name] ?? "", 10);
96
- return Number.isFinite(v) && v > 0 ? v : fallback;
97
- }
98
-
99
- const MAX_HL_CHARS = envInt("PRETTY_MAX_HL_CHARS", 80_000);
100
- const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
101
- const CACHE_LIMIT = envInt("PRETTY_CACHE_LIMIT", 128);
102
-
103
- // ---------------------------------------------------------------------------
104
- // pi-pretty.json config
105
- // ---------------------------------------------------------------------------
106
-
107
- /** Schema for pi-pretty.json — user config file placed adjacent to settings.json. */
108
- export interface PrettyConfig {
109
- /** Background color overrides for tool output boxes. */
110
- background?: {
111
- /** Background color for normal tool output (hex, e.g. "#1e1e2e"). */
112
- tool?: string;
113
- /** Background color for error tool output (hex, e.g. "#2a1e1e"). Defaults to tool bg. */
114
- error?: string;
115
- };
116
- }
117
-
118
- /** Convert a hex color string (e.g. "#1e1e2e") to an ANSI 24-bit background escape. */
119
- function hexToAnsiBg(hex: string): string | null {
120
- const m = hex.match(/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
121
- if (!m) return null;
122
- const r = Number.parseInt(m[1], 16);
123
- const g = Number.parseInt(m[2], 16);
124
- const b = Number.parseInt(m[3], 16);
125
- return `\x1b[48;2;${r};${g};${b}m`;
126
- }
127
-
128
- /** Read pi-pretty.json from the agent directory. Returns empty object on any error. */
129
- function readPrettyConfig(agentDir?: string): PrettyConfig {
130
- const resolvedDir = agentDir ?? getDefaultAgentDir();
131
- if (!resolvedDir) return {};
132
-
133
- try {
134
- const raw = readFileSync(join(resolvedDir, "pi-pretty.json"), "utf8");
135
- const parsed = JSON.parse(raw) as PrettyConfig;
136
-
137
- // Validate: background fields must be valid hex strings if present
138
- if (parsed.background) {
139
- if (parsed.background.tool && !hexToAnsiBg(parsed.background.tool)) {
140
- parsed.background.tool = undefined;
141
- }
142
- if (parsed.background.error && !hexToAnsiBg(parsed.background.error)) {
143
- parsed.background.error = undefined;
144
- }
145
- // Drop empty background block
146
- if (!parsed.background.tool && !parsed.background.error) {
147
- parsed.background = undefined;
148
- }
149
- }
150
-
151
- return parsed;
152
- } catch {
153
- return {};
154
- }
155
- }
156
-
157
- /** Apply backgrounds from pi-pretty.json config. Returns true if config was applied. */
158
- function applyPrettyConfigBg(agentDir?: string): boolean {
159
- const config = readPrettyConfig(agentDir);
160
- if (!config.background?.tool) return false;
161
-
162
- const toolBg = hexToAnsiBg(config.background.tool);
163
- if (!toolBg) return false;
164
-
165
- BG_BASE = toolBg;
166
- BG_ERROR = config.background.error ? (hexToAnsiBg(config.background.error) ?? toolBg) : toolBg;
167
- RST = "\x1b[0m";
168
- return true;
169
- }
170
-
171
- // ---------------------------------------------------------------------------
172
- // ANSI
173
- // ---------------------------------------------------------------------------
174
-
175
- let RST = "\x1b[0m";
176
- const BOLD = "\x1b[1m";
177
-
178
- const FG_LNUM = "\x1b[38;2;100;100;100m";
179
- const FG_DIM = "\x1b[38;2;80;80;80m";
180
- const FG_RULE = "\x1b[38;2;50;50;50m";
181
- const FG_GREEN = "\x1b[38;2;100;180;120m";
182
- const FG_RED = "\x1b[38;2;200;100;100m";
183
- const FG_YELLOW = "\x1b[38;2;220;180;80m";
184
- const FG_BLUE = "\x1b[38;2;100;140;220m";
185
- const FG_MUTED = "\x1b[38;2;139;148;158m";
186
-
187
- const BG_DEFAULT = "\x1b[49m";
188
- let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg or pi-pretty.json
189
- let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg or pi-pretty.json
190
-
191
- type BgTheme = { getBgAnsi?: (key: string) => string };
192
- type FgTheme = { fg: (key: string, text: string) => string };
193
-
194
- /** Parse an ANSI 24-bit color escape into { r, g, b }. Handles both fg (38;2) and bg (48;2). */
195
- function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
196
- const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
197
- return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
198
- }
199
-
200
- function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
201
- try {
202
- const bgAnsi = theme.getBgAnsi?.(key);
203
- return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
204
- } catch {
205
- return null;
206
- }
207
- }
208
-
209
- /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
210
- * Recompute on each render so runtime theme changes are respected.
211
- * Priority: pi-pretty.json config > theme > terminal default. */
212
- function resolveBaseBackground(theme: BgTheme | null | undefined): void {
213
- // Config takes highest priority: PRETTY_CONFIG_DIR env > agent dir
214
- const configDir = process.env.PRETTY_CONFIG_DIR ?? _agentDir ?? getDefaultAgentDir();
215
-
216
- if (applyPrettyConfigBg(configDir)) return;
217
-
218
- // Fall back to theme
219
- if (!theme?.getBgAnsi) return;
220
-
221
- BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
222
- BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
223
- RST = "\x1b[0m";
224
- }
225
-
226
- function compactErrorLines(error: string): string[] {
227
- const compactedLines: string[] = [];
228
- let previousBlank = false;
229
- for (const line of normalizeLineEndings(error).trim().split("\n")) {
230
- const isBlank = line.trim() === "";
231
- if (isBlank && previousBlank) continue;
232
- compactedLines.push(line);
233
- previousBlank = isBlank;
234
- }
235
- return compactedLines;
236
- }
237
-
238
- function stripBashExitStatusLine(text: string): string {
239
- return normalizeLineEndings(text)
240
- .split("\n")
241
- .filter((line) => !/^Command exited with code \d+$/i.test(line.trim()))
242
- .join("\n");
243
- }
244
-
245
- function renderToolError(error: string, theme: FgTheme): string {
246
- const body = compactErrorLines(error)
247
- .map((line) => ` ${line ? theme.fg("error", line) : ""}`)
248
- .join("\n");
249
- return fillToolBackground(body, BG_ERROR);
250
- }
251
-
252
- const ESC_RE = "\u001b";
253
- const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
254
-
255
- // ---------------------------------------------------------------------------
256
- // Low-contrast fix (same as pi-diff)
257
- // ---------------------------------------------------------------------------
258
-
259
- function isLowContrastShikiFg(params: string): boolean {
260
- if (params === "30" || params === "90") return true;
261
- if (params === "38;5;0" || params === "38;5;8") return true;
262
- if (!params.startsWith("38;2;")) return false;
263
- const parts = params.split(";").map(Number);
264
- if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
265
- const [, , r, g, b] = parts;
266
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
267
- return luminance < 72;
268
- }
269
-
270
- function normalizeShikiContrast(ansi: string): string {
271
- return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => (isLowContrastShikiFg(params) ? FG_MUTED : seq));
272
- }
273
-
274
- // ---------------------------------------------------------------------------
275
- // Utilities
276
- // ---------------------------------------------------------------------------
277
-
278
- function normalizeLineEndings(text: string): string {
279
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
280
- }
281
-
282
- const RESET_WITHOUT_BG = "\x1b[22;23;24;25;27;28;29;39m";
283
-
284
- function preserveBoxBackground(ansi: string): string {
285
- return ansi.replace(ANSI_CAPTURE_RE, (_seq, params: string) => {
286
- if (!params || params === "0") return RESET_WITHOUT_BG;
287
-
288
- const parts = params.split(";").filter(Boolean);
289
- const kept: string[] = [];
290
- for (let i = 0; i < parts.length; i++) {
291
- const code = Number(parts[i]);
292
- if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) continue;
293
- if (code === 48) {
294
- if (parts[i + 1] === "5") i += 2;
295
- else if (parts[i + 1] === "2") i += 4;
296
- continue;
297
- }
298
- kept.push(parts[i]);
299
- }
300
- return kept.length ? `\x1b[${kept.join(";")}m` : "";
301
- });
302
- }
303
-
304
- function fillToolBackground(text: string, _bg = BG_BASE, width?: number): string {
305
- return text
306
- .split("\n")
307
- .map((line) => {
308
- // The TUI Box owns full-width success/error backgrounds. Remove
309
- // background-affecting ANSI from text so inline resets don't punch
310
- // holes in the Box background after status labels like "✗ exit 1".
311
- const fitted = width ? truncateToWidth(line, width, "") : line;
312
- return preserveBoxBackground(fitted);
313
- })
314
- .join("\n");
315
- }
316
-
317
- function termW(): number {
318
- // When process.stdout.columns is available (real terminal or compositor override),
319
- // use it directly — the TUI/compositor already provides the exact content width.
320
- // The -4 safety margin only applies to fallback values (stderr.columns, env.COLUMNS, default).
321
- if (process.stdout.columns) {
322
- return Math.max(1, Math.min(process.stdout.columns, 210));
323
- }
324
- const raw =
325
- (process.stderr as NodeJS.WriteStream & { columns?: number }).columns ||
326
- Number.parseInt(process.env.COLUMNS ?? "", 10) ||
327
- 200;
328
- return Math.max(1, Math.min(raw - 4, 210));
329
- }
330
-
331
- function shortPath(cwd: string, home: string, p: string): string {
332
- if (!p) return "";
333
- const r = relative(cwd, p);
334
- if (!r.startsWith("..") && !r.startsWith("/")) return r;
335
- return p.replace(home, "~");
336
- }
337
-
338
- function rule(w: number): string {
339
- return `${FG_RULE}${"─".repeat(w)}${RST}`;
340
- }
341
-
342
- function lnum(n: number, w: number): string {
343
- const v = String(n);
344
- return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
345
- }
346
-
347
- // ---------------------------------------------------------------------------
348
- // Language detection
349
- // ---------------------------------------------------------------------------
350
-
351
- const EXT_LANG: Record<string, BundledLanguage> = {
352
- ts: "typescript",
353
- tsx: "tsx",
354
- js: "javascript",
355
- jsx: "jsx",
356
- mjs: "javascript",
357
- cjs: "javascript",
358
- py: "python",
359
- rb: "ruby",
360
- rs: "rust",
361
- go: "go",
362
- java: "java",
363
- c: "c",
364
- cpp: "cpp",
365
- h: "c",
366
- hpp: "cpp",
367
- cs: "csharp",
368
- swift: "swift",
369
- kt: "kotlin",
370
- html: "html",
371
- css: "css",
372
- scss: "scss",
373
- less: "css",
374
- json: "json",
375
- jsonc: "jsonc",
376
- yaml: "yaml",
377
- yml: "yaml",
378
- toml: "toml",
379
- md: "markdown",
380
- mdx: "mdx",
381
- sql: "sql",
382
- sh: "bash",
383
- bash: "bash",
384
- zsh: "bash",
385
- lua: "lua",
386
- php: "php",
387
- dart: "dart",
388
- xml: "xml",
389
- graphql: "graphql",
390
- svelte: "svelte",
391
- vue: "vue",
392
- dockerfile: "dockerfile",
393
- makefile: "make",
394
- zig: "zig",
395
- nim: "nim",
396
- elixir: "elixir",
397
- ex: "elixir",
398
- erb: "erb",
399
- hbs: "handlebars",
400
- };
401
-
402
- function lang(fp: string): BundledLanguage | undefined {
403
- const base = basename(fp).toLowerCase();
404
- if (base === "dockerfile") return "dockerfile";
405
- if (base === "makefile" || base === "gnumakefile") return "make";
406
- if (base === ".envrc" || base === ".env") return "bash";
407
- return EXT_LANG[extname(fp).slice(1).toLowerCase()];
408
- }
409
-
410
- // ---------------------------------------------------------------------------
411
- // Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols)
412
- // Handles tmux passthrough for image protocols.
413
- // ---------------------------------------------------------------------------
414
-
415
- type ImageProtocol = "iterm2" | "kitty" | "none";
416
-
417
- let _tmuxClientTermCache: string | null | undefined;
418
- let _tmuxAllowPassthroughCache: boolean | null | undefined;
419
- let _tmuxClientTermOverrideForTests: string | null | undefined;
420
- let _tmuxAllowPassthroughOverrideForTests: boolean | null | undefined;
421
-
422
- function isTmuxSession(): boolean {
423
- return !!process.env.TMUX || /^(tmux|screen)/.test(process.env.TERM ?? "");
424
- }
425
-
426
- function normalizeTerminalName(term: string): string {
427
- const t = term.toLowerCase();
428
- if (t.includes("kitty")) return "kitty";
429
- if (t.includes("ghostty")) return "ghostty";
430
- if (t.includes("wezterm")) return "WezTerm";
431
- if (t.includes("iterm")) return "iTerm.app";
432
- if (t.includes("mintty")) return "mintty";
433
- return term;
434
- }
435
-
436
- function readTmuxClientTerm(): string | null {
437
- if (_tmuxClientTermOverrideForTests !== undefined) {
438
- return _tmuxClientTermOverrideForTests ? normalizeTerminalName(_tmuxClientTermOverrideForTests) : null;
439
- }
440
- if (!isTmuxSession()) return null;
441
- if (_tmuxClientTermCache !== undefined) return _tmuxClientTermCache;
442
- try {
443
- const term = childProcess
444
- .execFileSync("tmux", ["display-message", "-p", "#{client_termname}"], {
445
- encoding: "utf8",
446
- stdio: ["ignore", "pipe", "ignore"],
447
- timeout: 200,
448
- })
449
- .trim();
450
- _tmuxClientTermCache = term ? normalizeTerminalName(term) : null;
451
- } catch {
452
- _tmuxClientTermCache = null;
453
- }
454
- return _tmuxClientTermCache;
455
- }
456
-
457
- /**
458
- * Detect the outer terminal when running inside tmux.
459
- * tmux sets TERM_PROGRAM=tmux, but the real terminal is often in
460
- * the environment of the tmux server or can be inferred.
461
- */
462
- function getOuterTerminal(): string {
463
- // Environment hints that often survive inside tmux
464
- if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
465
- if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
466
- if (process.env.KITTY_WINDOW_ID || process.env.KITTY_PID) return "kitty";
467
- if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_CONFIG_DIR || process.env.WEZTERM_CONFIG_FILE) {
468
- return "WezTerm";
469
- }
470
-
471
- const termProgram = process.env.TERM_PROGRAM ?? "";
472
- if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
473
- return normalizeTerminalName(termProgram);
474
- }
475
-
476
- const tmuxClientTerm = readTmuxClientTerm();
477
- if (tmuxClientTerm) return tmuxClientTerm;
478
-
479
- const term = process.env.TERM ?? "";
480
- if (term) return normalizeTerminalName(term);
481
- if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") return "unknown-modern";
482
- return termProgram;
483
- }
484
-
485
- function detectImageProtocol(): ImageProtocol {
486
- const forced = (process.env.PRETTY_IMAGE_PROTOCOL ?? "").toLowerCase();
487
- if (forced === "kitty" || forced === "iterm2" || forced === "none") {
488
- return forced;
489
- }
490
-
491
- const term = getOuterTerminal();
492
- // Ghostty and Kitty use the Kitty graphics protocol
493
- if (term === "ghostty" || term === "kitty") return "kitty";
494
- // iTerm2, WezTerm, Mintty support the iTerm2 protocol
495
- if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return "iterm2";
496
- if (process.env.LC_TERMINAL === "iTerm2") return "iterm2";
497
- return "none";
498
- }
499
-
500
- function tmuxAllowsPassthrough(): boolean | null {
501
- if (_tmuxAllowPassthroughOverrideForTests !== undefined) return _tmuxAllowPassthroughOverrideForTests;
502
- if (!isTmuxSession()) return null;
503
- if (_tmuxAllowPassthroughCache !== undefined) return _tmuxAllowPassthroughCache;
504
- try {
505
- const value = childProcess
506
- .execFileSync("tmux", ["show-options", "-gv", "allow-passthrough"], {
507
- encoding: "utf8",
508
- stdio: ["ignore", "pipe", "ignore"],
509
- timeout: 200,
510
- })
511
- .trim()
512
- .toLowerCase();
513
- _tmuxAllowPassthroughCache = value === "on" || value === "all";
514
- } catch {
515
- _tmuxAllowPassthroughCache = null;
516
- }
517
- return _tmuxAllowPassthroughCache;
518
- }
519
-
520
- function getTmuxPassthroughWarning(protocol: ImageProtocol): string | null {
521
- if (!isTmuxSession() || protocol === "none") return null;
522
- if (tmuxAllowsPassthrough() === false) {
523
- return "tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on";
524
- }
525
- return null;
526
- }
527
-
528
- /**
529
- * Wrap escape sequence for tmux passthrough.
530
- * tmux requires: ESC Ptmux; <escaped-sequence> ESC \
531
- * Inner ESC chars must be doubled.
532
- */
533
- function tmuxWrap(seq: string): string {
534
- if (!isTmuxSession()) return seq;
535
- // Double all ESC chars inside the sequence
536
- const escaped = seq.split("\x1b").join("\x1b\x1b");
537
- return `\x1bPtmux;${escaped}\x1b\\`;
538
- }
539
-
540
- export const __imageInternals = {
541
- isTmuxSession,
542
- getOuterTerminal,
543
- detectImageProtocol,
544
- tmuxWrap,
545
- tmuxAllowsPassthrough,
546
- getTmuxPassthroughWarning,
547
- setTmuxClientTermOverrideForTests: (value: string | null | undefined) => {
548
- _tmuxClientTermOverrideForTests = value;
549
- },
550
- setTmuxAllowPassthroughOverrideForTests: (value: boolean | null | undefined) => {
551
- _tmuxAllowPassthroughOverrideForTests = value;
552
- },
553
- resetCachesForTests: () => {
554
- _tmuxClientTermCache = undefined;
555
- _tmuxAllowPassthroughCache = undefined;
556
- _tmuxClientTermOverrideForTests = undefined;
557
- _tmuxAllowPassthroughOverrideForTests = undefined;
558
- },
559
- };
560
-
561
- /**
562
- * Get human-readable file size
4
+ * Enhances read, bash, ls, find, grep, multi_grep with:
5
+ * Syntax-highlighted file content (Shiki)
6
+ * • Colored bash exit status + output
7
+ * Tree-view directory listings with file-type icons
8
+ * • FFF-accelerated find/grep with SDK fallback
9
+ * • Custom ANSI rendering for all tools
563
10
  */
564
- function humanSize(bytes: number): string {
565
- if (bytes < 1024) return `${bytes}B`;
566
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
567
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
568
- }
569
-
570
- // ---------------------------------------------------------------------------
571
- // File-type icons — Nerd Font glyphs (Seti-UI + Devicons, stable in NF v3+)
572
- //
573
- // Requires a Nerd Font installed (e.g., JetBrainsMono Nerd Font, FiraCode NF).
574
- // Fallback: set PRETTY_ICONS=none to disable icons.
575
- // ---------------------------------------------------------------------------
576
-
577
- const ICONS_MODE = (process.env.PRETTY_ICONS ?? "nerd").toLowerCase();
578
- const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
579
-
580
- // Nerd Font codepoints + ANSI color per file type
581
- const NF_DIR = `${FG_BLUE}\ue5ff${RST}`; // folder
582
- const NF_DEFAULT = `${FG_DIM}\uf15b${RST}`; // generic file
583
-
584
- const EXT_ICON: Record<string, string> = {
585
- // TypeScript / JavaScript
586
- ts: `\x1b[38;2;49;120;198m\ue628${RST}`, // blue
587
- tsx: `\x1b[38;2;49;120;198m\ue7ba${RST}`, // react blue
588
- js: `\x1b[38;2;241;224;90m\ue74e${RST}`, // yellow
589
- jsx: `\x1b[38;2;97;218;251m\ue7ba${RST}`, // react cyan
590
- mjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
591
- cjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
592
-
593
- // Systems / Backend
594
- py: `\x1b[38;2;55;118;171m\ue73c${RST}`, // python blue
595
- rs: `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust orange
596
- go: `\x1b[38;2;0;173;216m\ue724${RST}`, // go cyan
597
- java: `\x1b[38;2;204;62;68m\ue738${RST}`, // java red
598
- swift: `\x1b[38;2;255;172;77m\ue755${RST}`, // swift orange
599
- rb: `\x1b[38;2;204;52;45m\ue739${RST}`, // ruby red
600
- kt: `\x1b[38;2;126;103;200m\ue634${RST}`, // kotlin purple
601
- c: `\x1b[38;2;85;154;211m\ue61e${RST}`, // c blue
602
- cpp: `\x1b[38;2;85;154;211m\ue61d${RST}`, // cpp blue
603
- h: `\x1b[38;2;140;160;185m\ue61e${RST}`, // header muted
604
- hpp: `\x1b[38;2;140;160;185m\ue61d${RST}`,
605
- cs: `\x1b[38;2;104;33;122m\ue648${RST}`, // c# purple
606
-
607
- // Web
608
- html: `\x1b[38;2;228;77;38m\ue736${RST}`, // html orange
609
- css: `\x1b[38;2;66;165;245m\ue749${RST}`, // css blue
610
- scss: `\x1b[38;2;207;100;154m\ue749${RST}`, // scss pink
611
- less: `\x1b[38;2;66;165;245m\ue749${RST}`,
612
- vue: `\x1b[38;2;65;184;131m\ue6a0${RST}`, // vue green
613
- svelte: `\x1b[38;2;255;62;0m\ue697${RST}`, // svelte red-orange
614
-
615
- // Config / Data
616
- json: `\x1b[38;2;241;224;90m\ue60b${RST}`, // json yellow
617
- jsonc: `\x1b[38;2;241;224;90m\ue60b${RST}`,
618
- yaml: `\x1b[38;2;160;116;196m\ue6a8${RST}`, // yaml purple
619
- yml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
620
- toml: `\x1b[38;2;160;116;196m\ue6b2${RST}`, // toml purple
621
- xml: `\x1b[38;2;228;77;38m\ue619${RST}`, // xml orange
622
- sql: `\x1b[38;2;218;218;218m\ue706${RST}`, // sql gray
623
-
624
- // Markdown / Docs
625
- md: `\x1b[38;2;66;165;245m\ue73e${RST}`, // markdown blue
626
- mdx: `\x1b[38;2;66;165;245m\ue73e${RST}`,
627
-
628
- // Shell / Scripts
629
- sh: `\x1b[38;2;137;180;130m\ue795${RST}`, // shell green
630
- bash: `\x1b[38;2;137;180;130m\ue795${RST}`,
631
- zsh: `\x1b[38;2;137;180;130m\ue795${RST}`,
632
- fish: `\x1b[38;2;137;180;130m\ue795${RST}`,
633
- lua: `\x1b[38;2;81;160;207m\ue620${RST}`, // lua blue
634
- php: `\x1b[38;2;137;147;186m\ue73d${RST}`, // php purple
635
- dart: `\x1b[38;2;87;182;240m\ue798${RST}`, // dart blue
636
-
637
- // Images
638
- png: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
639
- jpg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
640
- jpeg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
641
- gif: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
642
- svg: `\x1b[38;2;255;180;50m\uf1c5${RST}`,
643
- webp: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
644
- ico: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
645
-
646
- // Misc
647
- lock: `\x1b[38;2;130;130;130m\uf023${RST}`, // lock gray
648
- env: `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
649
- graphql: `\x1b[38;2;224;51;144m\ue662${RST}`, // graphql pink
650
- dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`,
651
- };
652
-
653
- const NAME_ICON: Record<string, string> = {
654
- "package.json": `\x1b[38;2;137;180;130m\ue71e${RST}`, // npm green
655
- "package-lock.json": `\x1b[38;2;130;130;130m\ue71e${RST}`, // npm gray
656
- "tsconfig.json": `\x1b[38;2;49;120;198m\ue628${RST}`, // ts blue
657
- "biome.json": `\x1b[38;2;96;165;250m\ue615${RST}`, // config blue
658
- ".gitignore": `\x1b[38;2;222;165;132m\ue702${RST}`, // git orange
659
- ".git": `\x1b[38;2;222;165;132m\ue702${RST}`,
660
- ".env": `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
661
- ".envrc": `\x1b[38;2;241;224;90m\ue615${RST}`,
662
- dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`, // docker blue
663
- makefile: `\x1b[38;2;130;130;130m\ue615${RST}`, // make gray
664
- gnumakefile: `\x1b[38;2;130;130;130m\ue615${RST}`,
665
- "readme.md": `\x1b[38;2;66;165;245m\ue73e${RST}`, // readme blue
666
- license: `\x1b[38;2;218;218;218m\ue60a${RST}`, // license white
667
- "cargo.toml": `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust
668
- "go.mod": `\x1b[38;2;0;173;216m\ue724${RST}`, // go
669
- "pyproject.toml": `\x1b[38;2;55;118;171m\ue73c${RST}`, // python
670
- };
671
-
672
- function fileIcon(fp: string): string {
673
- if (!USE_ICONS) return "";
674
- const base = basename(fp).toLowerCase();
675
- if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
676
- const ext = extname(fp).slice(1).toLowerCase();
677
- return EXT_ICON[ext] ? `${EXT_ICON[ext]} ` : `${NF_DEFAULT} `;
678
- }
679
-
680
- function dirIcon(): string {
681
- return USE_ICONS ? `${NF_DIR} ` : "";
682
- }
683
-
684
- // ---------------------------------------------------------------------------
685
- // Shiki ANSI cache
686
- // ---------------------------------------------------------------------------
687
-
688
- // Pre-warm
689
- codeToANSI("", "typescript", THEME).catch(() => {});
690
-
691
- const _cache = new Map<string, string[]>();
692
-
693
- function _touch(k: string, v: string[]): string[] {
694
- _cache.delete(k);
695
- _cache.set(k, v);
696
- while (_cache.size > CACHE_LIMIT) {
697
- const first = _cache.keys().next().value;
698
- if (first === undefined) break;
699
- _cache.delete(first);
700
- }
701
- return v;
702
- }
703
-
704
- async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
705
- if (!code) return [""];
706
- if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
707
-
708
- const k = `${THEME}\0${language}\0${code}`;
709
- const hit = _cache.get(k);
710
- if (hit) return _touch(k, hit);
711
-
712
- try {
713
- const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
714
- const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
715
- return _touch(k, out);
716
- } catch {
717
- return code.split("\n");
718
- }
719
- }
720
-
721
- // ---------------------------------------------------------------------------
722
- // Renderers
723
- // ---------------------------------------------------------------------------
724
-
725
- /** Render syntax-highlighted file content with line numbers. */
726
- async function renderFileContent(
727
- content: string,
728
- filePath: string,
729
- offset = 1,
730
- maxLines = MAX_PREVIEW_LINES,
731
- width?: number,
732
- ): Promise<string> {
733
- const normalizedContent = normalizeLineEndings(content);
734
- const lines = normalizedContent.split("\n");
735
- const total = lines.length;
736
- const show = lines.slice(0, maxLines);
737
- const lg = lang(filePath);
738
- const hl = await hlBlock(show.join("\n"), lg);
739
-
740
- const tw = width ?? termW();
741
- const startLine = offset;
742
- const endLine = startLine + show.length - 1;
743
- const nw = Math.max(3, String(endLine).length);
744
- const gw = nw + 3; // num + " │ "
745
- const cw = Math.max(1, tw - gw);
746
-
747
- const out: string[] = [];
748
- out.push(rule(tw));
749
-
750
- for (let i = 0; i < hl.length; i++) {
751
- const ln = startLine + i;
752
- const code = hl[i] ?? show[i] ?? "";
753
- const display = truncateToWidth(code, cw, `${FG_DIM}›`);
754
- out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
755
- }
756
-
757
- out.push(rule(tw));
758
- if (total > maxLines) {
759
- out.push(`${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`);
760
- }
761
- return out.join("\n");
762
- }
763
-
764
- function inferBashExitCode(text: string, fallback: number | null): number | null {
765
- const exitMatch = text.match(/(?:exit code|exited with(?: code)?|exit status)[:\s]*(\d+)/i);
766
- if (exitMatch) return Number(exitMatch[1]);
767
- if (text.includes("command not found") || text.includes("No such file")) return 1;
768
- return fallback;
769
- }
770
-
771
- /** Render bash output with colored exit code and stderr highlighting. */
772
- function renderBashOutput(text: string, exitCode: number | null): { summary: string; body: string } {
773
- const isOk = exitCode === 0;
774
- const statusFg = isOk ? FG_GREEN : FG_RED;
775
- const statusIcon = isOk ? "✓" : "✗";
776
- const codeStr = exitCode !== null ? `${statusFg}${statusIcon} exit ${exitCode}${RST}` : `${FG_YELLOW}⚡ killed${RST}`;
777
-
778
- const lines = text.split("\n");
779
- const maxShow = MAX_PREVIEW_LINES;
780
- const show = lines.slice(0, maxShow);
781
- const remaining = lines.length - maxShow;
782
-
783
- let body = show.join("\n");
784
- if (remaining > 0) {
785
- body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
786
- }
787
-
788
- return { summary: codeStr, body };
789
- }
790
-
791
- /** Render ls output as a tree view with icons. */
792
- function renderTree(text: string, _basePath: string): string {
793
- const lines = text.trim().split("\n").filter(Boolean);
794
- if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
795
-
796
- const out: string[] = [];
797
- const total = lines.length;
798
- const show = lines.slice(0, MAX_PREVIEW_LINES);
799
-
800
- for (let i = 0; i < show.length; i++) {
801
- const entry = show[i].trim();
802
- const isLast = i === show.length - 1 && total <= MAX_PREVIEW_LINES;
803
- const prefix = isLast ? "└── " : "├── ";
804
- const connector = `${FG_RULE}${prefix}${RST}`;
805
-
806
- // Detect directories (entries ending with /)
807
- const isDir = entry.endsWith("/");
808
- const name = isDir ? entry.slice(0, -1) : entry;
809
- const icon = isDir ? dirIcon() : fileIcon(name);
810
- const fg = isDir ? FG_BLUE + BOLD : "";
811
- const reset = isDir ? RST : "";
812
-
813
- out.push(`${connector}${icon}${fg}${name}${reset}`);
814
- }
815
11
 
816
- if (total > MAX_PREVIEW_LINES) {
817
- out.push(`${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`);
818
- }
819
-
820
- return out.join("\n");
821
- }
822
-
823
- /** Render find results grouped by directory with icons. */
824
- function renderFindResults(text: string): string {
825
- const lines = text.trim().split("\n").filter(Boolean);
826
- if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
827
-
828
- // Group by directory
829
- const groups = new Map<string, string[]>();
830
- for (const line of lines) {
831
- const trimmed = line.trim();
832
- const dir = dirname(trimmed) || ".";
833
- const file = basename(trimmed);
834
- if (!groups.has(dir)) groups.set(dir, []);
835
- const bucket = groups.get(dir);
836
- if (bucket) bucket.push(file);
837
- }
838
-
839
- const out: string[] = [];
840
- let count = 0;
841
-
842
- for (const [dir, files] of groups) {
843
- if (count > 0) out.push(""); // blank line between groups
844
- out.push(`${dirIcon()}${FG_BLUE}${BOLD}${dir}/${RST}`);
845
- for (let i = 0; i < files.length; i++) {
846
- if (count >= MAX_PREVIEW_LINES) {
847
- out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
848
- return out.join("\n");
849
- }
850
- const isLast = i === files.length - 1;
851
- const prefix = isLast ? "└── " : "├── ";
852
- const icon = fileIcon(files[i]);
853
- out.push(` ${FG_RULE}${prefix}${RST}${icon}${files[i]}`);
854
- count++;
855
- }
856
- }
857
-
858
- return out.join("\n");
859
- }
860
-
861
- /** Render grep results with highlighted matches and line numbers. */
862
- async function renderGrepResults(text: string, pattern: string): Promise<string> {
863
- const lines = normalizeLineEndings(text).split("\n");
864
- if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
12
+ // Re-export for tests
13
+ export { __imageInternals } from "./image.js";
865
14
 
866
- const out: string[] = [];
867
- let currentFile = "";
868
- let count = 0;
15
+ import { mkdirSync } from "node:fs";
16
+ import { join } from "node:path";
869
17
 
870
- // Try to build a regex for highlighting
871
- let re: RegExp | null = null;
872
- try {
873
- re = new RegExp(`(${pattern})`, "gi");
874
- } catch {
875
- // invalid regex — skip highlighting
876
- }
18
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
877
19
 
878
- for (const line of lines) {
879
- if (count >= MAX_PREVIEW_LINES) {
880
- out.push(`${FG_DIM} more matches${RST}`);
881
- break;
882
- }
883
-
884
- // ripgrep-style: "file:line:content" or "file-line-content" or just "file"
885
- const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
886
- if (fileMatch) {
887
- const [, file, lineNo, content] = fileMatch;
888
- if (file !== currentFile) {
889
- if (currentFile) out.push(""); // blank line between files
890
- const icon = fileIcon(file);
891
- out.push(`${icon}${FG_BLUE}${BOLD}${file}${RST}`);
892
- currentFile = file;
893
- }
894
-
895
- const nw = Math.max(3, lineNo.length);
896
- let display = content;
897
- if (re) {
898
- display = content.replace(re, `${RST}${FG_YELLOW}${BOLD}$1${RST}`);
899
- }
900
- out.push(` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
901
- count++;
902
- } else if (line.trim() === "--") {
903
- // ripgrep separator
904
- out.push(` ${FG_DIM} ···${RST}`);
905
- } else if (line.trim()) {
906
- out.push(line);
907
- count++;
908
- }
909
- }
910
-
911
- return out.join("\n");
912
- }
20
+ import type { PiPrettyDeps } from "./types.js";
21
+ import { FffService } from "./fff.js";
22
+ import { registerReadTool } from "./tools/read.js";
23
+ import { registerBashTool } from "./tools/bash.js";
24
+ import { registerLsTool } from "./tools/ls.js";
25
+ import { registerFindTool } from "./tools/find.js";
26
+ import { registerGrepTool } from "./tools/grep.js";
27
+ import { registerMultiGrepTool } from "./tools/multi-grep.js";
28
+ import { runMultiGrepRipgrepFallback } from "./multi-grep-fallback.js";
29
+ import { getDefaultAgentDir } from "./config.js";
30
+ import { createFffAutocompleteProvider } from "./autocomplete.js";
913
31
 
914
32
  // ---------------------------------------------------------------------------
915
- // Tool metrics — elapsed time + output size
916
- // pi-droid-styling-inspired: wrap execute to record performance, display in footer.
917
- // ---------------------------------------------------------------------------
918
-
919
- const ELAPSED_KEY = "__prettyElapsedMs";
920
- const CHARS_KEY = "__prettyOutputChars";
921
-
922
- /** Format milliseconds for display. */
923
- function formatElapsedMs(ms: number | undefined): string {
924
- if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
925
- if (ms < 1000) return `${Math.round(ms)}ms`;
926
- const s = ms / 1000;
927
- return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
928
- }
929
-
930
- /** Format character count for display. */
931
- function formatCharCount(chars: number | undefined): string {
932
- if (typeof chars !== "number" || !Number.isFinite(chars) || chars <= 0) return "";
933
- if (chars < 1000) return `${chars} chars`;
934
- if (chars < 10_000) return `${(chars / 1000).toFixed(1)}k chars`;
935
- return `${Math.round(chars / 1000)}k chars`;
936
- }
937
-
938
- /** Compute text output length from a tool result. */
939
- function getOutputCharCount(result: ToolResultLike): number {
940
- const content = result.content;
941
- if (!Array.isArray(content)) return 0;
942
- let length = 0;
943
- for (const block of content) {
944
- if (block.type !== "text") continue;
945
- length += String(block.text ?? "").replace(/\r/g, "").length;
946
- }
947
- return length;
948
- }
949
-
950
- /**
951
- * Wrap a tool's execute function to measure elapsed time and output size.
952
- * Annotates result.details with __prettyElapsedMs and __prettyOutputChars.
953
- */
954
- function wrapExecuteWithMetrics<TParams, TDetails>(
955
- execute: (...args: any[]) => Promise<ToolResultLike<TDetails>>,
956
- ): ToolExecutor<TParams, TDetails> {
957
- return async (
958
- tid: string,
959
- params: TParams,
960
- sig?: AbortSignal,
961
- onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
962
- ctx?: ExtensionContext,
963
- ) => {
964
- const start = performance.now();
965
- const result = await execute(tid, params, sig, onUpdate, ctx);
966
- const elapsedMs = performance.now() - start;
967
- const details = (result.details ?? {}) as Record<string, unknown>;
968
- details[ELAPSED_KEY] = elapsedMs;
969
- details[CHARS_KEY] = getOutputCharCount(result);
970
- (result as { details: Record<string, unknown> }).details = details;
971
- return result;
972
- };
973
- }
974
-
975
- /** Render a tool metrics line: "3.2s · 14.2k chars" */
976
- function renderToolMetrics(result: ToolResultLike): string {
977
- const details = result.details as Record<string, unknown> | undefined;
978
- if (!details) return "";
979
- const elapsed = formatElapsedMs(details[ELAPSED_KEY] as number | undefined);
980
- const chars = formatCharCount(details[CHARS_KEY] as number | undefined);
981
- if (!elapsed && !chars) return "";
982
- return `${FG_DIM}· ${[elapsed, chars].filter(Boolean).join(" · ")}${RST}`;
983
- }
984
-
985
- // ---------------------------------------------------------------------------
986
- // FFF integration (optional) — Fast File Finder with frecency & SIMD search
987
- //
988
- // If @ff-labs/fff-node is installed, find/grep use FFF for speed + frecency.
989
- // If not, falls back to wrapping SDK tools (current behavior).
33
+ // Config
990
34
  // ---------------------------------------------------------------------------
991
35
 
992
- type ToolTextContent = TextContent;
993
- type ToolImageContent = ImageContent;
994
- type ToolContent = TextContent | ImageContent;
995
- type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
996
- type TextComponentLike = { setText(value: string): void; getText?: () => string; render?: (width: number) => string[] };
997
- type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
998
- type WidthAwareTextComponent = TextComponentLike & {
999
- __piPrettyWidthAware?: boolean;
1000
- __piPrettyRender?: (width: number) => string[];
1001
- __piPrettyRenderedKey?: string;
1002
- __piPrettyTask?: {
1003
- key: (width: number) => string;
1004
- render: (width: number) => string;
1005
- };
1006
- };
1007
- type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
1008
- type RenderContextLike<TState extends Record<string, string | undefined> = Record<string, string | undefined>> = {
1009
- lastComponent?: TextComponentLike;
1010
- state: TState;
1011
- expanded: boolean;
1012
- isError: boolean;
1013
- invalidate: () => void;
1014
- };
1015
- type SessionContextLike = ExtensionContext;
1016
- type CommandContextLike = ExtensionCommandContext;
1017
- type ToolExecutor<TParams, TDetails = unknown> = (
1018
- toolCallId: string,
1019
- params: TParams,
1020
- signal?: AbortSignal,
1021
- onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
1022
- ctx?: ExtensionContext,
1023
- ) => Promise<ToolResultLike<TDetails>>;
1024
- type ToolFactory<TParams, TDetails = unknown> = (cwd: string) => {
1025
- name?: string;
1026
- description?: string;
1027
- label?: string;
1028
- parameters?: unknown;
1029
- execute: ToolExecutor<TParams, TDetails>;
1030
- };
1031
- type PiPrettySdk = {
1032
- createReadToolDefinition?: ToolFactory<ReadToolInput>;
1033
- createReadTool?: ToolFactory<ReadToolInput>;
1034
- createBashToolDefinition?: ToolFactory<BashToolInput>;
1035
- createBashTool?: ToolFactory<BashToolInput>;
1036
- createLsToolDefinition?: ToolFactory<LsToolInput>;
1037
- createLsTool?: ToolFactory<LsToolInput>;
1038
- createFindToolDefinition?: ToolFactory<FindToolInput>;
1039
- createFindTool?: ToolFactory<FindToolInput>;
1040
- createGrepToolDefinition?: ToolFactory<GrepToolInput>;
1041
- createGrepTool?: ToolFactory<GrepToolInput>;
1042
- getAgentDir?: () => string;
1043
- };
1044
- type PiPrettyApi = {
1045
- registerTool: (tool: unknown) => void;
1046
- registerCommand: (
1047
- name: string,
1048
- command: {
1049
- description?: string;
1050
- handler: (args: string, ctx: CommandContextLike) => Promise<void> | void;
1051
- },
1052
- ) => void;
1053
- on: (event: string, handler: (event: unknown, ctx: SessionContextLike) => Promise<void> | void) => void;
1054
- };
1055
- type OptionalFffModule = { FileFinder: typeof FileFinder };
1056
- type FffBackedFinder = FileFinder;
1057
- type ReadParams = ReadToolInput;
1058
- type BashParams = BashToolInput;
1059
- type LsParams = LsToolInput;
1060
- type FindParams = FindToolInput;
1061
- type GrepParams = GrepToolInput;
1062
- type MultiGrepParams = {
1063
- patterns: string[];
1064
- path?: string;
1065
- constraints?: string;
1066
- context?: number;
1067
- limit?: number;
1068
- };
1069
- type BashRenderState = Record<string, string | undefined>;
1070
- type GrepRenderState = { _gk?: string; _gt?: string };
1071
- type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
1072
- type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
1073
- type GrepResultDetails = { _type: "grepResult"; text: string; pattern: string; matchCount: number };
1074
- type RenderDetails =
1075
- | { _type: "readImage"; filePath: string; data: string; mimeType: string }
1076
- | { _type: "readFile"; filePath: string; content: string; offset: number; lineCount: number }
1077
- | { _type: "bashResult"; text: string; exitCode: number | null; command: string }
1078
- | { _type: "lsResult"; text: string; path: string; entryCount: number }
1079
- | FindResultDetails
1080
- | GrepResultDetails;
1081
-
1082
- function isTextContent(content: ToolContent): content is ToolTextContent {
1083
- return content.type === "text";
1084
- }
1085
-
1086
- function isImageContent(content: ToolContent): content is ToolImageContent {
1087
- return content.type === "image";
1088
- }
1089
-
1090
- function getTextContent(result: ToolResultLike): string {
1091
- return (
1092
- result.content
1093
- ?.filter(isTextContent)
1094
- .map((content) => content.text || "")
1095
- .join("\n") ?? ""
36
+ function envDisabledTools(): Set<string> {
37
+ return new Set(
38
+ (process.env.PRETTY_DISABLE_TOOLS ?? "")
39
+ .split(",")
40
+ .map((s) => s.trim().toLowerCase())
41
+ .filter(Boolean),
1096
42
  );
1097
43
  }
1098
44
 
1099
- function setResultDetails<T>(result: ToolResultLike, details: T): void {
1100
- result.details = details;
1101
- }
1102
-
1103
- function makeTextResult<TDetails>(text: string, details: TDetails): ToolResultLike<TDetails> {
1104
- return {
1105
- content: [{ type: "text", text }],
1106
- details,
1107
- };
1108
- }
1109
-
1110
- function appendNotices(text: string, notices: string[]): string {
1111
- return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text;
1112
- }
1113
-
1114
- function countRipgrepMatches(text: string): number {
1115
- return text
1116
- .trim()
1117
- .split("\n")
1118
- .filter((line) => /^.+?[:-]\d+[:-]/.test(line)).length;
1119
- }
1120
-
1121
- function getErrorMessage(error: unknown): string {
1122
- return error instanceof Error ? error.message : String(error);
1123
- }
1124
-
1125
- function trimToUndefined(value: string | undefined): string | undefined {
1126
- const trimmed = value?.trim();
1127
- return trimmed ? trimmed : undefined;
1128
- }
1129
-
1130
- function escapeRegexLiteral(text: string): string {
1131
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1132
- }
1133
-
1134
- function buildLiteralAlternationPattern(patterns: string[]): string {
1135
- return patterns
1136
- .map(escapeRegexLiteral)
1137
- .sort((a, b) => b.length - a.length)
1138
- .join("|");
1139
- }
1140
-
1141
- function shouldIgnoreCaseForPatterns(patterns: string[]): boolean {
1142
- return patterns.every((pattern) => pattern.toLowerCase() === pattern);
1143
- }
1144
-
1145
- function getConstraintBackedPath(constraints: string | undefined): string | undefined {
1146
- const trimmed = trimToUndefined(constraints);
1147
- if (!trimmed || /\s/.test(trimmed) || trimmed.includes("!") || trimmed.endsWith("/") || /[*?[{]/.test(trimmed)) {
1148
- return undefined;
1149
- }
1150
- return trimmed;
1151
- }
1152
-
1153
- const _cursorStore = new CursorStore();
1154
- let _fffModule: OptionalFffModule | null = null;
1155
- let _fffFinder: FffBackedFinder | null = null;
1156
- let _fffPartialIndex = false;
1157
- let _fffDbDir: string | null = null;
1158
- const FFF_SCAN_TIMEOUT = 15_000;
1159
-
1160
- function getPiPrettyFffDir(agentDir: string): string {
1161
- return join(agentDir, "pi-pretty", "fff");
1162
- }
1163
-
1164
- async function fffEnsureFinder(cwd: string): Promise<FffBackedFinder | null> {
1165
- if (_fffFinder && !_fffFinder.isDestroyed) return _fffFinder;
1166
- if (!_fffModule || !_fffDbDir) return null;
1167
-
1168
- const result = _fffModule.FileFinder.create({
1169
- basePath: cwd,
1170
- frecencyDbPath: join(_fffDbDir, "frecency.mdb"),
1171
- historyDbPath: join(_fffDbDir, "history.mdb"),
1172
- aiMode: true,
1173
- });
1174
-
1175
- if (!result.ok) throw new Error(`FFF init failed: ${result.error}`);
1176
-
1177
- _fffFinder = result.value;
1178
- const scan = await _fffFinder.waitForScan(FFF_SCAN_TIMEOUT);
1179
- _fffPartialIndex = scan.ok && !scan.value;
1180
-
1181
- return _fffFinder;
1182
- }
1183
-
1184
- function fffDestroy(): void {
1185
- if (_fffFinder && !_fffFinder.isDestroyed) {
1186
- _fffFinder.destroy();
1187
- _fffFinder = null;
1188
- }
1189
- _fffPartialIndex = false;
1190
- }
1191
-
1192
45
  // ---------------------------------------------------------------------------
1193
- // Extension entry point
46
+ // Entry
1194
47
  // ---------------------------------------------------------------------------
1195
48
 
1196
- /**
1197
- * Dependencies that can be injected for testing.
1198
- * In production, omit `deps` — the extension uses require() to load them.
1199
- */
1200
- export interface PiPrettyDeps {
1201
- sdk: PiPrettySdk;
1202
- TextComponent: TextComponentCtor;
1203
- fffModule?: OptionalFffModule;
1204
- multiGrepRipgrepFallback?: MultiGrepRipgrepFallback;
1205
- }
49
+ export type { PiPrettyDeps };
50
+
51
+ export default function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrettyDeps): void {
52
+ const disabledTools = envDisabledTools();
53
+ const isToolEnabled = (name: string) => !disabledTools.has(name.toLowerCase());
54
+ const cwd = process.cwd();
1206
55
 
1207
- export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps): void {
1208
- let createReadTool: ToolFactory<ReadToolInput> | undefined;
1209
- let createBashTool: ToolFactory<BashToolInput> | undefined;
1210
- let createLsTool: ToolFactory<LsToolInput> | undefined;
1211
- let createFindTool: ToolFactory<FindToolInput> | undefined;
1212
- let createGrepTool: ToolFactory<GrepToolInput> | undefined;
1213
- let TextComponent: TextComponentCtor;
56
+ // ------------------------------------------------------------------
57
+ // Resolve SDK tools
58
+ // ------------------------------------------------------------------
1214
59
 
1215
- let sdk: PiPrettySdk;
60
+ let sdk: any;
61
+ let createReadTool: any;
62
+ let createBashTool: any;
63
+ let createLsTool: any;
64
+ let createFindTool: any;
65
+ let createGrepTool: any;
66
+ let getAgentDir: (() => string) | undefined;
1216
67
 
1217
68
  if (deps) {
1218
- // Test path: use injected dependencies, reset module state
1219
- sdk = deps.sdk;
1220
- createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
1221
- createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
1222
- createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
1223
- createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
1224
- createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
1225
- TextComponent = deps.TextComponent;
1226
- _fffModule = deps.fffModule ?? null;
1227
- _fffFinder = null;
1228
- _fffPartialIndex = false;
1229
- _fffDbDir = null;
69
+ sdk = deps.sdk ?? {};
70
+ createReadTool = sdk.createReadTool ?? sdk.createReadToolDefinition;
71
+ createBashTool = sdk.createBashTool ?? sdk.createBashToolDefinition;
72
+ createLsTool = sdk.createLsTool ?? sdk.createLsToolDefinition;
73
+ createFindTool = sdk.createFindTool ?? sdk.createFindToolDefinition;
74
+ createGrepTool = sdk.createGrepTool ?? sdk.createGrepToolDefinition;
75
+ getAgentDir = sdk.getAgentDir;
1230
76
  } else {
1231
77
  try {
1232
78
  sdk = require("@earendil-works/pi-coding-agent");
@@ -1235,975 +81,95 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1235
81
  createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
1236
82
  createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
1237
83
  createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
1238
- TextComponent = require("@earendil-works/pi-tui").Text;
1239
84
  } catch {
1240
- return;
85
+ return; // pi SDK not available
1241
86
  }
1242
87
  }
1243
- if (!createReadTool || !TextComponent) return;
1244
88
 
1245
- const cwd = process.cwd();
1246
- const home = process.env.HOME ?? "";
1247
- const sp = (p: string) => shortPath(cwd, home, p);
1248
- const multiGrepRipgrepFallback = deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
89
+ if (!createReadTool) return;
1249
90
 
1250
- // Parse PRETTY_DISABLE_TOOLS — comma-separated tool names to skip
1251
- const disabledTools = new Set(
1252
- (process.env.PRETTY_DISABLE_TOOLS ?? "")
1253
- .split(",")
1254
- .map((s) => s.trim().toLowerCase())
1255
- .filter(Boolean),
1256
- );
1257
- function isToolEnabled(name: string): boolean {
1258
- return !disabledTools.has(name.toLowerCase());
1259
- }
1260
-
1261
- function getWidthAwareText(lastComponent: TextComponentLike | undefined): WidthAwareTextComponent {
1262
- const text = (lastComponent ?? new TextComponent("", 0, 0)) as WidthAwareTextComponent;
1263
- if (text.__piPrettyWidthAware) return text;
1264
- const baseRender = typeof text.render === "function" ? text.render.bind(text) : null;
1265
- if (!baseRender) return text;
1266
- text.__piPrettyWidthAware = true;
1267
- text.__piPrettyRender = baseRender;
1268
- text.render = (width: number) => {
1269
- const task = text.__piPrettyTask;
1270
- if (task) {
1271
- const renderWidth = Math.max(1, Math.floor(width || termW()));
1272
- const key = task.key(renderWidth);
1273
- if (text.__piPrettyRenderedKey !== key) {
1274
- text.__piPrettyRenderedKey = key;
1275
- text.setText(task.render(renderWidth));
1276
- }
1277
- }
1278
- return text.__piPrettyRender?.(width) ?? [];
1279
- };
1280
- return text;
1281
- }
91
+ // ------------------------------------------------------------------
92
+ // FFF service init
93
+ // ------------------------------------------------------------------
1282
94
 
1283
- // ===================================================================
1284
- // Generic renderResult for custom tools (no custom renderer)
1285
- // ===================================================================
95
+ const agentDir = getAgentDir ? getAgentDir() : getDefaultAgentDir();
1286
96
 
1287
- const origRegisterTool = pi.registerTool.bind(pi);
1288
- pi.registerTool = (tool: any) => {
1289
- if (!tool.renderResult && !tool.renderCall) {
1290
- const toolName = tool.label ?? tool.name ?? "tool";
1291
- tool.renderResult = (result: any, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) => {
1292
- resolveBaseBackground(theme);
1293
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1294
-
1295
- if (ctx.isError) {
1296
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1297
- return text;
1298
- }
97
+ let fffService: FffService | null = null;
1299
98
 
1300
- const content = getTextContent(result);
1301
- if (content) {
1302
- const renderWidth = termW();
1303
- const lines = content.split("\n");
1304
- const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
1305
- const preview = lines.slice(0, maxShow).join("\n");
1306
- const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
1307
- const metrics = renderToolMetrics(result);
1308
- text.setText(
1309
- fillToolBackground(` ${preview}${more}${metrics ? `\n ${metrics}` : ""}`, undefined, renderWidth),
1310
- );
1311
- } else {
1312
- text.setText(fillToolBackground(` ${theme.fg("dim", "(no text output)")}`));
1313
- }
1314
-
1315
- return text;
1316
- };
1317
-
1318
- tool.renderCall = (args: any, theme: ThemeLike, ctx: RenderContextLike) => {
1319
- resolveBaseBackground(theme);
1320
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1321
- const bg = ctx.isError ? BG_ERROR : undefined;
1322
- text.setText(
1323
- fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`, bg),
1324
- );
1325
- return text;
1326
- };
1327
- }
1328
- origRegisterTool(tool);
1329
- };
1330
-
1331
- // ===================================================================
1332
- // FFF initialization (optional — graceful fallback to SDK)
1333
- // ===================================================================
1334
-
1335
- const getAgentDir = sdk.getAgentDir;
1336
- setPrettyTheme(
1337
- (() => {
99
+ if (deps?.fffModule) {
100
+ fffService = new FffService(deps.fffModule, agentDir);
101
+ } else if (!deps) {
102
+ // Production: try to load FFF module
1338
103
  try {
1339
- return getAgentDir?.() ?? getDefaultAgentDir();
1340
- } catch {
1341
- return getDefaultAgentDir();
1342
- }
1343
- })(),
1344
- );
1345
- if (!deps) {
1346
- // Only try require() in production — tests inject fffModule via deps
1347
- try {
1348
- _fffModule = require("@ff-labs/fff-node");
1349
- if (getAgentDir) {
1350
- _fffDbDir = getPiPrettyFffDir(getAgentDir());
1351
- try {
1352
- mkdirSync(_fffDbDir, { recursive: true });
1353
- } catch {}
1354
- }
104
+ const fffMod = require("@ff-labs/fff-node");
105
+ fffService = new FffService(fffMod, agentDir);
1355
106
  } catch {
1356
- /* FFF not installed SDK tools will be used */
107
+ // Dynamic import fallback (ESM-only package)
108
+ import("@ff-labs/fff-node").then((mod) => {
109
+ if (!fffService) {
110
+ fffService = new FffService(mod, agentDir);
111
+ }
112
+ }).catch(() => {});
1357
113
  }
1358
- } else if (_fffModule && getAgentDir) {
1359
- _fffDbDir = getPiPrettyFffDir(getAgentDir());
1360
- try {
1361
- mkdirSync(_fffDbDir, { recursive: true });
1362
- } catch {}
1363
114
  }
1364
115
 
1365
- pi.on("session_start", async (_event, ctx) => {
1366
- // Try dynamic import if sync require failed (ESM-only package)
1367
- if (!_fffModule) {
1368
- try {
1369
- const imported = await import("@ff-labs/fff-node");
1370
- _fffModule = { FileFinder: imported.FileFinder };
1371
- } catch {}
1372
- }
1373
- if (!_fffModule) return;
1374
-
1375
- if (!_fffDbDir) {
1376
- const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
1377
- _fffDbDir = getPiPrettyFffDir(agentDir);
1378
- try {
1379
- mkdirSync(_fffDbDir, { recursive: true });
1380
- } catch {}
1381
- }
1382
-
1383
- try {
1384
- await fffEnsureFinder(ctx.cwd);
1385
- if (_fffPartialIndex) {
1386
- ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
1387
- } else {
1388
- const ui = ctx.ui;
1389
- ui?.setStatus?.("fff", "FFF indexed");
1390
- setTimeout(() => ui?.setStatus?.("fff", undefined), 3000);
1391
- }
1392
- } catch (error: unknown) {
1393
- ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
1394
- }
1395
- });
1396
-
1397
- pi.on("session_shutdown", async () => {
1398
- fffDestroy();
1399
- });
1400
-
1401
- // ===================================================================
1402
- // read — syntax-highlighted file content
1403
- // ===================================================================
1404
-
1405
- const origRead = createReadTool(cwd);
1406
-
1407
- if (isToolEnabled("read")) {
1408
- pi.registerTool({
1409
- ...origRead,
1410
- name: "read",
1411
-
1412
- execute: wrapExecuteWithMetrics(async (
1413
- tid: string,
1414
- params: ReadParams,
1415
- sig: AbortSignal | undefined,
1416
- upd: AgentToolUpdateCallback<unknown> | undefined,
1417
- ctx: ExtensionContext,
1418
- ) => {
1419
- const result = (await origRead.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
116
+ // Ripgrep fallback for multi_grep
117
+ const multiGrepFallback = deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
1420
118
 
1421
- const fp = params.path ?? "";
1422
- const offset = params.offset ?? 1;
1423
-
1424
- const imageBlock = result.content?.find(isImageContent);
1425
- if (imageBlock) {
1426
- setResultDetails(result, {
1427
- _type: "readImage",
1428
- filePath: fp,
1429
- data: imageBlock.data,
1430
- mimeType: imageBlock.mimeType ?? "image/png",
1431
- });
1432
- return result;
1433
- }
1434
-
1435
- const textContent = getTextContent(result);
1436
- if (textContent && fp) {
1437
- const normalizedContent = normalizeLineEndings(textContent);
1438
- const lineCount = normalizedContent.split("\n").length;
1439
- setResultDetails(result, {
1440
- _type: "readFile",
1441
- filePath: fp,
1442
- content: normalizedContent,
1443
- offset,
1444
- lineCount,
1445
- });
1446
- }
119
+ // Text component for custom rendering (DI-friendly)
120
+ const TextComp = deps?.TextComponent;
1447
121
 
1448
- return result;
1449
- }),
122
+ // ------------------------------------------------------------------
123
+ // Tool registration
124
+ // ------------------------------------------------------------------
1450
125
 
1451
- renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
1452
- resolveBaseBackground(theme);
1453
- const fp = args.path ?? "";
1454
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1455
- const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1456
- const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1457
- const bg = ctx.isError ? BG_ERROR : undefined;
1458
- text.setText(
1459
- fillToolBackground(
1460
- `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
1461
- bg,
1462
- ),
1463
- );
1464
- return text;
1465
- },
1466
-
1467
- renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
1468
- resolveBaseBackground(theme);
1469
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1470
-
1471
- if (ctx.isError) {
1472
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1473
- return text;
1474
- }
1475
-
1476
- const d = result.details as RenderDetails | undefined;
1477
-
1478
- // Image reads keep the original image content so Pi's native TUI renderer
1479
- // can display it exactly once. pi-pretty only renders metadata here;
1480
- // rendering another inline image caused duplicate previews.
1481
- if (d?._type === "readImage") {
1482
- const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
1483
- const sizeStr = humanSize(byteSize);
1484
- const mimeStr = d.mimeType ?? "image";
1485
-
1486
- text.setText(fillToolBackground(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`));
1487
- return text;
1488
- }
1489
-
1490
- if (d?._type === "readFile" && d.content) {
1491
- const renderWidth = termW();
1492
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${renderWidth}`;
1493
- if (ctx.state._rk !== key) {
1494
- ctx.state._rk = key;
1495
- const metrics = renderToolMetrics(result);
1496
- const info = `${FG_DIM}${d.lineCount} lines${RST}${metrics}`;
1497
- ctx.state._rt = fillToolBackground(` ${info}`, undefined, renderWidth);
1498
-
1499
- const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1500
- renderFileContent(d.content, d.filePath, d.offset, maxShow, renderWidth)
1501
- .then((rendered: string) => {
1502
- if (ctx.state._rk !== key) return;
1503
- ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
1504
- ctx.invalidate();
1505
- })
1506
- .catch(() => {});
1507
- }
1508
- text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
1509
- return text;
1510
- }
1511
-
1512
- // Fallback
1513
- const fallback = result.content?.[0];
1514
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
1515
- text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1516
- return text;
1517
- },
1518
- });
126
+ if (isToolEnabled("read") && createReadTool) {
127
+ registerReadTool(pi, cwd, null, createReadTool(cwd), TextComp);
1519
128
  }
1520
-
1521
- // ===================================================================
1522
- // bash — colored exit status
1523
- // ===================================================================
1524
-
1525
- if (createBashTool && isToolEnabled("bash")) {
1526
- const origBash = createBashTool(cwd);
1527
-
1528
- pi.registerTool({
1529
- ...origBash,
1530
- name: "bash",
1531
-
1532
- execute: wrapExecuteWithMetrics(async (
1533
- tid: string,
1534
- params: BashParams,
1535
- sig: AbortSignal | undefined,
1536
- upd: AgentToolUpdateCallback<unknown> | undefined,
1537
- ctx: ExtensionContext,
1538
- ) => {
1539
- const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1540
- const textContent = getTextContent(result);
1541
-
1542
- const exitCode = textContent ? inferBashExitCode(textContent, 0) : 0;
1543
-
1544
- setResultDetails(result, {
1545
- _type: "bashResult",
1546
- text: textContent ?? "",
1547
- exitCode,
1548
- command: params.command ?? "",
1549
- });
1550
-
1551
- // Propagate error state to result so the TUI Box picks up
1552
- // toolErrorBg instead of toolSuccessBg for the background.
1553
- // Cast to any since AgentToolResult doesn't expose isError but
1554
- // the TUI runtime checks for it when selecting the background color.
1555
- if (exitCode !== null && exitCode !== 0) {
1556
- (result as any).isError = true;
1557
- }
1558
-
1559
- return result;
1560
- }),
1561
-
1562
- renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
1563
- resolveBaseBackground(theme);
1564
- const cmd = args.command ?? "";
1565
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1566
- const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1567
- const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
1568
- const bg = ctx.isError ? BG_ERROR : undefined;
1569
- const title = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`;
1570
- text.setText(fillToolBackground(title, bg, termW()));
1571
- return text;
1572
- },
1573
-
1574
- renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
1575
- resolveBaseBackground(theme);
1576
- const text = getWidthAwareText(ctx.lastComponent);
1577
-
1578
- const details = result.details as RenderDetails | undefined;
1579
- const textContent = getTextContent(result);
1580
- const d: Extract<RenderDetails, { _type: "bashResult" }> | undefined =
1581
- details?._type === "bashResult"
1582
- ? details
1583
- : textContent || ctx.isError
1584
- ? {
1585
- _type: "bashResult",
1586
- text: textContent || "Error",
1587
- exitCode: inferBashExitCode(textContent, ctx.isError ? 1 : 0),
1588
- command: "",
1589
- }
1590
- : undefined;
1591
- if (d?._type === "bashResult") {
1592
- const isBashError = ctx.isError || (d.exitCode !== null && d.exitCode !== 0);
1593
- const bg = isBashError ? BG_ERROR : undefined;
1594
- const cleanedText = stripBashExitStatusLine(d.text);
1595
- const outputText = isBashError ? compactErrorLines(cleanedText).join("\n") : cleanedText;
1596
- const { summary } = renderBashOutput(outputText, d.exitCode);
1597
- const lines = outputText.split("\n");
1598
- const lineCount = lines.length;
1599
- const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}` : ` ${renderToolMetrics(result)}`;
1600
- const header = ` ${summary}${lineInfo}`;
1601
- const renderAtWidth = (width: number) => {
1602
- if (!outputText.trim()) return fillToolBackground(header, bg, width);
1603
- const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
1604
- const show = lines.slice(0, maxShow);
1605
- const out: string[] = [header, rule(width)];
1606
- for (const line of show) {
1607
- out.push(` ${line}`);
1608
- }
1609
- out.push(rule(width));
1610
- if (lineCount > maxShow) {
1611
- out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
1612
- }
1613
- return fillToolBackground(out.join("\n"), bg, width);
1614
- };
1615
- text.__piPrettyTask = {
1616
- key: (width: number) => `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${outputText.length}:${renderToolMetrics(result)}`,
1617
- render: renderAtWidth,
1618
- };
1619
- text.setText(renderAtWidth(termW()));
1620
- return text;
1621
- }
1622
-
1623
- text.__piPrettyTask = undefined;
1624
- if (ctx.isError) {
1625
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1626
- return text;
1627
- }
1628
-
1629
- const fallback = result.content?.[0];
1630
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "done";
1631
- text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1632
- return text;
1633
- },
1634
- });
129
+ if (isToolEnabled("bash") && createBashTool) {
130
+ registerBashTool(pi, cwd, null, createBashTool(cwd), TextComp);
1635
131
  }
1636
-
1637
- // ===================================================================
1638
- // ls — tree view with icons
1639
- // ===================================================================
1640
-
1641
- if (createLsTool && isToolEnabled("ls")) {
1642
- const origLs = createLsTool(cwd);
1643
-
1644
- pi.registerTool({
1645
- ...origLs,
1646
- name: "ls",
1647
-
1648
- execute: wrapExecuteWithMetrics(async (
1649
- tid: string,
1650
- params: LsParams,
1651
- sig: AbortSignal | undefined,
1652
- upd: AgentToolUpdateCallback<unknown> | undefined,
1653
- ctx: ExtensionContext,
1654
- ) => {
1655
- const result = (await origLs.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1656
- const textContent = getTextContent(result);
1657
- const fp = params.path ?? cwd;
1658
- const entryCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
1659
-
1660
- setResultDetails(result, {
1661
- _type: "lsResult",
1662
- text: textContent ?? "",
1663
- path: fp,
1664
- entryCount,
1665
- });
1666
-
1667
- return result;
1668
- }),
1669
-
1670
- renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
1671
- resolveBaseBackground(theme);
1672
- const fp = args.path ?? ".";
1673
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1674
- const bg = ctx.isError ? BG_ERROR : undefined;
1675
- text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`, bg));
1676
- return text;
1677
- },
1678
-
1679
- renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
1680
- resolveBaseBackground(theme);
1681
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1682
-
1683
- if (ctx.isError) {
1684
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1685
- return text;
1686
- }
1687
-
1688
- const d = result.details as RenderDetails | undefined;
1689
- if (d?._type === "lsResult" && d.text) {
1690
- const tree = renderTree(d.text, d.path);
1691
- const info = `${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}`;
1692
- text.setText(fillToolBackground(` ${info}\n${tree}`));
1693
- return text;
1694
- }
1695
-
1696
- const fallback = result.content?.[0];
1697
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
1698
- text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1699
- return text;
1700
- },
1701
- });
132
+ if (isToolEnabled("ls") && createLsTool) {
133
+ registerLsTool(pi, cwd, null, createLsTool(cwd), TextComp);
1702
134
  }
1703
-
1704
- // ===================================================================
1705
- // find — grouped file list with icons
1706
- // ===================================================================
1707
-
1708
- if (createFindTool && isToolEnabled("find")) {
1709
- const origFind = createFindTool(cwd);
1710
-
1711
- pi.registerTool({
1712
- ...origFind,
1713
- name: "find",
1714
-
1715
- execute: wrapExecuteWithMetrics(async (
1716
- tid: string,
1717
- params: FindParams,
1718
- sig: AbortSignal | undefined,
1719
- upd: unknown,
1720
- ctx: ExtensionContext,
1721
- ) => {
1722
- // Try FFF first (frecency-ranked, SIMD-accelerated)
1723
- if (_fffFinder && !_fffFinder.isDestroyed) {
1724
- try {
1725
- const effectiveLimit = Math.max(1, params.limit ?? 200);
1726
- let query = params.pattern;
1727
- if (params.path) query = `${params.path} ${query}`;
1728
-
1729
- const searchResult = _fffFinder.fileSearch(query, { pageSize: effectiveLimit });
1730
- if (searchResult.ok) {
1731
- const search: SearchResult = searchResult.value;
1732
- const items: FileItem[] = search.items.slice(0, effectiveLimit);
1733
- const notices: string[] = [];
1734
- if (_fffPartialIndex) notices.push("Warning: partial file index");
1735
- if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1736
- if (search.totalMatched > items.length) notices.push(`${search.totalMatched} total matches`);
1737
-
1738
- const textContent = appendNotices(items.map((item) => item.relativePath).join("\n"), notices);
1739
- return makeTextResult<FindResultDetails>(textContent, {
1740
- _type: "findResult",
1741
- text: textContent,
1742
- pattern: params.pattern,
1743
- matchCount: items.length,
1744
- });
1745
- }
1746
- } catch {
1747
- /* fall through to SDK */
1748
- }
1749
- }
1750
-
1751
- // SDK fallback
1752
- const result = await origFind.execute(tid, params, sig, upd as never, ctx);
1753
- const textContent = getTextContent(result);
1754
- const matchCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
1755
-
1756
- setResultDetails<FindResultDetails>(result, {
1757
- _type: "findResult",
1758
- text: textContent,
1759
- pattern: params.pattern,
1760
- matchCount,
1761
- });
1762
-
1763
- return result;
1764
- }),
1765
-
1766
- renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
1767
- resolveBaseBackground(theme);
1768
- const pattern = args.pattern ?? "";
1769
- const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1770
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1771
- const bg = ctx.isError ? BG_ERROR : undefined;
1772
- text.setText(
1773
- fillToolBackground(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`, bg),
1774
- );
1775
- return text;
1776
- },
1777
-
1778
- renderResult(
1779
- result: ToolResultLike<FindResultDetails>,
1780
- _opt: ToolRenderResultOptions,
1781
- theme: ThemeLike,
1782
- ctx: RenderContextLike,
1783
- ) {
1784
- resolveBaseBackground(theme);
1785
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1786
-
1787
- if (ctx.isError) {
1788
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1789
- return text;
1790
- }
1791
-
1792
- const d = result.details;
1793
- if (d?._type === "findResult" && d.text) {
1794
- const rendered = renderFindResults(d.text);
1795
- const info = `${FG_DIM}${d.matchCount} files${RST}${renderToolMetrics(result)}`;
1796
- text.setText(fillToolBackground(` ${info}\n${rendered}`));
1797
- return text;
1798
- }
1799
-
1800
- const fallback = result.content?.[0];
1801
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
1802
- text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1803
- return text;
1804
- },
1805
- });
135
+ if (isToolEnabled("find") && createFindTool) {
136
+ registerFindTool(pi, cwd, fffService, createFindTool(cwd), TextComp);
1806
137
  }
1807
-
1808
- // ===================================================================
1809
- // grep — highlighted matches with line numbers
1810
- // ===================================================================
1811
-
1812
- if (createGrepTool && isToolEnabled("grep")) {
1813
- const origGrep = createGrepTool(cwd);
1814
-
1815
- pi.registerTool({
1816
- ...origGrep,
1817
- name: "grep",
1818
-
1819
- execute: wrapExecuteWithMetrics(async (
1820
- tid: string,
1821
- params: GrepParams,
1822
- sig: AbortSignal | undefined,
1823
- upd: unknown,
1824
- ctx: ExtensionContext,
1825
- ) => {
1826
- // Try FFF first (SIMD-accelerated, frecency-ranked).
1827
- // FFF 0.5.2 can abort the process when path/glob constraints meet
1828
- // Unicode filenames, so constrained searches use the SDK fallback.
1829
- if (_fffFinder && !_fffFinder.isDestroyed && !params.path && !params.glob) {
1830
- try {
1831
- const effectiveLimit = Math.max(1, params.limit ?? 100);
1832
- const query = params.pattern;
1833
-
1834
- const grepResult = _fffFinder.grep(query, {
1835
- mode: params.literal ? "plain" : "regex",
1836
- smartCase: !params.ignoreCase,
1837
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
1838
- cursor: null,
1839
- beforeContext: params.context ?? 0,
1840
- afterContext: params.context ?? 0,
1841
- });
1842
-
1843
- if (grepResult.ok) {
1844
- const grep: GrepResult = grepResult.value;
1845
- const notices: string[] = [];
1846
- if (_fffPartialIndex) notices.push("Warning: partial file index");
1847
- if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1848
- if (grep.regexFallbackError) notices.push(`Regex failed: ${grep.regexFallbackError}, used literal match`);
1849
- if (grep.nextCursor) {
1850
- const cursorId = _cursorStore.store(grep.nextCursor);
1851
- notices.push(`More results available. Use cursor="${cursorId}" to continue`);
1852
- }
1853
-
1854
- const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1855
- return makeTextResult<GrepResultDetails>(textContent, {
1856
- _type: "grepResult",
1857
- text: textContent,
1858
- pattern: params.pattern,
1859
- matchCount: Math.min(grep.items.length, effectiveLimit),
1860
- });
1861
- }
1862
- } catch {
1863
- /* fall through to SDK */
1864
- }
1865
- }
1866
-
1867
- // SDK fallback
1868
- const result = await origGrep.execute(tid, params, sig, upd as never, ctx);
1869
- const textContent = normalizeLineEndings(getTextContent(result));
1870
- if (result.content) {
1871
- for (const content of result.content) {
1872
- if (isTextContent(content)) content.text = normalizeLineEndings(content.text || "");
1873
- }
1874
- }
1875
- const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
1876
-
1877
- setResultDetails<GrepResultDetails>(result, {
1878
- _type: "grepResult",
1879
- text: textContent,
1880
- pattern: params.pattern,
1881
- matchCount,
1882
- });
1883
-
1884
- return result;
1885
- }),
1886
-
1887
- renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1888
- resolveBaseBackground(theme);
1889
- const pattern = args.pattern ?? "";
1890
- const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1891
- const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1892
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1893
- const bg = ctx.isError ? BG_ERROR : undefined;
1894
- text.setText(
1895
- fillToolBackground(
1896
- `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
1897
- bg,
1898
- ),
1899
- );
1900
- return text;
1901
- },
1902
-
1903
- renderResult(
1904
- result: ToolResultLike<GrepResultDetails>,
1905
- _opt: ToolRenderResultOptions,
1906
- theme: ThemeLike,
1907
- ctx: RenderContextLike<GrepRenderState>,
1908
- ) {
1909
- resolveBaseBackground(theme);
1910
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1911
-
1912
- if (ctx.isError) {
1913
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1914
- return text;
1915
- }
1916
-
1917
- const d = result.details;
1918
- if (d?._type === "grepResult" && d.text) {
1919
- const renderWidth = termW();
1920
- const key = `grep:${d.pattern}:${d.matchCount}:${renderWidth}`;
1921
- if (ctx.state._gk !== key) {
1922
- ctx.state._gk = key;
1923
- const metrics = renderToolMetrics(result);
1924
- const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
1925
- ctx.state._gt = fillToolBackground(` ${info}`, undefined, renderWidth);
1926
-
1927
- renderGrepResults(d.text, d.pattern)
1928
- .then((rendered: string) => {
1929
- if (ctx.state._gk !== key) return;
1930
- ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
1931
- ctx.invalidate();
1932
- })
1933
- .catch(() => {});
1934
- }
1935
- text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
1936
- return text;
1937
- }
1938
-
1939
- const fallback = result.content?.[0];
1940
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1941
- text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1942
- return text;
1943
- },
1944
- });
138
+ if (isToolEnabled("grep") && createGrepTool) {
139
+ registerGrepTool(pi, cwd, fffService, createGrepTool(cwd), TextComp);
1945
140
  }
1946
-
1947
- // ===================================================================
1948
- // multi_grep — OR-logic multi-pattern search (FFF when available,
1949
- // SDK grep fallback otherwise)
1950
- // ===================================================================
1951
-
1952
- if ((_fffModule || createGrepTool) && isToolEnabled("multi_grep")) {
1953
- const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
1954
-
1955
- pi.registerTool({
1956
- name: "multi_grep",
1957
- label: "multi_grep",
1958
- description: [
1959
- "Search file contents for lines matching ANY of multiple patterns (OR logic).",
1960
- "Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
1961
- "Falls back to ripgrep while preserving literal OR semantics and file constraints when needed.",
1962
- "Patterns are literal text — never escape special characters.",
1963
- "Use path to scope a directory/file and constraints for file filtering ('*.rs', 'src/', '!test/').",
1964
- ].join(" "),
1965
- promptSnippet: "Multi-pattern OR search across file contents (FFF-accelerated with grep fallback)",
1966
- promptGuidelines: [
1967
- "Use multi_grep when you need to find multiple identifiers at once (OR logic).",
1968
- "Include all naming conventions: snake_case, PascalCase, camelCase variants.",
1969
- "Patterns are literal text. Never escape special characters.",
1970
- "Use path to scope a directory or file when you need fresh on-disk results.",
1971
- "Use the constraints parameter for additional file filtering, not inside patterns.",
1972
- ],
1973
-
1974
- parameters: {
1975
- type: "object",
1976
- properties: {
1977
- patterns: {
1978
- type: "array",
1979
- items: { type: "string" },
1980
- description: "Patterns to search for (OR logic — matches lines containing ANY pattern).",
1981
- },
1982
- path: {
1983
- type: "string",
1984
- description: "Directory or file path to search (default: current directory)",
1985
- },
1986
- constraints: {
1987
- type: "string",
1988
- description: "File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
1989
- },
1990
- context: {
1991
- type: "number",
1992
- description: "Number of context lines before and after each match (default: 0)",
1993
- },
1994
- limit: {
1995
- type: "number",
1996
- description: "Maximum number of matches to return (default: 100)",
1997
- },
1998
- },
1999
- required: ["patterns"],
2000
- },
2001
-
2002
- execute: wrapExecuteWithMetrics(async (
2003
- tid: string,
2004
- params: MultiGrepParams,
2005
- sig: AbortSignal | undefined,
2006
- upd: unknown,
2007
- ctx: ExtensionContext,
2008
- ) => {
2009
- if (sig?.aborted) return makeTextResult("Aborted", {});
2010
-
2011
- if (!params.patterns || params.patterns.length === 0) {
2012
- return makeTextResult("Error: patterns array must have at least 1 element", { error: "empty patterns" });
2013
- }
2014
-
2015
- const effectiveLimit = Math.max(1, params.limit ?? 100);
2016
- const pattern = buildLiteralAlternationPattern(params.patterns);
2017
- const requestedPath = trimToUndefined(params.path);
2018
- const requestedConstraints = trimToUndefined(params.constraints);
2019
- const effectivePath = requestedPath ?? getConstraintBackedPath(requestedConstraints);
2020
- const hasNativeConstraints = Boolean(requestedPath || requestedConstraints);
2021
-
2022
- if (_fffFinder && !_fffFinder.isDestroyed && !hasNativeConstraints) {
2023
- try {
2024
- const grepResult = _fffFinder.multiGrep({
2025
- patterns: params.patterns,
2026
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
2027
- smartCase: true,
2028
- cursor: null,
2029
- beforeContext: params.context ?? 0,
2030
- afterContext: params.context ?? 0,
2031
- });
2032
-
2033
- if (!grepResult.ok) {
2034
- return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
2035
- }
2036
-
2037
- const grep: GrepResult = grepResult.value;
2038
- const notices: string[] = [];
2039
- if (_fffPartialIndex) notices.push("Warning: partial file index");
2040
- if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
2041
- if (grep.nextCursor) {
2042
- const cursorId = _cursorStore.store(grep.nextCursor);
2043
- notices.push(`More results: cursor="${cursorId}"`);
2044
- }
2045
-
2046
- const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
2047
- return makeTextResult<GrepResultDetails>(textContent, {
2048
- _type: "grepResult",
2049
- text: textContent,
2050
- pattern,
2051
- matchCount: Math.min(grep.items.length, effectiveLimit),
2052
- });
2053
- } catch {
2054
- /* fall through to SDK */
2055
- }
2056
- }
2057
-
2058
- if (requestedConstraints || !multiGrepFallback) {
2059
- try {
2060
- const pathBackedConstraint = Boolean(
2061
- requestedConstraints && !requestedPath && requestedConstraints === effectivePath,
2062
- );
2063
- const constraintsForRipgrep = pathBackedConstraint ? undefined : requestedConstraints;
2064
- const notices: string[] = [];
2065
-
2066
- if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used ripgrep fallback");
2067
- else if (hasNativeConstraints) notices.push("Used ripgrep fallback for constrained search");
2068
- else notices.push("Used ripgrep fallback");
2069
-
2070
- const rgResult = await multiGrepRipgrepFallback({
2071
- cwd,
2072
- patterns: params.patterns,
2073
- path: effectivePath,
2074
- constraints: constraintsForRipgrep,
2075
- ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
2076
- context: params.context,
2077
- limit: effectiveLimit,
2078
- signal: sig,
2079
- });
2080
- const textContent = normalizeLineEndings(rgResult.text) || "No matches found";
2081
- if (rgResult.limitReached) notices.push(`${effectiveLimit} limit reached`);
2082
- const finalText = appendNotices(textContent, notices);
2083
-
2084
- return makeTextResult<GrepResultDetails>(finalText, {
2085
- _type: "grepResult",
2086
- text: finalText,
2087
- pattern,
2088
- matchCount: rgResult.matchCount,
2089
- });
2090
- } catch (error: unknown) {
2091
- const message = getErrorMessage(error);
2092
- return makeTextResult(`multi_grep error: ${message}`, { error: message });
2093
- }
2094
- }
2095
-
2096
- try {
2097
- const notices: string[] = [];
2098
- if (!_fffFinder || _fffFinder.isDestroyed) notices.push("FFF unavailable, used SDK grep fallback");
2099
-
2100
- const result = await multiGrepFallback.execute(
2101
- tid,
2102
- {
2103
- pattern,
2104
- path: effectivePath,
2105
- ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
2106
- context: params.context,
2107
- limit: params.limit,
2108
- },
2109
- sig,
2110
- upd as never,
2111
- ctx,
2112
- );
2113
- const textContent = normalizeLineEndings(getTextContent(result)) || "No matches found";
2114
- const finalText = appendNotices(textContent, notices);
2115
-
2116
- return makeTextResult<GrepResultDetails>(finalText, {
2117
- _type: "grepResult",
2118
- text: finalText,
2119
- pattern,
2120
- matchCount: textContent ? countRipgrepMatches(textContent) : 0,
2121
- });
2122
- } catch (error: unknown) {
2123
- const message = getErrorMessage(error);
2124
- return makeTextResult(`multi_grep error: ${message}`, { error: message });
2125
- }
2126
- }),
2127
-
2128
- renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
2129
- resolveBaseBackground(theme);
2130
- const patterns = args.patterns ?? [];
2131
- const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
2132
- const constraints = args.constraints;
2133
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2134
- const bg = ctx.isError ? BG_ERROR : undefined;
2135
- let content =
2136
- theme.fg("toolTitle", theme.bold("multi_grep")) +
2137
- " " +
2138
- theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
2139
- content += path;
2140
- if (constraints) content += theme.fg("muted", ` (${constraints})`);
2141
- text.setText(fillToolBackground(content, bg));
2142
- return text;
2143
- },
2144
-
2145
- renderResult(
2146
- result: ToolResultLike<GrepResultDetails | { error?: string }>,
2147
- _opt: ToolRenderResultOptions,
2148
- theme: ThemeLike,
2149
- ctx: RenderContextLike<MultiGrepRenderState>,
2150
- ) {
2151
- resolveBaseBackground(theme);
2152
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2153
-
2154
- if (ctx.isError) {
2155
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
2156
- return text;
2157
- }
2158
-
2159
- const d = result.details;
2160
- if (d && "_type" in d && d._type === "grepResult" && d.text) {
2161
- const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
2162
- if (ctx.state._mgk !== key) {
2163
- ctx.state._mgk = key;
2164
- const metrics = renderToolMetrics(result);
2165
- const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
2166
- ctx.state._mgt = ` ${info}`;
2167
-
2168
- renderGrepResults(d.text, d.pattern)
2169
- .then((rendered: string) => {
2170
- if (ctx.state._mgk !== key) return;
2171
- ctx.state._mgt = ` ${info}\n${rendered}`;
2172
- ctx.invalidate();
2173
- })
2174
- .catch(() => {});
2175
- }
2176
- text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`);
2177
- return text;
2178
- }
2179
-
2180
- const fallback = result.content?.[0];
2181
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
2182
- text.setText(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`);
2183
- return text;
2184
- },
2185
- });
141
+ if (isToolEnabled("multi_grep") && (fffService || createGrepTool)) {
142
+ registerMultiGrepTool(
143
+ pi, cwd, fffService,
144
+ createGrepTool ? createGrepTool(cwd) : undefined,
145
+ multiGrepFallback,
146
+ TextComp,
147
+ );
2186
148
  }
2187
149
 
2188
- // ===================================================================
150
+ // ------------------------------------------------------------------
2189
151
  // FFF commands
2190
- // ===================================================================
152
+ // ------------------------------------------------------------------
2191
153
 
2192
- if (_fffModule) {
154
+ if (fffService) {
2193
155
  pi.registerCommand("fff-health", {
2194
156
  description: "Show FFF file finder health and indexer status",
2195
- handler: async (_args: string, ctx: CommandContextLike) => {
2196
- if (!_fffFinder || _fffFinder.isDestroyed) {
2197
- ctx.ui?.notify?.("FFF not initialized", "warning");
157
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
158
+ const fff = fffService!;
159
+ if (!fff.isAvailable) {
160
+ ctx.ui.notify("FFF not initialized", "warning");
2198
161
  return;
2199
162
  }
2200
-
2201
- const health = _fffFinder.healthCheck();
163
+ const finder = fff.getFinder();
164
+ if (!finder) {
165
+ ctx.ui.notify("FFF not initialized", "warning");
166
+ return;
167
+ }
168
+ const health = finder.healthCheck();
2202
169
  if (!health.ok) {
2203
- ctx.ui?.notify?.(`Health check failed: ${health.error}`, "error");
170
+ ctx.ui.notify(`Health check failed: ${health.error}`, "error");
2204
171
  return;
2205
172
  }
2206
-
2207
173
  const h = health.value;
2208
174
  const lines = [
2209
175
  `FFF v${h.version}`,
@@ -2211,37 +177,73 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
2211
177
  `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
2212
178
  `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
2213
179
  `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
2214
- `Partial index: ${_fffPartialIndex ? "yes (scan timed out)" : "no"}`,
180
+ `Partial index: ${fff.partialIndex ? "yes (scan timed out)" : "no"}`,
2215
181
  ];
2216
-
2217
- const progress = _fffFinder.getScanProgress();
182
+ const progress = finder.getScanProgress();
2218
183
  if (progress.ok) {
2219
- lines.push(
2220
- `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
2221
- );
184
+ lines.push(`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`);
2222
185
  }
2223
-
2224
- ctx.ui?.notify?.(lines.join("\n"), "info");
186
+ ctx.ui.notify(lines.join("\n"), "info");
2225
187
  },
2226
188
  });
2227
189
 
2228
190
  pi.registerCommand("fff-rescan", {
2229
191
  description: "Trigger FFF to rescan files",
2230
- handler: async (_args: string, ctx: CommandContextLike) => {
2231
- if (!_fffFinder || _fffFinder.isDestroyed) {
2232
- ctx.ui?.notify?.("FFF not initialized", "warning");
192
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
193
+ const fff = fffService!;
194
+ if (!fff.isAvailable) {
195
+ ctx.ui.notify("FFF not initialized", "warning");
2233
196
  return;
2234
197
  }
2235
-
2236
- const result = _fffFinder.scanFiles();
198
+ const finder = fff.getFinder();
199
+ if (!finder) {
200
+ ctx.ui.notify("FFF not initialized", "warning");
201
+ return;
202
+ }
203
+ const result = finder.scanFiles();
2237
204
  if (!result.ok) {
2238
- ctx.ui?.notify?.(`Rescan failed: ${result.error}`, "error");
205
+ ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
2239
206
  return;
2240
207
  }
2241
-
2242
- _fffPartialIndex = false;
2243
- ctx.ui?.notify?.("FFF rescan triggered", "info");
208
+ fff.partialIndex = false;
209
+ ctx.ui.notify("FFF rescan triggered", "info");
2244
210
  },
2245
211
  });
2246
212
  }
213
+
214
+ // ------------------------------------------------------------------
215
+ // Session lifecycle
216
+ // ------------------------------------------------------------------
217
+
218
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
219
+ if (!fffService) return;
220
+
221
+ // Try dynamic import if sync require failed
222
+ if (!fffService.isModuleLoaded()) {
223
+ const loaded = await fffService.tryLoadModule();
224
+ if (!loaded) return;
225
+ }
226
+
227
+ try {
228
+ await fffService.ensureFinder(ctx.cwd);
229
+ if (fffService.partialIndex) {
230
+ ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
231
+ } else {
232
+ const ui = ctx.ui;
233
+ ui?.setStatus?.("fff", "FFF indexed");
234
+ setTimeout(() => ui?.setStatus?.("fff", undefined), 3000);
235
+ }
236
+ } catch (error: unknown) {
237
+ ctx.ui?.notify?.(`FFF init failed: ${error instanceof Error ? error.message : String(error)}`, "error");
238
+ }
239
+
240
+ // Register FFF-backed @-mention autocomplete
241
+ ctx.ui?.addAutocompleteProvider?.((current) =>
242
+ createFffAutocompleteProvider(current, () => fffService?.getFinder() ?? null),
243
+ );
244
+ });
245
+
246
+ pi.on("session_shutdown", async () => {
247
+ fffService?.destroy();
248
+ });
2247
249
  }