@heyhuynhgiabuu/pi-pretty 0.6.15 → 0.6.17

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +19 -116
  2. package/README.md +8 -16
  3. package/dist/collapsed-hint.d.ts +7 -0
  4. package/dist/collapsed-hint.d.ts.map +1 -0
  5. package/dist/collapsed-hint.js +17 -0
  6. package/dist/collapsed-hint.js.map +1 -0
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +64 -19
  10. package/dist/config.js.map +1 -1
  11. package/dist/expand.d.ts +7 -0
  12. package/dist/expand.d.ts.map +1 -0
  13. package/dist/expand.js +11 -0
  14. package/dist/expand.js.map +1 -0
  15. package/dist/find-glob.d.ts +9 -0
  16. package/dist/find-glob.d.ts.map +1 -0
  17. package/dist/find-glob.js +29 -0
  18. package/dist/find-glob.js.map +1 -0
  19. package/dist/image.js +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +18 -19
  23. package/dist/index.js.map +1 -1
  24. package/dist/render.d.ts +3 -5
  25. package/dist/render.d.ts.map +1 -1
  26. package/dist/render.js +24 -19
  27. package/dist/render.js.map +1 -1
  28. package/dist/tools/bash.d.ts +1 -1
  29. package/dist/tools/bash.d.ts.map +1 -1
  30. package/dist/tools/bash.js +22 -26
  31. package/dist/tools/bash.js.map +1 -1
  32. package/dist/tools/find.d.ts +3 -3
  33. package/dist/tools/find.d.ts.map +1 -1
  34. package/dist/tools/find.js +99 -50
  35. package/dist/tools/find.js.map +1 -1
  36. package/dist/tools/grep.d.ts +2 -2
  37. package/dist/tools/grep.d.ts.map +1 -1
  38. package/dist/tools/grep.js +12 -8
  39. package/dist/tools/grep.js.map +1 -1
  40. package/dist/tools/labels.d.ts +2 -0
  41. package/dist/tools/labels.d.ts.map +1 -0
  42. package/dist/tools/labels.js +7 -0
  43. package/dist/tools/labels.js.map +1 -0
  44. package/dist/tools/ls.d.ts +1 -1
  45. package/dist/tools/ls.d.ts.map +1 -1
  46. package/dist/tools/ls.js +9 -5
  47. package/dist/tools/ls.js.map +1 -1
  48. package/dist/tools/read.d.ts +1 -1
  49. package/dist/tools/read.d.ts.map +1 -1
  50. package/dist/tools/read.js +34 -48
  51. package/dist/tools/read.js.map +1 -1
  52. package/dist/tui-text.d.ts.map +1 -1
  53. package/dist/tui-text.js +1 -5
  54. package/dist/tui-text.js.map +1 -1
  55. package/dist/types.d.ts +4 -27
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +5 -4
  58. package/src/collapsed-hint.ts +14 -0
  59. package/src/config.ts +70 -22
  60. package/src/expand.ts +10 -0
  61. package/src/find-glob.ts +21 -0
  62. package/src/image.ts +1 -1
  63. package/src/index.ts +25 -32
  64. package/src/notices.ts +1 -1
  65. package/src/render.ts +50 -41
  66. package/src/tools/bash.ts +27 -48
  67. package/src/tools/find.ts +131 -86
  68. package/src/tools/grep.ts +23 -17
  69. package/src/tools/labels.ts +3 -0
  70. package/src/tools/ls.ts +27 -13
  71. package/src/tools/metrics.ts +1 -1
  72. package/src/tools/read.ts +55 -66
  73. package/src/tui-text.ts +1 -5
  74. package/src/types.ts +4 -35
  75. package/dist/multi-grep-fallback.d.ts +0 -30
  76. package/dist/multi-grep-fallback.d.ts.map +0 -1
  77. package/dist/multi-grep-fallback.js +0 -205
  78. package/dist/multi-grep-fallback.js.map +0 -1
  79. package/dist/tools/multi-grep.d.ts +0 -7
  80. package/dist/tools/multi-grep.d.ts.map +0 -1
  81. package/dist/tools/multi-grep.js +0 -294
  82. package/dist/tools/multi-grep.js.map +0 -1
  83. package/src/multi-grep-fallback.ts +0 -248
  84. package/src/tools/multi-grep.ts +0 -331
package/src/notices.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Stable user-facing strings for FFF/grep tool footers (not source-line titles). */
2
2
 
3
- export const NOTICE_PARTIAL_FILE_INDEX = "FFF index incomplete; results may be missing files";
3
+ export const NOTICE_PARTIAL_FILE_INDEX = "FFF index incomplete; results may be missing files";
package/src/render.ts CHANGED
@@ -5,40 +5,40 @@
5
5
  * They are async only when Shiki syntax highlighting is involved.
6
6
  */
7
7
 
8
- import type { BundledLanguage } from "shiki";
9
- import { codeToANSI } from "@shikijs/cli";
10
8
  import { basename, dirname } from "node:path";
11
-
9
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
10
+ import { codeToANSI } from "@shikijs/cli";
11
+ import type { BundledLanguage } from "shiki";
12
12
  import {
13
- RST,
14
- FG_LNUM,
13
+ BG_BASE,
14
+ BG_ERROR,
15
+ CACHE_LIMIT,
16
+ detectLang,
17
+ dirIcon,
18
+ FG_BLUE,
15
19
  FG_DIM,
16
- FG_RULE,
17
20
  FG_GREEN,
21
+ FG_LNUM,
18
22
  FG_RED,
23
+ FG_RULE,
19
24
  FG_YELLOW,
20
- FG_BLUE,
21
- BG_BASE,
22
- BG_ERROR,
23
- dirIcon,
24
- detectLang,
25
- termWidth,
26
- MAX_PREVIEW_LINES,
27
25
  MAX_HL_CHARS,
28
- CACHE_LIMIT,
26
+ MAX_PREVIEW_LINES,
27
+ RST,
29
28
  resolveBaseBackground,
29
+ TOOL_RESULT_INDENT,
30
+ termWidth,
30
31
  } from "./config.js";
31
32
  import {
32
- normalizeLineEndings,
33
- humanSize,
34
- formatElapsedMs,
35
- formatCharCount,
36
- ELAPSED_KEY,
37
33
  CHARS_KEY,
38
34
  compactErrorLines,
35
+ ELAPSED_KEY,
36
+ formatCharCount,
37
+ formatElapsedMs,
38
+ humanSize,
39
+ normalizeLineEndings,
39
40
  } from "./helpers.js";
40
- import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
41
- import type { ThemeLike, RenderCtxLike as RenderContext } from "./types.js";
41
+ import type { RenderCtxLike as RenderContext, ThemeLike } from "./types.js";
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
44
  // Lazy imports — avoid top-level require() that blocks module loading
@@ -72,7 +72,7 @@ function resolveTheme(): BundledTheme {
72
72
  }
73
73
  }
74
74
 
75
- let THEME: BundledTheme = resolveTheme();
75
+ const THEME: BundledTheme = resolveTheme();
76
76
  const _cache = new Map<string, string[]>();
77
77
 
78
78
  function _touch(k: string, v: string[]): string[] {
@@ -168,9 +168,14 @@ export function fillToolBackground(text: string, bg = BG_BASE, width?: number):
168
168
  return text
169
169
  .split("\n")
170
170
  .map((line) => {
171
- const fitted = width ? _truncateToWidth(line, width, "") : line;
171
+ if (!width) {
172
+ const stripped = preserveBoxBackground(line);
173
+ return bg ? bg + stripped : stripped;
174
+ }
175
+ const plainLead = line.replace(/\x1b\[[0-9;]*m/g, "");
176
+ const skipPad = line.startsWith(TOOL_RESULT_INDENT) || plainLead.startsWith(TOOL_RESULT_INDENT);
177
+ const fitted = _truncateToWidth(line, width, "", !skipPad);
172
178
  const stripped = preserveBoxBackground(fitted);
173
- // Apply background to the entire line
174
179
  return bg ? bg + stripped : stripped;
175
180
  })
176
181
  .join("\n");
@@ -198,13 +203,18 @@ export function renderToolMetrics(result: AgentToolResult<Record<string, unknown
198
203
  return `${FG_DIM}· ${[elapsed, chars].filter(Boolean).join(" · ")}${RST}`;
199
204
  }
200
205
 
206
+ export function renderToolDuration(result: AgentToolResult<Record<string, unknown>>): string {
207
+ const details = result.details as Record<string, unknown> | undefined;
208
+ return formatElapsedMs(details?.[ELAPSED_KEY] as number | undefined);
209
+ }
210
+
201
211
  // ---------------------------------------------------------------------------
202
212
  // Error renderer
203
213
  // ---------------------------------------------------------------------------
204
214
 
205
215
  export function renderToolError(error: string, theme: ThemeLike): string {
206
216
  const body = compactErrorLines(error)
207
- .map((line) => ` ${line ? theme.fg("error", line) : ""}`)
217
+ .map((line) => `${TOOL_RESULT_INDENT}${line ? theme.fg("error", line) : ""}`)
208
218
  .join("\n");
209
219
  return fillToolBackground(body, BG_ERROR);
210
220
  }
@@ -239,23 +249,16 @@ export async function renderFileContent(
239
249
  // Bash — colored exit status
240
250
  // ---------------------------------------------------------------------------
241
251
 
242
- export function renderBashOutput(text: string, exitCode: number | null): { summary: string; body: string } {
243
- const isOk = exitCode === 0;
244
- const statusIcon = isOk ? "✓" : "✗";
245
- const codeStr =
246
- exitCode !== null
247
- ? `${isOk ? FG_GREEN : FG_RED}${statusIcon} exit ${exitCode}${RST}`
248
- : `${FG_YELLOW}⚡ killed${RST}`;
249
-
252
+ export function renderBashOutput(text: string): string {
250
253
  const lines = text.split("\n");
251
254
  const maxShow = MAX_PREVIEW_LINES;
252
255
  const show = lines.slice(0, maxShow);
253
256
  const remaining = lines.length - maxShow;
254
257
 
255
258
  let body = show.join("\n");
256
- if (remaining > 0) body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
259
+ if (remaining > 0) body += `\n${TOOL_RESULT_INDENT}${FG_DIM}… ${remaining} more lines${RST}`;
257
260
 
258
- return { summary: codeStr, body };
261
+ return body;
259
262
  }
260
263
 
261
264
  // ---------------------------------------------------------------------------
@@ -318,10 +321,10 @@ export function renderFindResults(text: string, theme?: ThemeLike): string {
318
321
  const more = theme
319
322
  ? theme.fg("dim", `… ${lines.length - count} more files`)
320
323
  : `${FG_DIM}… ${lines.length - count} more files${RST}`;
321
- out.push(` ${more}`);
324
+ out.push(`${TOOL_RESULT_INDENT}${more}`);
322
325
  return out.join("\n");
323
326
  }
324
- out.push(` ${files[i]}`);
327
+ out.push(`${TOOL_RESULT_INDENT}${files[i]}`);
325
328
  count++;
326
329
  }
327
330
  }
@@ -364,10 +367,10 @@ export async function renderGrepResults(text: string, pattern: string): Promise<
364
367
  const nw = Math.max(3, lineNo.length);
365
368
  let display = content;
366
369
  if (re) display = content.replace(re, `${RST}${FG_YELLOW}\x1b[1m$1${RST}`);
367
- out.push(` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
370
+ out.push(`${TOOL_RESULT_INDENT}${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
368
371
  count++;
369
372
  } else if (line.trim() === "--") {
370
- out.push(` ${FG_DIM} ···${RST}`);
373
+ out.push(`${TOOL_RESULT_INDENT}${FG_DIM} ···${RST}`);
371
374
  } else if (line.trim()) {
372
375
  out.push(line);
373
376
  count++;
@@ -407,9 +410,15 @@ export function makeRenderResult() {
407
410
  const preview = lines.slice(0, maxShow).join("\n");
408
411
  const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
409
412
  const metrics = renderToolMetrics(result);
410
- text.setText(fillToolBackground(` ${preview}${more}${metrics ? `\n ${metrics}` : ""}`, undefined, renderWidth));
413
+ text.setText(
414
+ fillToolBackground(
415
+ `${TOOL_RESULT_INDENT}${preview}${more}${metrics ? `\n${TOOL_RESULT_INDENT}${metrics}` : ""}`,
416
+ undefined,
417
+ renderWidth,
418
+ ),
419
+ );
411
420
  } else {
412
- text.setText(fillToolBackground(` ${theme.fg("dim", "(no text output)")}`));
421
+ text.setText(fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", "(no text output)")}`));
413
422
  }
414
423
  return text;
415
424
  };
package/src/tools/bash.ts CHANGED
@@ -1,26 +1,12 @@
1
1
  /* pi-pretty: bash tool -- command execution with styled output. */
2
2
 
3
- import {
4
- type ToolDefinition,
5
- type ExtensionAPI,
6
- type ExtensionContext,
7
- type AgentToolResult,
8
- } from "@earendil-works/pi-coding-agent";
9
- import type { SdkToolDef, BashDetails, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
10
- import {
11
- resolveBaseBackground,
12
- termWidth,
13
- MAX_PREVIEW_LINES,
14
- BG_BASE,
15
- BG_ERROR,
16
- FG_DIM,
17
- FG_RULE,
18
- RST,
19
- } from "../config.js";
20
- import { wrapExecuteWithMetrics } from "./metrics.js";
21
- import { renderBashOutput, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
3
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { BG_BASE, FG_DIM, MAX_PREVIEW_LINES, resolveBaseBackground, TOOL_RESULT_INDENT, termWidth } from "../config.js";
5
+ import { compactErrorLines, inferBashExitCode, stripBashExitStatusLine } from "../helpers.js";
6
+ import { fillToolBackground, renderToolDuration, renderToolError } from "../render.js";
22
7
  import { resolveTextCtor } from "../tui-text.js";
23
- import { stripBashExitStatusLine, inferBashExitCode, compactErrorLines } from "../helpers.js";
8
+ import type { BashDetails, ComponentLike, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
9
+ import { wrapExecuteWithMetrics } from "./metrics.js";
24
10
 
25
11
  type Result = AgentToolResult<Record<string, unknown>>;
26
12
 
@@ -38,8 +24,8 @@ export function registerBashTool(
38
24
  label: "Bash",
39
25
  description: sdkTool.description
40
26
  ? `${sdkTool.description} For text search: \`rg -n\`.`
41
- : "Execute shell commands. For text search: \`rg -n\`.",
42
- promptSnippet: "Execute commands via bash. For text search: \`rg -n\`.",
27
+ : "Execute shell commands. For text search: `rg -n`.",
28
+ promptSnippet: "Execute commands via bash. For text search: `rg -n`.",
43
29
  promptGuidelines: [
44
30
  "For text search: `rg -n`. If no results, try `rg -u` (respects .gitignore by default).",
45
31
  "In rg: | means alternation, \\| means literal pipe. Opposite of GNU grep. Never use \\| for alternation.",
@@ -71,19 +57,16 @@ export function registerBashTool(
71
57
  const t = typeof args.timeout === "number" ? ` ${theme.fg("muted", `(timeout ${args.timeout}s)`)}` : "";
72
58
  const tw = termWidth() || 80;
73
59
  const rawCmd = String(args.command ?? "");
60
+ const headerBudget = ctx.expanded ? tw : Math.max(8, tw - 20);
74
61
  const cmd =
75
62
  rawCmd.length === 0
76
63
  ? theme.fg("toolOutput", "...")
77
- : rawCmd.length > tw - 20
78
- ? rawCmd.slice(0, Math.max(1, tw - 20)) + "…"
64
+ : !ctx.expanded && rawCmd.length > headerBudget
65
+ ? `${rawCmd.slice(0, Math.max(1, headerBudget))}…`
79
66
  : rawCmd;
80
- const toolWidth = ctx.expanded ? undefined : tw;
67
+ const commandLabel = theme.fg(ctx.isError ? "error" : "toolTitle", theme.bold(`$ ${cmd}`));
81
68
  text.setText(
82
- fillToolBackground(
83
- `\n ${theme.fg("toolTitle", theme.bold(`$ ${cmd}`))}${t}`,
84
- ctx.isError ? BG_ERROR : undefined,
85
- toolWidth,
86
- ),
69
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${commandLabel}${t}`, undefined, ctx.expanded ? undefined : tw),
87
70
  );
88
71
  return text;
89
72
  },
@@ -109,25 +92,22 @@ export function registerBashTool(
109
92
 
110
93
  if (d?._type === "bashResult") {
111
94
  const isErr = ctx.isError || (d.exitCode !== null && d.exitCode !== 0);
112
- const bg = isErr ? BG_ERROR : undefined;
113
95
  const cleaned = stripBashExitStatusLine(d.text);
114
96
  const output = isErr ? compactErrorLines(cleaned).join("\n") : cleaned;
115
- const { summary } = renderBashOutput(output, d.exitCode);
116
97
  const lineCount = output.split("\n").length;
117
- const info =
118
- lineCount > 1
119
- ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}`
120
- : ` ${renderToolMetrics(result)}`;
121
- const header = ` ${summary}${info}`;
98
+ const info = [`${lineCount} lines`, renderToolDuration(result), !ctx.expanded ? "ctrl+o to expand" : ""]
99
+ .filter(Boolean)
100
+ .map((part) => theme.fg("dim", part))
101
+ .join(theme.fg("dim", " · "));
102
+ const header = `${TOOL_RESULT_INDENT}${info}`;
122
103
  const rw = termWidth();
123
104
 
124
105
  const renderFn = (w: number) => {
125
- if (!output.trim()) return fillToolBackground(header, bg, w);
126
- const max = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
127
- const show = output.split("\n").slice(0, max);
128
- const out = [header, rule(w), ...show.map((l: string) => ` ${l}`)];
129
- if (lineCount > max) out.push(`${FG_DIM} \u2026 ${lineCount - max} more lines${RST}`);
130
- return fillToolBackground(out.join("\n"), bg, w);
106
+ if (!ctx.expanded) return fillToolBackground(`${header}\n`, undefined, w);
107
+ if (!output.trim()) return fillToolBackground(`${header}\n`, undefined, w);
108
+ const show = output.split("\n");
109
+ const out = [header, "", ...show.map((line: string) => `${TOOL_RESULT_INDENT}${line}`)];
110
+ return fillToolBackground(`${out.join("\n")}\n`, undefined, w);
131
111
  };
132
112
 
133
113
  text.setText(renderFn(rw));
@@ -137,7 +117,7 @@ export function registerBashTool(
137
117
  let key: string | undefined;
138
118
  (text as unknown as Record<string, unknown>).render = (w: number) => {
139
119
  const width = Math.max(1, Math.floor(w || termWidth()));
140
- const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolMetrics(result)}`;
120
+ const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolDuration(result)}`;
141
121
  if (key !== k) {
142
122
  text.setText(renderFn(width));
143
123
  key = k;
@@ -154,16 +134,15 @@ export function registerBashTool(
154
134
  }
155
135
  const fc = result.content?.[0];
156
136
  text.setText(
157
- fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`),
137
+ fillToolBackground(
138
+ `${TOOL_RESULT_INDENT}${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`,
139
+ ),
158
140
  );
159
141
  return text;
160
142
  },
161
143
  } as unknown as ToolDefinition<any, any, any>);
162
144
  }
163
145
 
164
- function rule(w: number): string {
165
- return `${FG_RULE}${"\u2500".repeat(w)}${RST}`;
166
- }
167
146
  function getText(result: Result): string {
168
147
  return (
169
148
  ((result.content ?? []) as TextContent[])
package/src/tools/find.ts CHANGED
@@ -1,35 +1,80 @@
1
- /* pi-pretty: find tool -- FFF-backed file search with SDK fallback. */
2
-
3
- import {
4
- type ToolDefinition,
5
- type ExtensionAPI,
6
- type ExtensionContext,
7
- type AgentToolResult,
8
- } from "@earendil-works/pi-coding-agent";
9
- import { isAbsolute, relative as toRelative } from "node:path";
10
- import type {
11
- SdkToolDef,
12
- FindDetails,
13
- FffServiceLike,
14
- FileItem,
15
- TextContent,
16
- ThemeLike,
17
- RenderCtxLike,
18
- ComponentLike,
19
- } from "../types.js";
20
- import { resolveBaseBackground, BG_ERROR, FG_DIM, RST } from "../config.js";
1
+ /* pi-pretty: find tool -- FFF-backed file search with SDK (fd) fallback. */
2
+
3
+ import { isAbsolute, relative } from "node:path";
4
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { BG_ERROR, FG_DIM, RST, resolveBaseBackground, TOOL_RESULT_INDENT } from "../config.js";
6
+ import { isLikelyGlobPattern, normalizeFindGlobPattern } from "../find-glob.js";
21
7
  import { shortPath } from "../helpers.js";
22
- import { wrapExecuteWithMetrics } from "./metrics.js";
23
- import { renderFindResults, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
24
- import { resolveTextCtor } from "../tui-text.js";
25
8
  import { NOTICE_PARTIAL_FILE_INDEX } from "../notices.js";
9
+ import { fillToolBackground, renderFindResults, renderToolDuration, renderToolError } from "../render.js";
10
+ import { resolveTextCtor } from "../tui-text.js";
11
+ import type { FffServiceWithCursor, FindDetails, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
12
+ import { wrapExecuteWithMetrics } from "./metrics.js";
26
13
 
27
14
  type Result = AgentToolResult<Record<string, unknown>>;
28
15
 
16
+ function getText(result: Result): string {
17
+ return (result.content ?? [])
18
+ .filter((c) => c.type === "text")
19
+ .map((c) => (c as TextContent).text ?? "")
20
+ .join("\n");
21
+ }
22
+
23
+ function buildGlobPattern(pattern: string, path: string | undefined, basePath: string | null): string {
24
+ const raw = pattern.startsWith("/") ? pattern.slice(1) : pattern;
25
+ const normalized = normalizeFindGlobPattern(raw);
26
+ let cleanPath = path ?? "";
27
+ if (cleanPath && isAbsolute(cleanPath) && basePath) {
28
+ cleanPath = relative(basePath, cleanPath) || "";
29
+ }
30
+ cleanPath = cleanPath.replace(/\/$/, "");
31
+ if (cleanPath) {
32
+ if (normalized.startsWith("**/")) {
33
+ return `${cleanPath}/${normalized}`;
34
+ }
35
+ if (normalized.includes("/")) {
36
+ return `${cleanPath}/${normalized}`;
37
+ }
38
+ return `${cleanPath}/**/${normalized}`;
39
+ }
40
+ return normalized.startsWith("**/") || normalized.includes("/") ? normalized : `**/${normalized}`;
41
+ }
42
+
43
+ function appendFindNotices(result: Result, extra: string[]): Result {
44
+ if (extra.length === 0) return result;
45
+ const d = result.details;
46
+ if (!d || d._type !== "findResult") return result;
47
+ const prev = Array.isArray(d.notices) ? (d.notices as string[]) : [];
48
+ return { ...result, details: { ...d, notices: [...prev, ...extra] } };
49
+ }
50
+
51
+ async function sdkFindAsFindResult(
52
+ sdkTool: SdkToolDef,
53
+ tid: string,
54
+ params: Record<string, unknown>,
55
+ sig: AbortSignal | undefined,
56
+ ctx: ExtensionContext,
57
+ pattern: string,
58
+ extraNotices: string[],
59
+ ): Promise<Result> {
60
+ const result = (await sdkTool.execute(tid, params, sig, undefined, ctx)) as Result;
61
+ const tc = getText(result);
62
+ const prev = (result.details as FindDetails | undefined)?.notices ?? [];
63
+ const notices = [...(Array.isArray(prev) ? prev : []), ...extraNotices];
64
+ result.details = {
65
+ _type: "findResult",
66
+ text: tc,
67
+ pattern,
68
+ matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0,
69
+ notices,
70
+ };
71
+ return result;
72
+ }
73
+
29
74
  export function registerFindTool(
30
75
  pi: ExtensionAPI,
31
76
  cwd: string,
32
- fffService: FffServiceLike | null | undefined,
77
+ fffService: FffServiceWithCursor | null | undefined,
33
78
  sdkTool: SdkToolDef,
34
79
  TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
35
80
  ): void {
@@ -44,32 +89,40 @@ export function registerFindTool(
44
89
  renderShell: "self",
45
90
 
46
91
  execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
47
- const pattern = String((params as any).pattern ?? "");
48
- const path = (params as any).path ? String((params as any).path) : undefined;
49
- const limit = (params as any).limit;
50
- const glob = (params as any).glob;
51
-
92
+ const pattern = String(params.pattern ?? "");
93
+ const path = params.path ? String(params.path) : undefined;
94
+ const limit = params.limit;
52
95
  const fff = fffService?.isAvailable ? fffService.getFinder() : null;
96
+
53
97
  if (fff) {
54
98
  try {
55
99
  const effectiveLimit = Math.max(1, typeof limit === "number" ? limit : 100);
56
100
  const basePathResult = fff.getBasePath();
57
101
  const basePath = basePathResult.ok ? basePathResult.value : null;
58
- let cleanPath = path ?? "";
59
- if (cleanPath && isAbsolute(cleanPath) && basePath) {
60
- cleanPath = toRelative(basePath, cleanPath) || "";
61
- }
62
- cleanPath = cleanPath.replace(/\/$/, "");
63
- const cleanPattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
64
- const globPattern = cleanPath ? `${cleanPath}/**/${cleanPattern}` : `**/${cleanPattern}`;
65
- const searchResult = fff.glob(globPattern, { pageSize: effectiveLimit });
102
+ const globPattern = buildGlobPattern(pattern, path, basePath);
103
+ const searchResult = fff.glob(globPattern, {
104
+ pageSize: effectiveLimit,
105
+ });
106
+
66
107
  if (searchResult.ok) {
67
- const items: FileItem[] = searchResult.value.items.slice(0, effectiveLimit);
108
+ const items = searchResult.value.items.slice(0, effectiveLimit);
68
109
  const notices: string[] = [];
69
110
  if (fffService?.partialIndex) notices.push(NOTICE_PARTIAL_FILE_INDEX);
70
111
  if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
71
- if (searchResult.value.totalMatched > items.length)
112
+ if (searchResult.value.totalMatched > items.length) {
72
113
  notices.push(`${searchResult.value.totalMatched} total matches`);
114
+ }
115
+
116
+ if (items.length === 0 && isLikelyGlobPattern(pattern)) {
117
+ return sdkFindAsFindResult(sdkTool, tid, params, sig, ctx, pattern, [
118
+ "FFF glob returned no matches; results from SDK find (fd).",
119
+ ...notices,
120
+ ]);
121
+ }
122
+
123
+ if (items.length > 0) notices.push("Search engine: FFF glob.");
124
+ else if (notices.length === 0) notices.push("Search engine: FFF glob (no matches).");
125
+
73
126
  const paths = items.map((i) => i.relativePath).join("\n");
74
127
  return {
75
128
  content: [{ type: "text" as const, text: paths }],
@@ -79,7 +132,7 @@ export function registerFindTool(
79
132
  pattern,
80
133
  matchCount: items.length,
81
134
  notices,
82
- } as FindDetails,
135
+ },
83
136
  };
84
137
  }
85
138
  } catch {
@@ -87,82 +140,74 @@ export function registerFindTool(
87
140
  }
88
141
  }
89
142
 
90
- const result = (await sdkTool.execute(tid, params, sig, undefined, ctx)) as Result;
91
- const tc = getText(result);
92
- result.details = {
93
- _type: "findResult",
94
- text: tc,
95
- pattern,
96
- matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0,
97
- } as FindDetails;
98
- return result;
143
+ return sdkFindAsFindResult(sdkTool, tid, params, sig, ctx, pattern, [
144
+ fff ? "FFF find unavailable; results from SDK find (fd)." : "Search engine: SDK find (fd).",
145
+ ]);
99
146
  }),
100
147
 
101
148
  renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
102
149
  resolveBaseBackground(theme);
103
- const text = ctx.lastComponent ?? new TC("", 0, 0);
104
- const pattern = args.pattern === null || args.pattern === undefined ? "" : String(args.pattern);
105
- const path =
106
- args.path === null || args.path === undefined ? "<missing>" : shortPath(cwd, home, String(args.path));
107
- const limit = args.limit;
108
- const glob = args.glob;
109
- const findLabel = theme.fg("toolTitle", theme.bold("find"));
110
- const patternPart = pattern ? theme.fg("accent", pattern) : "";
150
+ const a = args as { pattern?: unknown; path?: unknown; limit?: unknown };
151
+ const text = (ctx as RenderCtxLike).lastComponent ?? new TC("", 0, 0);
152
+ const pattern = a.pattern == null ? "" : String(a.pattern);
153
+ const pathArg = a.path == null ? "<missing>" : shortPath(cwd, home, String(a.path));
154
+ const limit = a.limit;
155
+ const findLabel = theme.fg(ctx.isError ? "error" : "toolTitle", theme.bold("✱ find"));
156
+ const patternPart = pattern ? theme.fg("toolTitle", pattern) : "";
111
157
  const inPart = theme.fg("dim", " in ");
112
- const pathPart = theme.fg("toolOutput", path);
158
+ const pathPart = theme.fg("toolOutput", pathArg);
113
159
  const limitPart = limit !== undefined && limit !== null ? theme.fg("dim", ` limit ${limit}`) : "";
114
160
  const out = `${findLabel} ${patternPart}${inPart}${pathPart}${limitPart}`;
115
- text.setText(fillToolBackground(` \n ${out}`, ctx.isError ? BG_ERROR : undefined));
161
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
116
162
  return text;
117
163
  },
118
164
 
119
165
  renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
120
166
  resolveBaseBackground(theme);
121
- const text = ctx.lastComponent ?? new TC("", 0, 0);
167
+ const r = result;
168
+ const text = (ctx as RenderCtxLike).lastComponent ?? new TC("", 0, 0);
122
169
  if (ctx.isError) {
123
- text.setText(renderToolError(getText(result) || "Error", theme));
170
+ text.setText(renderToolError(getText(r) || "Error", theme));
124
171
  return text;
125
172
  }
126
- const d = result.details as FindDetails | undefined;
173
+ const d = r.details as FindDetails | undefined;
127
174
  if (d?._type === "findResult") {
128
175
  if (!d.text.trim()) {
129
- const notices = (d as any).notices as string[] | undefined;
130
- const noticeStr = notices?.length ? `\n ${theme.fg("warning", `[${notices.join(". ")}]`)}` : "";
131
- text.setText(fillToolBackground(` \n ${theme.fg("dim", "0 files")}${noticeStr}\n `));
176
+ const noticeStr = d.notices?.length
177
+ ? `\n${TOOL_RESULT_INDENT}${theme.fg("warning", `[${d.notices.join(". ")}]`)}`
178
+ : "";
179
+ text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${theme.fg("dim", "0 files")}${noticeStr}\n`));
180
+ return text;
181
+ }
182
+ if (!ctx.expanded) {
183
+ const duration = renderToolDuration(r);
184
+ text.setText(
185
+ fillToolBackground(
186
+ `${TOOL_RESULT_INDENT}${FG_DIM}${d.matchCount} files — ctrl+o to expand${RST}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n`,
187
+ ),
188
+ );
132
189
  return text;
133
190
  }
134
191
  const rendered = renderFindResults(d.text, theme)
135
192
  .split("\n")
136
- .map((l) => ` ${l}`)
193
+ .map((l) => `${TOOL_RESULT_INDENT}${l}`)
137
194
  .join("\n");
138
- const notices = (d as any).notices as string[] | undefined;
139
- const noticeStr = notices?.length ? `\n ${theme.fg("warning", `[${notices.join(". ")}]`)}` : "";
195
+ const noticeStr = d.notices?.length
196
+ ? `\n${TOOL_RESULT_INDENT}${theme.fg("warning", `[${d.notices.join(". ")}]`)}`
197
+ : "";
198
+ const duration = renderToolDuration(r);
140
199
  text.setText(
141
200
  fillToolBackground(
142
- ` \n ${theme.fg("dim", `${d.matchCount} files`)}${renderToolMetrics(result)}\n${rendered}${noticeStr}\n `,
201
+ `\n${TOOL_RESULT_INDENT}${theme.fg("dim", `${d.matchCount} files`)}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n${rendered}${noticeStr}\n`,
143
202
  ),
144
203
  );
145
204
  return text;
146
205
  }
147
- const fc = result.content?.[0];
206
+ const fc = r.content?.[0] as TextContent | undefined;
148
207
  text.setText(
149
- fillToolBackground(
150
- ` \n ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "0 files")}\n `,
151
- ),
208
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${theme.fg("dim", fc?.text?.slice(0, 120) ?? "0 files")}\n`),
152
209
  );
153
210
  return text;
154
211
  },
155
- } as unknown as ToolDefinition<any, any, any>);
156
- }
157
-
158
- function appendNotices(text: string, notices: string[]): string {
159
- return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text;
160
- }
161
- function getText(result: Result): string {
162
- return (
163
- ((result.content ?? []) as TextContent[])
164
- .filter((c) => c.type === "text")
165
- .map((c) => c.text ?? "")
166
- .join("\n") ?? ""
167
- );
212
+ } as unknown as ToolDefinition);
168
213
  }