@heyhuynhgiabuu/pi-pretty 0.6.1 → 0.6.3
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/package.json +2 -2
- package/src/index.ts +21 -0
- package/src/render.ts +41 -57
- package/src/tools/bash.ts +5 -8
- package/src/tools/find.ts +52 -23
- package/src/tools/grep.ts +27 -31
- package/src/tools/ls.ts +11 -5
- package/src/tools/multi-grep.ts +9 -5
- package/src/tools/read.ts +9 -7
- package/test/bash-rendering.test.ts +3 -4
- package/test/fff-integration.test.ts +57 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
|
|
5
5
|
"author": "huynhgiabuu",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"pi": {
|
|
49
49
|
"extensions": [
|
|
50
|
-
"./
|
|
50
|
+
"./dist/index.js"
|
|
51
51
|
]
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/index.ts
CHANGED
|
@@ -246,4 +246,25 @@ export default function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrettyDeps)
|
|
|
246
246
|
pi.on("session_shutdown", async () => {
|
|
247
247
|
fffService?.destroy();
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
// Fallback padding for SDK-rendered tool bodies. The SDK reads
|
|
251
|
+
// result.content[0].text and slices collapsed output to roughly the first
|
|
252
|
+
// 10 lines, so insert bottom padding inside that visible slice.
|
|
253
|
+
const PADDED_TOOLS = new Set(["read", "grep", "bash"]);
|
|
254
|
+
const RESULT_LEFT_PAD = " ";
|
|
255
|
+
const BOTTOM_PADDING_BY_TOOL: Record<string, number> = { read: 2, grep: 2, bash: 0 };
|
|
256
|
+
pi.on("tool_result", (event, _ctx) => {
|
|
257
|
+
if (!PADDED_TOOLS.has(event.toolName)) return undefined;
|
|
258
|
+
const first = event.content[0];
|
|
259
|
+
if (!first || first.type !== "text") return undefined;
|
|
260
|
+
const lines = first.text.split("\n").map((line) => `${RESULT_LEFT_PAD}${line}`);
|
|
261
|
+
if (lines.length === 0) return undefined;
|
|
262
|
+
|
|
263
|
+
const padCount = BOTTOM_PADDING_BY_TOOL[event.toolName] ?? 0;
|
|
264
|
+
lines.push(...Array.from({ length: padCount }, () => RESULT_LEFT_PAD));
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text" as const, text: lines.join("\n") }, ...event.content.slice(1)],
|
|
268
|
+
};
|
|
269
|
+
});
|
|
249
270
|
}
|
package/src/render.ts
CHANGED
|
@@ -184,37 +184,21 @@ export function renderToolError(error: string, theme: ThemeLike): string {
|
|
|
184
184
|
export async function renderFileContent(
|
|
185
185
|
content: string,
|
|
186
186
|
filePath: string,
|
|
187
|
-
offset =
|
|
187
|
+
offset = 0,
|
|
188
188
|
maxLines = MAX_PREVIEW_LINES,
|
|
189
189
|
width?: number,
|
|
190
190
|
): Promise<string> {
|
|
191
191
|
const normalizedContent = normalizeLineEndings(content);
|
|
192
192
|
const lines = normalizedContent.split("\n");
|
|
193
|
-
const total = lines.length;
|
|
194
193
|
const show = lines.slice(0, maxLines);
|
|
195
194
|
const lg = detectLang(filePath);
|
|
196
195
|
const hl = await hlBlock(show.join("\n"), lg);
|
|
197
196
|
|
|
198
197
|
const tw = width ?? termWidth();
|
|
199
|
-
const startLine = offset;
|
|
200
|
-
const endLine = startLine + show.length - 1;
|
|
201
|
-
const nw = Math.max(3, String(endLine).length);
|
|
202
|
-
const gw = nw + 3;
|
|
203
|
-
const cw = Math.max(1, tw - gw);
|
|
204
198
|
|
|
205
199
|
const out: string[] = [];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
for (let i = 0; i < hl.length; i++) {
|
|
209
|
-
const ln = startLine + i;
|
|
210
|
-
const code = hl[i] ?? show[i] ?? "";
|
|
211
|
-
const display = truncateToWidth(code, cw, `${FG_DIM}›`);
|
|
212
|
-
out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
out.push(rule(tw));
|
|
216
|
-
if (total > maxLines) {
|
|
217
|
-
out.push(`${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`);
|
|
200
|
+
for (const line of hl) {
|
|
201
|
+
out.push(truncateToWidth(line ?? "", Math.max(1, tw), `${FG_DIM}›`));
|
|
218
202
|
}
|
|
219
203
|
return out.join("\n");
|
|
220
204
|
}
|
|
@@ -271,44 +255,44 @@ export function renderTree(text: string, _basePath: string): string {
|
|
|
271
255
|
return out.join("\n");
|
|
272
256
|
}
|
|
273
257
|
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
// Find — grouped file list
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
export function renderFindResults(text: string): string {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Find — grouped file list (plain, no tree characters or icons)
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
export function renderFindResults(text: string, theme?: ThemeLike): string {
|
|
263
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
264
|
+
if (!lines.length) return theme ? theme.fg("dim", "(no matches)") : `${FG_DIM}(no matches)${RST}`;
|
|
265
|
+
|
|
266
|
+
const groups = new Map<string, string[]>();
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
const trimmed = line.trim();
|
|
269
|
+
const dir = dirname(trimmed) || ".";
|
|
270
|
+
const file = basename(trimmed);
|
|
271
|
+
if (!groups.has(dir)) groups.set(dir, []);
|
|
272
|
+
const bucket = groups.get(dir);
|
|
273
|
+
if (bucket) bucket.push(file);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const out: string[] = [];
|
|
277
|
+
let count = 0;
|
|
278
|
+
|
|
279
|
+
for (const [dir, files] of groups) {
|
|
280
|
+
if (count > 0) out.push("");
|
|
281
|
+
const dirColored = theme ? theme.fg("accent", theme.bold(`${dir}/`)) : `${FG_BLUE}\x1b[1m${dir}/${RST}`;
|
|
282
|
+
out.push(dirColored);
|
|
283
|
+
for (let i = 0; i < files.length; i++) {
|
|
284
|
+
if (count >= MAX_PREVIEW_LINES) {
|
|
285
|
+
const more = theme ? theme.fg("dim", `… ${lines.length - count} more files`) : `${FG_DIM}… ${lines.length - count} more files${RST}`;
|
|
286
|
+
out.push(` ${more}`);
|
|
287
|
+
return out.join("\n");
|
|
288
|
+
}
|
|
289
|
+
out.push(` ${files[i]}`);
|
|
290
|
+
count++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return out.join("\n");
|
|
295
|
+
}
|
|
312
296
|
|
|
313
297
|
// ---------------------------------------------------------------------------
|
|
314
298
|
// Grep — highlighted matches with line numbers
|
package/src/tools/bash.ts
CHANGED
|
@@ -37,18 +37,15 @@ export function registerBashTool(
|
|
|
37
37
|
}
|
|
38
38
|
}),
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
41
41
|
resolveBaseBackground(theme);
|
|
42
42
|
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
43
|
-
const t = typeof args.timeout === "number" ? ` ${theme.fg("muted", `(${args.timeout}s
|
|
43
|
+
const t = typeof args.timeout === "number" ? ` ${theme.fg("muted", `(timeout ${args.timeout}s)`)}` : "";
|
|
44
44
|
const tw = termWidth() || 80;
|
|
45
45
|
const rawCmd = String(args.command ?? "");
|
|
46
|
-
const cmd = (()
|
|
47
|
-
const ml = Math.max(1, tw - 20);
|
|
48
|
-
return rawCmd.length > ml ? rawCmd.slice(0, ml) + "\u2026" : rawCmd;
|
|
49
|
-
})();
|
|
46
|
+
const cmd = rawCmd.length === 0 ? theme.fg("toolOutput", "...") : (rawCmd.length > tw - 20 ? rawCmd.slice(0, Math.max(1, tw - 20)) + "…" : rawCmd);
|
|
50
47
|
const toolWidth = ctx.expanded ? undefined : tw;
|
|
51
|
-
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold(
|
|
48
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold(`$ ${cmd}`))}${t}`, ctx.isError ? BG_ERROR : undefined, toolWidth));
|
|
52
49
|
return text;
|
|
53
50
|
},
|
|
54
51
|
|
|
@@ -80,7 +77,7 @@ export function registerBashTool(
|
|
|
80
77
|
if (!output.trim()) return fillToolBackground(header, bg, w);
|
|
81
78
|
const max = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
82
79
|
const show = output.split("\n").slice(0, max);
|
|
83
|
-
const out = [header, rule(w), ...show.map((l: string) => ` ${l}`)
|
|
80
|
+
const out = [header, rule(w), ...show.map((l: string) => ` ${l}`)];
|
|
84
81
|
if (lineCount > max) out.push(`${FG_DIM} \u2026 ${lineCount - max} more lines${RST}`);
|
|
85
82
|
return fillToolBackground(out.join("\n"), bg, w);
|
|
86
83
|
};
|
package/src/tools/find.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* pi-pretty: find tool -- FFF-backed file search with SDK fallback. */
|
|
2
2
|
|
|
3
3
|
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isAbsolute, relative as toRelative } from "node:path";
|
|
4
5
|
import type { SdkToolDef, FindDetails, FffServiceLike, FileItem, TextContent, ThemeLike, RenderCtxLike, ComponentLike } from "../types.js";
|
|
5
6
|
import { resolveBaseBackground, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
7
|
import { shortPath } from "../helpers.js";
|
|
@@ -32,24 +33,35 @@ export function registerFindTool(
|
|
|
32
33
|
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
33
34
|
const pattern = String((params as any).pattern ?? "");
|
|
34
35
|
const path = (params as any).path ? String((params as any).path) : undefined;
|
|
35
|
-
const limit =
|
|
36
|
+
const limit = (params as any).limit;
|
|
37
|
+
const glob = (params as any).glob;
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
const fff = fffService?.isAvailable ? fffService.getFinder() : null;
|
|
40
|
+
if (fff) {
|
|
38
41
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
const notices: string[] = [];
|
|
46
|
-
if (fffService.partialIndex) notices.push("Warning: partial file index");
|
|
47
|
-
if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
48
|
-
if (searchResult.value.totalMatched > items.length) notices.push(`${searchResult.value.totalMatched} total matches`);
|
|
49
|
-
const text = appendNotices(items.map((i) => i.relativePath).join("\n"), notices);
|
|
50
|
-
return { content: [{ type: "text" as const, text }], details: { _type: "findResult", text, pattern, matchCount: items.length } as FindDetails };
|
|
42
|
+
const effectiveLimit = Math.max(1, typeof limit === "number" ? limit : 100);
|
|
43
|
+
const basePathResult = fff.getBasePath();
|
|
44
|
+
const basePath = basePathResult.ok ? basePathResult.value : null;
|
|
45
|
+
let cleanPath = path ?? "";
|
|
46
|
+
if (cleanPath && isAbsolute(cleanPath) && basePath) {
|
|
47
|
+
cleanPath = toRelative(basePath, cleanPath) || "";
|
|
51
48
|
}
|
|
52
|
-
|
|
49
|
+
cleanPath = cleanPath.replace(/\/$/, "");
|
|
50
|
+
const cleanPattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
|
51
|
+
const globPattern = cleanPath
|
|
52
|
+
? `${cleanPath}/**/${cleanPattern}`
|
|
53
|
+
: `**/${cleanPattern}`;
|
|
54
|
+
const searchResult = fff.glob(globPattern, { pageSize: effectiveLimit });
|
|
55
|
+
if (searchResult.ok) {
|
|
56
|
+
const items: FileItem[] = searchResult.value.items.slice(0, effectiveLimit);
|
|
57
|
+
const notices: string[] = [];
|
|
58
|
+
if (fffService?.partialIndex) notices.push("Warning: partial file index");
|
|
59
|
+
if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
60
|
+
if (searchResult.value.totalMatched > items.length) notices.push(`${searchResult.value.totalMatched} total matches`);
|
|
61
|
+
const paths = items.map((i) => i.relativePath).join("\n");
|
|
62
|
+
return { content: [{ type: "text" as const, text: paths }], details: { _type: "findResult", text: paths, pattern, matchCount: items.length, notices } as FindDetails };
|
|
63
|
+
}
|
|
64
|
+
} catch { /* fall through to SDK */ }
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
const result = await sdkTool.execute(tid, params, sig, undefined, ctx) as Result;
|
|
@@ -58,11 +70,20 @@ export function registerFindTool(
|
|
|
58
70
|
return result;
|
|
59
71
|
}),
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
62
74
|
resolveBaseBackground(theme);
|
|
63
75
|
const text = ctx.lastComponent ?? new TextComp!("", 0, 0);
|
|
64
|
-
const
|
|
65
|
-
|
|
76
|
+
const pattern = args.pattern === null || args.pattern === undefined ? "" : String(args.pattern);
|
|
77
|
+
const path = args.path === null || args.path === undefined ? "<missing>" : shortPath(cwd, home, String(args.path));
|
|
78
|
+
const limit = args.limit;
|
|
79
|
+
const glob = args.glob;
|
|
80
|
+
const findLabel = theme.fg("toolTitle", theme.bold("find"));
|
|
81
|
+
const patternPart = pattern ? theme.fg("accent", pattern) : "";
|
|
82
|
+
const inPart = theme.fg("dim", " in ");
|
|
83
|
+
const pathPart = theme.fg("toolOutput", path);
|
|
84
|
+
const limitPart = limit !== undefined && limit !== null ? theme.fg("dim", ` limit ${limit}`) : "";
|
|
85
|
+
const out = `${findLabel} ${patternPart}${inPart}${pathPart}${limitPart}`;
|
|
86
|
+
text.setText(fillToolBackground(` \n ${out}`, ctx.isError ? BG_ERROR : undefined));
|
|
66
87
|
return text;
|
|
67
88
|
},
|
|
68
89
|
|
|
@@ -71,13 +92,21 @@ export function registerFindTool(
|
|
|
71
92
|
const text = ctx.lastComponent ?? new TextComp!("", 0, 0);
|
|
72
93
|
if (ctx.isError) { text.setText(renderToolError(getText(result) || "Error", theme)); return text; }
|
|
73
94
|
const d = result.details as FindDetails | undefined;
|
|
74
|
-
if (d?._type === "findResult"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
if (d?._type === "findResult") {
|
|
96
|
+
if (!d.text.trim()) {
|
|
97
|
+
const notices = (d as any).notices as string[] | undefined;
|
|
98
|
+
const noticeStr = notices?.length ? `\n ${theme.fg("warning", `[${notices.join(". ")}]`)}` : "";
|
|
99
|
+
text.setText(fillToolBackground(` \n ${theme.fg("dim", "0 files")}${noticeStr}\n `));
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
const rendered = renderFindResults(d.text, theme).split("\n").map(l => ` ${l}`).join("\n");
|
|
103
|
+
const notices = (d as any).notices as string[] | undefined;
|
|
104
|
+
const noticeStr = notices?.length ? `\n ${theme.fg("warning", `[${notices.join(". ")}]`)}` : "";
|
|
105
|
+
text.setText(fillToolBackground(` \n ${theme.fg("dim", `${d.matchCount} files`)}${renderToolMetrics(result)}\n${rendered}${noticeStr}\n `));
|
|
106
|
+
return text;
|
|
78
107
|
}
|
|
79
108
|
const fc = result.content?.[0];
|
|
80
|
-
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "
|
|
109
|
+
text.setText(fillToolBackground(` \n ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "0 files")}\n `));
|
|
81
110
|
return text;
|
|
82
111
|
},
|
|
83
112
|
} as unknown as ToolDefinition<any, any, any>);
|
package/src/tools/grep.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { SdkToolDef, GrepDetails, FffServiceWithCursor, TextContent, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
|
-
import {
|
|
5
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { MAX_PREVIEW_LINES, BG_ERROR, resolveBaseBackground } from "../config.js";
|
|
6
7
|
import { shortPath, normalizeLineEndings } from "../helpers.js";
|
|
7
8
|
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
8
|
-
import { renderToolError,
|
|
9
|
+
import { renderToolError, fillToolBackground } from "../render.js";
|
|
9
10
|
import { fffFormatGrepText } from "../fff-helpers.js";
|
|
10
11
|
|
|
12
|
+
const invalidArg = "<missing>";
|
|
13
|
+
|
|
11
14
|
type Result = AgentToolResult<Record<string, unknown>>;
|
|
12
15
|
|
|
13
16
|
export function registerGrepTool(
|
|
@@ -70,9 +73,18 @@ export function registerGrepTool(
|
|
|
70
73
|
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
71
74
|
resolveBaseBackground(theme);
|
|
72
75
|
const text = ctx.lastComponent ?? new T("", 0, 0);
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
+
const pattern = args.pattern === null || args.pattern === undefined ? invalidArg : String(args.pattern);
|
|
77
|
+
const path = args.path === null || args.path === undefined ? invalidArg : shortPath(cwd, home, String(args.path));
|
|
78
|
+
const glob = args.glob;
|
|
79
|
+
const limit = args.limit;
|
|
80
|
+
const literal = args.literal === true;
|
|
81
|
+
const caseInsensitive = args.caseInsensitive === true || args.ignoreCase === true;
|
|
82
|
+
let out = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${pattern || ""}/`)}${theme.fg("toolOutput", ` in ${path}`)}`;
|
|
83
|
+
if (glob) out += theme.fg("dim", ` (${String(glob)})`);
|
|
84
|
+
if (limit !== undefined && limit !== null) out += theme.fg("dim", ` limit ${limit}`);
|
|
85
|
+
if (literal) out += theme.fg("dim", ` (literal)`);
|
|
86
|
+
if (caseInsensitive) out += theme.fg("dim", ` (case-insensitive)`);
|
|
87
|
+
text.setText(fillToolBackground(`\n ${out}`, ctx.isError ? BG_ERROR : undefined));
|
|
76
88
|
return text;
|
|
77
89
|
},
|
|
78
90
|
|
|
@@ -85,38 +97,22 @@ export function registerGrepTool(
|
|
|
85
97
|
const lines = d.text.split("\n");
|
|
86
98
|
const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
|
|
87
99
|
const show = lines.slice(0, maxShow);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
// Build highlight regex from pattern
|
|
91
|
-
let hlRe: RegExp | null = null;
|
|
92
|
-
try { hlRe = new RegExp(`(${d.pattern})`, "gi"); } catch {}
|
|
93
|
-
|
|
100
|
+
const remaining = lines.length - maxShow;
|
|
94
101
|
const out: string[] = [];
|
|
95
|
-
let currentFile = "";
|
|
96
102
|
for (const line of show) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
out.push(` ${theme.fg("accent", theme.bold(file))}`);
|
|
103
|
-
currentFile = file;
|
|
104
|
-
}
|
|
105
|
-
let display = content;
|
|
106
|
-
if (hlRe) display = content.replace(hlRe, (m) => `${RST}${theme.fg("warning", theme.bold(m))}${RST}`);
|
|
107
|
-
const padded = `${FG_LNUM}${String(lineNo).padStart(nw)}${RST} ${FG_RULE}│${RST} ${display}${RST}`;
|
|
108
|
-
out.push(` ${padded}`);
|
|
109
|
-
} else if (line.trim()) {
|
|
110
|
-
out.push(` ${FG_DIM} ${line.trim()}${RST}`);
|
|
111
|
-
}
|
|
103
|
+
if (!line) continue;
|
|
104
|
+
out.push(theme.fg("toolOutput", line));
|
|
105
|
+
}
|
|
106
|
+
if (remaining > 0) {
|
|
107
|
+
out.push(theme.fg("muted", `… (${remaining} more ${remaining === 1 ? "line" : "lines"}, ${keyHint("app.tools.expand", "to expand")})`));
|
|
112
108
|
}
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
text.setText(fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}\n${preview}${more}`));
|
|
109
|
+
const body = out.map((l) => ` ${l}`).join("\n") + "\n\n";
|
|
110
|
+
text.setText(fillToolBackground(body, ctx.isError ? BG_ERROR : undefined));
|
|
116
111
|
return text;
|
|
117
112
|
}
|
|
118
113
|
const fc = result.content?.[0];
|
|
119
|
-
|
|
114
|
+
const fallback = fc && "text" in fc ? String(fc.text).slice(0, 120) : "no matches";
|
|
115
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fallback)}`, ctx.isError ? BG_ERROR : undefined));
|
|
120
116
|
return text;
|
|
121
117
|
},
|
|
122
118
|
} as unknown as ToolDefinition<any, any, any>);
|
package/src/tools/ls.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { SdkToolDef, LsDetails, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
5
|
import { resolveBaseBackground, termWidth, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
|
+
import { shortPath } from "../helpers.js";
|
|
6
7
|
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
7
8
|
import { renderTree, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
8
9
|
|
|
@@ -10,11 +11,12 @@ type Result = AgentToolResult<Record<string, unknown>>;
|
|
|
10
11
|
|
|
11
12
|
export function registerLsTool(
|
|
12
13
|
pi: ExtensionAPI,
|
|
13
|
-
|
|
14
|
+
cwd: string,
|
|
14
15
|
_fffService: unknown,
|
|
15
16
|
sdkTool: SdkToolDef,
|
|
16
17
|
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
17
18
|
): void {
|
|
19
|
+
const home = process.env.HOME ?? "";
|
|
18
20
|
const TC = TextComp ?? (() => {
|
|
19
21
|
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } };
|
|
20
22
|
return Text;
|
|
@@ -36,10 +38,14 @@ export function registerLsTool(
|
|
|
36
38
|
|
|
37
39
|
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
38
40
|
resolveBaseBackground(theme);
|
|
39
|
-
|
|
40
41
|
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
41
|
-
const
|
|
42
|
-
|
|
42
|
+
const rawPath = args.path;
|
|
43
|
+
const path = rawPath === null || rawPath === undefined || String(rawPath).length === 0 ? "" : shortPath(cwd, home, String(rawPath));
|
|
44
|
+
const limit = args.limit;
|
|
45
|
+
let out = theme.fg("toolTitle", theme.bold("ls"));
|
|
46
|
+
if (path) out += ` ${theme.fg("accent", path)}`;
|
|
47
|
+
if (limit !== undefined && limit !== null) out += theme.fg("toolOutput", ` (limit ${limit})`);
|
|
48
|
+
text.setText(fillToolBackground(` \n ${out}`, ctx.isError ? BG_ERROR : undefined));
|
|
43
49
|
return text;
|
|
44
50
|
},
|
|
45
51
|
|
|
@@ -51,7 +57,7 @@ export function registerLsTool(
|
|
|
51
57
|
const d = result.details as LsDetails | undefined;
|
|
52
58
|
if (d?._type === "lsResult" && d.text) {
|
|
53
59
|
const rendered = renderTree(d.text, d.path).split("\n").map(l => ` ${l}`).join("\n");
|
|
54
|
-
text.setText(fillToolBackground(` ${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}`));
|
|
60
|
+
text.setText(fillToolBackground(` \n ${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}\n `));
|
|
55
61
|
return text;
|
|
56
62
|
}
|
|
57
63
|
const fc = result.content?.[0];
|
package/src/tools/multi-grep.ts
CHANGED
|
@@ -136,12 +136,16 @@ export function registerMultiGrepTool(
|
|
|
136
136
|
|
|
137
137
|
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
138
138
|
resolveBaseBackground(theme);
|
|
139
|
-
|
|
140
139
|
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
140
|
+
const patterns: string[] = Array.isArray(args.patterns) ? args.patterns.map((p: unknown) => String(p)) : [];
|
|
141
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
142
|
+
const path = args.path === null || args.path === undefined ? "<missing>" : shortPath(cwd, home, String(args.path));
|
|
143
|
+
const literal = args.literal === true;
|
|
144
|
+
const patternStr = patterns.length === 0 ? "" : patterns.length === 1 ? patterns[0]! : patterns.length === 2 ? `${patterns[0]}|${patterns[1]}` : `${patterns[0]}|${patterns[1]}|+${patterns.length - 2}`;
|
|
145
|
+
let out = `${theme.fg("toolTitle", theme.bold("mgrep"))} ${theme.fg("accent", `/${patternStr || ""}/`)}${theme.fg("toolOutput", ` in ${path}`)}`;
|
|
146
|
+
if (literal) out += theme.fg("dim", ` (literal)`);
|
|
147
|
+
if (limit !== undefined) out += theme.fg("dim", ` limit ${limit}`);
|
|
148
|
+
text.setText(fillToolBackground(`\n ${out}`, ctx.isError ? BG_ERROR : undefined));
|
|
145
149
|
return text;
|
|
146
150
|
},
|
|
147
151
|
|
package/src/tools/read.ts
CHANGED
|
@@ -57,9 +57,7 @@ export function registerReadTool(
|
|
|
57
57
|
resolveBaseBackground(theme);
|
|
58
58
|
|
|
59
59
|
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
60
|
-
|
|
61
|
-
const off = typeof args.offset === "number" ? `:${args.offset}` : "";
|
|
62
|
-
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", p)}${theme.fg("dim", off)}`, ctx.isError ? BG_ERROR : undefined));
|
|
60
|
+
text.setText("");
|
|
63
61
|
return text;
|
|
64
62
|
},
|
|
65
63
|
|
|
@@ -103,7 +101,10 @@ export function registerReadTool(
|
|
|
103
101
|
const gw = nw + 3;
|
|
104
102
|
const cw = Math.max(1, tw - gw);
|
|
105
103
|
|
|
106
|
-
const
|
|
104
|
+
const p2 = shortPath(cwd, home, String(d.filePath ?? ""));
|
|
105
|
+
const off2 = typeof d.offset === "number" ? `:${d.offset}` : "";
|
|
106
|
+
const header = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", p2)}${theme.fg("dim", off2)}`;
|
|
107
|
+
const out: string[] = ["", ` ${header}`];
|
|
107
108
|
out.push(` ${FG_RULE}${"─".repeat(tw - 2)}${RST}`);
|
|
108
109
|
for (let i = 0; i < show.length; i++) {
|
|
109
110
|
const ln = (d.offset || 0) + i + 1;
|
|
@@ -112,10 +113,10 @@ export function registerReadTool(
|
|
|
112
113
|
const lineNo = String(ln);
|
|
113
114
|
out.push(` ${FG_LNUM}${" ".repeat(Math.max(0, nw - lineNo.length))}${lineNo}${RST} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
114
115
|
}
|
|
115
|
-
out.push(` ${FG_RULE}${"─".repeat(tw - 2)}${RST}`);
|
|
116
116
|
if (total > maxShow) {
|
|
117
117
|
out.push(` ${FG_DIM} … ${total - maxShow} more lines (${total} total)${RST}`);
|
|
118
118
|
}
|
|
119
|
+
out.push("");
|
|
119
120
|
const rendered = out.join("\n");
|
|
120
121
|
text.setText(fillToolBackground(rendered));
|
|
121
122
|
(ctx as any).state._rt = rendered;
|
|
@@ -123,8 +124,9 @@ export function registerReadTool(
|
|
|
123
124
|
// Async syntax highlighting via Shiki
|
|
124
125
|
renderFileContent(d.content, d.filePath, d.offset || 0, maxShow, tw).then(hl => {
|
|
125
126
|
const padded = hl.split("\n").map(l => ` ${l}`).join("\n");
|
|
126
|
-
|
|
127
|
-
(
|
|
127
|
+
const rendered = `\n ${header}\n${padded}\n`;
|
|
128
|
+
text.setText(fillToolBackground(rendered));
|
|
129
|
+
(ctx as any).state._rt = rendered;
|
|
128
130
|
}).catch(() => {});
|
|
129
131
|
|
|
130
132
|
return text;
|
|
@@ -130,8 +130,8 @@ describe("bash renderCall expansion", () => {
|
|
|
130
130
|
invalidate: () => {},
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
expect(collapsed.getText()).toContain("5s
|
|
134
|
-
expect(expanded.getText()).toContain("5s
|
|
133
|
+
expect(collapsed.getText()).toContain("(timeout 5s)");
|
|
134
|
+
expect(expanded.getText()).toContain("(timeout 5s)");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
it("truncates ANSI tool headers that exceed the terminal width", () => {
|
|
@@ -184,7 +184,7 @@ describe("bash renderCall expansion", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
const lines = stripAnsi(rendered.getText()).split("\n");
|
|
187
|
-
expect(lines[1]).toMatch(/^
|
|
187
|
+
expect(lines[1]).toMatch(/^ \$ false/);
|
|
188
188
|
});
|
|
189
189
|
});
|
|
190
190
|
|
|
@@ -211,7 +211,6 @@ describe("bash renderCall expansion", () => {
|
|
|
211
211
|
expect(lines[3]).toMatch(/^ /);
|
|
212
212
|
expect(lines[3].trim()).toBe("");
|
|
213
213
|
expect(lines[4]).toMatch(/^ second error/);
|
|
214
|
-
expect(lines[5]).toMatch(/^─+$/);
|
|
215
214
|
});
|
|
216
215
|
});
|
|
217
216
|
|
|
@@ -249,13 +249,21 @@ function mkFinder(overrides?: Record<string, any>) {
|
|
|
249
249
|
return {
|
|
250
250
|
isDestroyed: false,
|
|
251
251
|
waitForScan: vi.fn().mockResolvedValue({ ok: true, value: true }),
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
fileSearch: vi.fn().mockReturnValue({
|
|
253
|
+
ok: true,
|
|
254
|
+
value: {
|
|
255
|
+
items: [{ relativePath: "src/index.ts" }, { relativePath: "src/main.ts" }],
|
|
256
|
+
totalMatched: 2,
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
glob: vi.fn().mockReturnValue({
|
|
260
|
+
ok: true,
|
|
261
|
+
value: {
|
|
262
|
+
items: [{ relativePath: "src/index.ts" }, { relativePath: "src/main.ts" }],
|
|
263
|
+
totalMatched: 2,
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
getBasePath: vi.fn().mockReturnValue({ ok: true, value: "/Users/test/proj" }),
|
|
259
267
|
grep: vi.fn().mockReturnValue({
|
|
260
268
|
ok: true,
|
|
261
269
|
value: {
|
|
@@ -489,52 +497,54 @@ describe("piPrettyExtension integration", () => {
|
|
|
489
497
|
expect(r.content[0].text).toContain("src/index.ts");
|
|
490
498
|
});
|
|
491
499
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
500
|
+
it("falls back to SDK on FFF { ok: false }", async () => {
|
|
501
|
+
await loadWithFFF({
|
|
502
|
+
glob: vi.fn().mockReturnValue({ ok: false, error: "fail" }),
|
|
503
|
+
});
|
|
504
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
505
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("falls back to SDK on FFF throw", async () => {
|
|
509
|
+
await loadWithFFF({
|
|
510
|
+
glob: vi.fn().mockImplementation(() => { throw new Error("crash"); }),
|
|
511
|
+
});
|
|
512
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
|
|
513
|
+
expect(findExec).toHaveBeenCalledOnce();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("respects limit param", async () => {
|
|
517
|
+
const glob = vi.fn().mockReturnValue({
|
|
518
|
+
ok: true,
|
|
519
|
+
value: { items: Array.from({ length: 50 }, (_, i) => ({ relativePath: `f${i}.ts` })), totalMatched: 50 },
|
|
520
|
+
});
|
|
521
|
+
await loadWithFFF({ glob });
|
|
522
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts", limit: 5 }, null, null, {});
|
|
523
|
+
expect(glob).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ pageSize: 5 }));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("combines path and pattern into glob pattern", async () => {
|
|
527
|
+
const glob = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0 } });
|
|
528
|
+
await loadWithFFF({ glob });
|
|
529
|
+
await tools.get("find")!.execute("t1", { pattern: "*.ts", path: "src" }, null, null, {});
|
|
530
|
+
expect(glob).toHaveBeenCalledWith("src/**/*.ts", expect.any(Object));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("shows partial-index + limit notices", async () => {
|
|
526
534
|
await loadWithFFF({
|
|
527
535
|
waitForScan: vi.fn().mockResolvedValue({ ok: true, value: false }),
|
|
528
|
-
|
|
536
|
+
glob: vi.fn().mockReturnValue({
|
|
529
537
|
ok: true,
|
|
530
538
|
value: { items: Array.from({ length: 200 }, (_, i) => ({ relativePath: `f${i}` })), totalMatched: 500 },
|
|
531
539
|
}),
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
expect(
|
|
536
|
-
expect(
|
|
540
|
+
});
|
|
541
|
+
const result = await tools.get("find")!.execute("t1", { pattern: "*", limit: 200 }, null, null, {});
|
|
542
|
+
const notices = (result.details as any).notices as string[];
|
|
543
|
+
expect(notices).toContain("Warning: partial file index");
|
|
544
|
+
expect(notices).toContain("200 limit reached");
|
|
545
|
+
expect(notices).toContain("500 total matches");
|
|
537
546
|
});
|
|
547
|
+
// ---- grep: FFF path ------------------------------------------------
|
|
538
548
|
});
|
|
539
549
|
|
|
540
550
|
// ---- grep: FFF path ------------------------------------------------
|