@heyhuynhgiabuu/pi-pretty 0.6.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.6.2",
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
- "./src/index.ts"
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,36 +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 = 1,
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
- out.push(rule(tw));
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
- if (total > maxLines) {
216
- 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}›`));
217
202
  }
218
203
  return out.join("\n");
219
204
  }
@@ -270,44 +255,44 @@ export function renderTree(text: string, _basePath: string): string {
270
255
  return out.join("\n");
271
256
  }
272
257
 
273
- // ---------------------------------------------------------------------------
274
- // Find — grouped file list with icons
275
- // ---------------------------------------------------------------------------
276
-
277
- export function renderFindResults(text: string): string {
278
- const lines = text.trim().split("\n").filter(Boolean);
279
- if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
280
-
281
- const groups = new Map<string, string[]>();
282
- for (const line of lines) {
283
- const trimmed = line.trim();
284
- const dir = dirname(trimmed) || ".";
285
- const file = basename(trimmed);
286
- if (!groups.has(dir)) groups.set(dir, []);
287
- const bucket = groups.get(dir);
288
- if (bucket) bucket.push(file);
289
- }
290
-
291
- const out: string[] = [];
292
- let count = 0;
293
-
294
- for (const [dir, files] of groups) {
295
- if (count > 0) out.push("");
296
- out.push(`${dirIcon()}${FG_BLUE}\x1b[1m${dir}/${RST}`);
297
- for (let i = 0; i < files.length; i++) {
298
- if (count >= MAX_PREVIEW_LINES) {
299
- out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
300
- return out.join("\n");
301
- }
302
- const isLast = i === files.length - 1;
303
- const prefix = isLast ? "└── " : "├── ";
304
- out.push(` ${FG_RULE}${prefix}${RST}${files[i]}`);
305
- count++;
306
- }
307
- }
308
-
309
- return out.join("\n");
310
- }
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
+ }
311
296
 
312
297
  // ---------------------------------------------------------------------------
313
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
- renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
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 timeout)`)}` : "";
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("bash"))} ${theme.fg("accent", cmd)}${t}`, ctx.isError ? BG_ERROR : undefined, toolWidth));
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
 
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 = typeof (params as any).limit === "number" ? (params as any).limit : 200;
36
+ const limit = (params as any).limit;
37
+ const glob = (params as any).glob;
36
38
 
37
- if (fffService?.isAvailable) {
39
+ const fff = fffService?.isAvailable ? fffService.getFinder() : null;
40
+ if (fff) {
38
41
  try {
39
- const fff = fffService.getFinder();
40
- if (!fff) throw new Error("FFF finder not available");
41
- const effectiveLimit = Math.max(1, limit);
42
- const searchResult = fff.fileSearch(path ? `${path} ${pattern}` : pattern, { pageSize: effectiveLimit });
43
- if (searchResult.ok) {
44
- const items: FileItem[] = searchResult.value.items.slice(0, effectiveLimit);
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
- } catch { /* fall through */ }
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
- renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
73
+ renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
62
74
  resolveBaseBackground(theme);
63
75
  const text = ctx.lastComponent ?? new TextComp!("", 0, 0);
64
- const p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
65
- text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", String(args.pattern ?? ""))}${p}`, ctx.isError ? BG_ERROR : undefined));
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" && d.text) {
75
- const rendered = renderFindResults(d.text).split("\n").map(l => ` ${l}`).join("\n");
76
- text.setText(fillToolBackground(` ${FG_DIM}${d.matchCount} files${RST}${renderToolMetrics(result)}\n${rendered}`));
77
- return text;
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) : "found")}`));
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 { resolveBaseBackground, MAX_PREVIEW_LINES, BG_ERROR, FG_DIM, FG_LNUM, FG_RULE, RST } from "../config.js";
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, renderToolMetrics, fillToolBackground } from "../render.js";
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 p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
74
- const lit = args.literal ? ` ${theme.fg("dim", "[literal]")}` : "";
75
- text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", String(args.pattern ?? ""))}${p}${lit}`, ctx.isError ? BG_ERROR : undefined));
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 nw = Math.max(3, 5);
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
- const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
98
- if (fileMatch) {
99
- const [, file, lineNo, content] = fileMatch;
100
- if (file !== currentFile) {
101
- if (currentFile) out.push("");
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 preview = out.join("\n");
114
- const more = lines.length > maxShow ? `\n${FG_DIM} ... ${lines.length - maxShow} more lines${RST}` : "";
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
- text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "no matches")}`));
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
- _cwd: string,
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 p = String(args.path ?? ".");
42
- text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", p)}`, ctx.isError ? BG_ERROR : undefined));
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];
@@ -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 pats = (Array.isArray(args.patterns) ? args.patterns.map(String) : [String(args.patterns ?? "")]);
142
- const label = pats.length > 2 ? `${pats[0]}, ${pats[1]}, +${pats.length - 2}` : pats.join(", ");
143
- const p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
144
- text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("mgrep"))} ${theme.fg("accent", label)}${p}`, ctx.isError ? BG_ERROR : undefined));
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
 
@@ -130,8 +130,8 @@ describe("bash renderCall expansion", () => {
130
130
  invalidate: () => {},
131
131
  });
132
132
 
133
- expect(collapsed.getText()).toContain("5s timeout");
134
- expect(expanded.getText()).toContain("5s timeout");
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(/^ bash false/);
187
+ expect(lines[1]).toMatch(/^ \$ false/);
188
188
  });
189
189
  });
190
190
 
@@ -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
- fileSearch: vi.fn().mockReturnValue({
253
- ok: true,
254
- value: {
255
- items: [{ relativePath: "src/index.ts" }, { relativePath: "src/main.ts" }],
256
- totalMatched: 2,
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
- it("falls back to SDK on FFF { ok: false }", async () => {
493
- await loadWithFFF({
494
- fileSearch: vi.fn().mockReturnValue({ ok: false, error: "fail" }),
495
- });
496
- await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
497
- expect(findExec).toHaveBeenCalledOnce();
498
- });
499
-
500
- it("falls back to SDK on FFF throw", async () => {
501
- await loadWithFFF({
502
- fileSearch: vi.fn().mockImplementation(() => { throw new Error("crash"); }),
503
- });
504
- await tools.get("find")!.execute("t1", { pattern: "*.ts" }, null, null, {});
505
- expect(findExec).toHaveBeenCalledOnce();
506
- });
507
-
508
- it("respects limit param", async () => {
509
- const fileSearch = vi.fn().mockReturnValue({
510
- ok: true,
511
- value: { items: Array.from({ length: 50 }, (_, i) => ({ relativePath: `f${i}.ts` })), totalMatched: 50 },
512
- });
513
- await loadWithFFF({ fileSearch });
514
- await tools.get("find")!.execute("t1", { pattern: "*.ts", limit: 5 }, null, null, {});
515
- expect(fileSearch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ pageSize: 5 }));
516
- });
517
-
518
- it("includes path in search query", async () => {
519
- const fileSearch = vi.fn().mockReturnValue({ ok: true, value: { items: [], totalMatched: 0 } });
520
- await loadWithFFF({ fileSearch });
521
- await tools.get("find")!.execute("t1", { pattern: "*.ts", path: "src/" }, null, null, {});
522
- expect(fileSearch).toHaveBeenCalledWith("src/ *.ts", expect.any(Object));
523
- });
524
-
525
- it("shows partial-index + limit notices", async () => {
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
- fileSearch: vi.fn().mockReturnValue({
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
- const text = (await tools.get("find")!.execute("t1", { pattern: "*" }, null, null, {})).content[0].text;
534
- expect(text).toContain("partial file index");
535
- expect(text).toContain("200 limit reached");
536
- expect(text).toContain("500 total matches");
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 ------------------------------------------------