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