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