@heyhuynhgiabuu/pi-pretty 0.6.26 → 0.6.28

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 (61) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +34 -17
  3. package/dist/config.d.ts +16 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +30 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/fff.d.ts +16 -1
  8. package/dist/fff.d.ts.map +1 -1
  9. package/dist/fff.js +139 -33
  10. package/dist/fff.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +70 -24
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompt-editor.d.ts +11 -0
  15. package/dist/prompt-editor.d.ts.map +1 -0
  16. package/dist/prompt-editor.js +78 -0
  17. package/dist/prompt-editor.js.map +1 -0
  18. package/dist/render.d.ts +9 -1
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/render.js +25 -9
  21. package/dist/render.js.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash.js +79 -32
  24. package/dist/tools/bash.js.map +1 -1
  25. package/dist/tools/find.d.ts.map +1 -1
  26. package/dist/tools/find.js +29 -10
  27. package/dist/tools/find.js.map +1 -1
  28. package/dist/tools/grep.d.ts.map +1 -1
  29. package/dist/tools/grep.js +20 -10
  30. package/dist/tools/grep.js.map +1 -1
  31. package/dist/tools/ls.d.ts.map +1 -1
  32. package/dist/tools/ls.js +11 -5
  33. package/dist/tools/ls.js.map +1 -1
  34. package/dist/tools/metrics.d.ts +6 -1
  35. package/dist/tools/metrics.d.ts.map +1 -1
  36. package/dist/tools/metrics.js +21 -8
  37. package/dist/tools/metrics.js.map +1 -1
  38. package/dist/tools/read.d.ts +2 -1
  39. package/dist/tools/read.d.ts.map +1 -1
  40. package/dist/tools/read.js +97 -31
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +13 -4
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/working-indicator.d.ts +39 -31
  45. package/dist/working-indicator.d.ts.map +1 -1
  46. package/dist/working-indicator.js +114 -87
  47. package/dist/working-indicator.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/config.ts +51 -0
  50. package/src/fff.ts +158 -29
  51. package/src/index.ts +77 -24
  52. package/src/prompt-editor.ts +102 -0
  53. package/src/render.ts +30 -11
  54. package/src/tools/bash.ts +100 -34
  55. package/src/tools/find.ts +39 -17
  56. package/src/tools/grep.ts +26 -15
  57. package/src/tools/ls.ts +20 -10
  58. package/src/tools/metrics.ts +28 -8
  59. package/src/tools/read.ts +126 -31
  60. package/src/types.ts +14 -4
  61. package/src/working-indicator.ts +123 -95
package/src/tools/bash.ts CHANGED
@@ -2,14 +2,71 @@
2
2
 
3
3
  import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
4
  import { resolveBaseBackground, TOOL_RESULT_INDENT, termWidth } from "../config.js";
5
- import { compactErrorLines, inferBashExitCode, stripBashExitStatusLine } from "../helpers.js";
6
- import { fillToolBackground, renderToolDuration, renderToolError } from "../render.js";
5
+ import {
6
+ CHARS_KEY,
7
+ compactErrorLines,
8
+ ELAPSED_KEY,
9
+ formatCharCount,
10
+ inferBashExitCode,
11
+ stripBashExitStatusLine,
12
+ } from "../helpers.js";
13
+ import {
14
+ fillToolBackground,
15
+ fillToolBody,
16
+ rememberToolTitle,
17
+ renderToolDuration,
18
+ renderToolError,
19
+ setCollapsedToolTitle,
20
+ } from "../render.js";
7
21
  import { resolveTextCtor } from "../tui-text.js";
8
22
  import type { BashDetails, ComponentLike, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
9
- import { wrapExecuteWithMetrics } from "./metrics.js";
23
+ import { type RejectedExecutionMetrics, wrapExecuteWithMetrics } from "./metrics.js";
10
24
 
11
25
  type Result = AgentToolResult<Record<string, unknown>>;
12
26
 
27
+ const BASH_REJECTED_METRICS_KEY = "__piPrettyBashRejectedMetrics";
28
+ const BASH_RESULT_RENDER_KEY = "__piPrettyBashResultRender";
29
+ const REJECTED_METRICS_TTL_MS = 60_000;
30
+ const rejectedBashMetrics = new Map<string, RejectedExecutionMetrics>();
31
+
32
+ function rememberRejectedBashMetrics(toolCallId: string, metrics: RejectedExecutionMetrics): void {
33
+ rejectedBashMetrics.set(toolCallId, metrics);
34
+ const timer = setTimeout(() => {
35
+ if (rejectedBashMetrics.get(toolCallId) === metrics) rejectedBashMetrics.delete(toolCallId);
36
+ }, REJECTED_METRICS_TTL_MS);
37
+ timer.unref?.();
38
+ }
39
+
40
+ function takeRejectedBashMetrics(ctx: RenderCtxLike): RejectedExecutionMetrics | undefined {
41
+ const state = ctx.state;
42
+ const cached = state[BASH_REJECTED_METRICS_KEY] as RejectedExecutionMetrics | undefined;
43
+ if (cached) return cached;
44
+ if (!ctx.toolCallId) return undefined;
45
+ const metrics = rejectedBashMetrics.get(ctx.toolCallId);
46
+ if (metrics) {
47
+ rejectedBashMetrics.delete(ctx.toolCallId);
48
+ state[BASH_REJECTED_METRICS_KEY] = metrics;
49
+ }
50
+ return metrics;
51
+ }
52
+
53
+ function addRejectedMetrics(result: Result, metrics: RejectedExecutionMetrics): Result {
54
+ const details = {
55
+ ...((result.details ?? {}) as Record<string, unknown>),
56
+ [ELAPSED_KEY]: metrics.elapsedMs,
57
+ [CHARS_KEY]: metrics.chars,
58
+ };
59
+ return { ...result, details } as Result;
60
+ }
61
+
62
+ function restoreBashResultRender(ctx: RenderCtxLike, text: ComponentLike): void {
63
+ const state = ctx.state;
64
+ const original = state[BASH_RESULT_RENDER_KEY] as ((width: number) => string[]) | undefined;
65
+ if (!original) return;
66
+ (text as unknown as { render: (width: number) => string[] }).render = original;
67
+ delete state[BASH_RESULT_RENDER_KEY];
68
+ }
69
+
13
70
  export function registerBashTool(
14
71
  pi: ExtensionAPI,
15
72
  _cwd: string,
@@ -27,29 +84,22 @@ export function registerBashTool(
27
84
  : "Execute shell commands. For text search: `rg -n`.",
28
85
  promptSnippet: "Execute commands via bash. For text search: `rg -n`.",
29
86
  promptGuidelines: [
30
- "For text search: `rg -n`. If no results, try `rg -u` (respects .gitignore by default).",
31
- "In rg: | means alternation, \\| means literal pipe. Opposite of GNU grep. Never use \\| for alternation.",
87
+ // Re-registering by name drops the host's built-in bash guidelines; keep them.
88
+ ...(sdkTool.promptGuidelines ?? []),
89
+ "rg skips .gitignored and hidden files by default. On no results use `--hidden` for dotfiles (add `-g '!.git'`), `--no-ignore` for ignored files, or name the path directly; `-u` = `--no-ignore`, `-uu` adds hidden.",
90
+ "Quote rg patterns: `rg -n 'foo|bar'`. `|` is alternation, `\\|` is a literal pipe (unlike GNU grep); use `-F` for literal text.",
91
+ "Keep output small: `-l` lists files only, `-m N` caps matches per file.",
32
92
  ],
33
93
  parameters: sdkTool.parameters,
94
+ constrainedSampling: sdkTool.constrainedSampling,
34
95
  renderShell: "self",
35
96
 
36
- execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
37
- try {
38
- return (await sdkTool.execute(tid, params, sig, undefined, ctx)) as Result;
39
- } catch (error: unknown) {
40
- const msg = error instanceof Error ? error.message : String(error);
41
- return {
42
- content: [{ type: "text" as const, text: msg }],
43
- isError: true,
44
- details: {
45
- _type: "bashResult",
46
- text: msg,
47
- exitCode: 1,
48
- command: String((params as any).command ?? ""),
49
- } as BashDetails,
50
- };
51
- }
52
- }),
97
+ execute: wrapExecuteWithMetrics(async (tid, params, sig, upd, ctx: ExtensionContext) => {
98
+ // Let the host derive the tool-error flag from the rejected execution.
99
+ // AgentToolResult has no portable `isError` field, so converting a bash
100
+ // failure into a successful-looking result hides the failure from the model.
101
+ return (await sdkTool.execute(tid, params, sig, upd, ctx)) as Result;
102
+ }, rememberRejectedBashMetrics),
53
103
 
54
104
  renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
55
105
  resolveBaseBackground(theme);
@@ -65,9 +115,14 @@ export function registerBashTool(
65
115
  ? `${rawCmd.slice(0, Math.max(1, headerBudget))}…`
66
116
  : rawCmd;
67
117
  const commandLabel = theme.fg(ctx.isError ? "error" : "toolTitle", theme.bold(`$ ${cmd}`));
68
- text.setText(
69
- fillToolBackground(`\n${TOOL_RESULT_INDENT}${commandLabel}${t}`, undefined, ctx.expanded ? undefined : tw),
70
- );
118
+ const renderTitle = (suffix = ""): string =>
119
+ fillToolBackground(
120
+ `\n${TOOL_RESULT_INDENT}${commandLabel}${t}${suffix}\n`,
121
+ undefined,
122
+ ctx.expanded ? undefined : tw,
123
+ );
124
+ rememberToolTitle(ctx, text, renderTitle);
125
+ text.setText(renderTitle());
71
126
  return text;
72
127
  },
73
128
 
@@ -75,9 +130,12 @@ export function registerBashTool(
75
130
  resolveBaseBackground(theme);
76
131
 
77
132
  const text = ctx.lastComponent ?? new TC("", 0, 0);
133
+ restoreBashResultRender(ctx, text as ComponentLike);
134
+ const rejectedMetrics = takeRejectedBashMetrics(ctx);
135
+ const displayResult = rejectedMetrics ? addRejectedMetrics(result, rejectedMetrics) : result;
78
136
 
79
- const details = result.details;
80
- const tc = getText(result);
137
+ const details = displayResult.details;
138
+ const tc = getText(displayResult);
81
139
  const d: BashDetails | undefined =
82
140
  (details as BashDetails)?._type === "bashResult"
83
141
  ? (details as BashDetails)
@@ -95,29 +153,37 @@ export function registerBashTool(
95
153
  const cleaned = stripBashExitStatusLine(d.text);
96
154
  const output = isErr ? compactErrorLines(cleaned).join("\n") : cleaned;
97
155
  const lineCount = output.split("\n").length;
98
- const info = [`${lineCount} lines`, renderToolDuration(result), !ctx.expanded ? "ctrl+o to expand" : ""]
156
+ const info = [
157
+ `${lineCount} lines`,
158
+ renderToolDuration(displayResult),
159
+ rejectedMetrics ? formatCharCount(rejectedMetrics.chars) : "",
160
+ !ctx.expanded ? "ctrl+o to expand" : "",
161
+ ]
99
162
  .filter(Boolean)
100
163
  .map((part) => theme.fg("dim", part))
101
164
  .join(theme.fg("dim", " · "));
102
165
  const header = `${TOOL_RESULT_INDENT}${info}`;
103
166
  const rw = termWidth();
104
167
 
168
+ if (setCollapsedToolTitle(ctx, text, ` ${info}`)) return text;
169
+
105
170
  const renderFn = (w: number) => {
106
- if (!ctx.expanded) return fillToolBackground(`${header}\n`, undefined, w);
107
- if (!output.trim()) return fillToolBackground(`${header}\n`, undefined, w);
171
+ if (!ctx.expanded) return fillToolBody(header, undefined, w);
172
+ if (!output.trim()) return fillToolBody(header, undefined, w);
108
173
  const show = output.split("\n");
109
174
  const out = [header, "", ...show.map((line: string) => `${TOOL_RESULT_INDENT}${line}`)];
110
- return fillToolBackground(`${out.join("\n")}\n`, undefined, w);
175
+ return fillToolBody(out.join("\n"), undefined, w);
111
176
  };
112
177
 
113
178
  text.setText(renderFn(rw));
114
179
  const baseRender =
115
180
  typeof (text as ComponentLike).render === "function" ? (text as ComponentLike).render.bind(text) : null;
116
181
  if (baseRender) {
182
+ ctx.state[BASH_RESULT_RENDER_KEY] = baseRender;
117
183
  let key: string | undefined;
118
184
  (text as unknown as Record<string, unknown>).render = (w: number) => {
119
185
  const width = Math.max(1, Math.floor(w || termWidth()));
120
- const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolDuration(result)}`;
186
+ const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolDuration(displayResult)}`;
121
187
  if (key !== k) {
122
188
  text.setText(renderFn(width));
123
189
  key = k;
@@ -132,9 +198,9 @@ export function registerBashTool(
132
198
  text.setText(renderToolError(tc || "Error", theme));
133
199
  return text;
134
200
  }
135
- const fc = result.content?.[0];
201
+ const fc = displayResult.content?.[0];
136
202
  text.setText(
137
- fillToolBackground(
203
+ fillToolBody(
138
204
  `${TOOL_RESULT_INDENT}${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`,
139
205
  ),
140
206
  );
package/src/tools/find.ts CHANGED
@@ -6,7 +6,15 @@ import { BG_ERROR, FG_DIM, RST, resolveBaseBackground, TOOL_RESULT_INDENT } from
6
6
  import { isLikelyGlobPattern, normalizeFindGlobPattern } from "../find-glob.js";
7
7
  import { shortPath } from "../helpers.js";
8
8
  import { NOTICE_PARTIAL_FILE_INDEX } from "../notices.js";
9
- import { fillToolBackground, renderFindResults, renderToolDuration, renderToolError } from "../render.js";
9
+ import {
10
+ fillToolBackground,
11
+ fillToolBody,
12
+ rememberToolTitle,
13
+ renderFindResults,
14
+ renderToolDuration,
15
+ renderToolError,
16
+ setCollapsedToolTitle,
17
+ } from "../render.js";
10
18
  import { resolveTextCtor } from "../tui-text.js";
11
19
  import type { FffServiceWithCursor, FindDetails, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
12
20
  import { wrapExecuteWithMetrics } from "./metrics.js";
@@ -40,6 +48,19 @@ function buildGlobPattern(pattern: string, path: string | undefined, basePath: s
40
48
  return normalized.startsWith("**/") || normalized.includes("/") ? normalized : `**/${normalized}`;
41
49
  }
42
50
 
51
+ function normalizeSdkFindOutput(text: string): { text: string; matchCount: number; notices: string[] } {
52
+ const trimmed = text.trim();
53
+ if (!trimmed || trimmed === "No files found matching pattern") {
54
+ return { text: "", matchCount: 0, notices: [] };
55
+ }
56
+
57
+ const noticeMatch = /\n\n\[([^\r\n]*)\]\s*$/.exec(text);
58
+ const body = noticeMatch ? text.slice(0, noticeMatch.index) : text;
59
+ const notices = noticeMatch ? noticeMatch[1].split(". ").filter(Boolean) : [];
60
+ const matchCount = body.trim() ? body.trim().split("\n").filter(Boolean).length : 0;
61
+ return { text: body, matchCount, notices };
62
+ }
63
+
43
64
  async function sdkFindAsFindResult(
44
65
  sdkTool: SdkToolDef,
45
66
  tid: string,
@@ -50,14 +71,14 @@ async function sdkFindAsFindResult(
50
71
  extraNotices: string[],
51
72
  ): Promise<Result> {
52
73
  const result = (await sdkTool.execute(tid, params, sig, undefined, ctx)) as Result;
53
- const tc = getText(result);
74
+ const parsed = normalizeSdkFindOutput(getText(result));
54
75
  const prev = (result.details as FindDetails | undefined)?.notices ?? [];
55
- const notices = [...(Array.isArray(prev) ? prev : []), ...extraNotices];
76
+ const notices = [...parsed.notices, ...(Array.isArray(prev) ? prev : []), ...extraNotices];
56
77
  result.details = {
57
78
  _type: "findResult",
58
- text: tc,
79
+ text: parsed.text,
59
80
  pattern,
60
- matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0,
81
+ matchCount: parsed.matchCount,
61
82
  notices,
62
83
  };
63
84
  return result;
@@ -150,7 +171,10 @@ export function registerFindTool(
150
171
  const pathPart = theme.fg("toolOutput", pathArg);
151
172
  const limitPart = limit !== undefined && limit !== null ? theme.fg("dim", ` limit ${limit}`) : "";
152
173
  const out = `${findLabel} ${patternPart}${inPart}${pathPart}${limitPart}`;
153
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
174
+ const renderTitle = (suffix = ""): string =>
175
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}${suffix}\n`, ctx.isError ? BG_ERROR : undefined);
176
+ rememberToolTitle(ctx, text, renderTitle);
177
+ text.setText(renderTitle());
154
178
  return text;
155
179
  },
156
180
 
@@ -165,19 +189,19 @@ export function registerFindTool(
165
189
  const d = r.details as FindDetails | undefined;
166
190
  if (d?._type === "findResult") {
167
191
  if (!d.text.trim()) {
192
+ const notice = d.notices?.length ? ` ${theme.fg("warning", `[${d.notices.join(". ")}]`)}` : "";
193
+ if (setCollapsedToolTitle(ctx, text, ` ${FG_DIM}0 files${RST}${notice}`)) return text;
168
194
  const noticeStr = d.notices?.length
169
195
  ? `\n${TOOL_RESULT_INDENT}${theme.fg("warning", `[${d.notices.join(". ")}]`)}`
170
196
  : "";
171
- text.setText(fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", "0 files")}${noticeStr}\n`));
197
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${theme.fg("dim", "0 files")}${noticeStr}`));
172
198
  return text;
173
199
  }
174
200
  if (!ctx.expanded) {
175
201
  const duration = renderToolDuration(r);
176
- text.setText(
177
- fillToolBackground(
178
- `${TOOL_RESULT_INDENT}${FG_DIM}${d.matchCount} files — ctrl+o to expand${RST}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n`,
179
- ),
180
- );
202
+ const summary = `${FG_DIM}${d.matchCount} files — ctrl+o to expand${RST}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}`;
203
+ if (setCollapsedToolTitle(ctx, text, ` ${summary}`)) return text;
204
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${summary}`));
181
205
  return text;
182
206
  }
183
207
  const rendered = renderFindResults(d.text, theme)
@@ -189,16 +213,14 @@ export function registerFindTool(
189
213
  : "";
190
214
  const duration = renderToolDuration(r);
191
215
  text.setText(
192
- fillToolBackground(
193
- `${TOOL_RESULT_INDENT}${theme.fg("dim", `${d.matchCount} files`)}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n${rendered}${noticeStr}\n`,
216
+ fillToolBody(
217
+ `${TOOL_RESULT_INDENT}${theme.fg("dim", `${d.matchCount} files`)}${duration ? `${FG_DIM}· ${duration}${RST}` : ""}\n${rendered}${noticeStr}`,
194
218
  ),
195
219
  );
196
220
  return text;
197
221
  }
198
222
  const fc = r.content?.[0] as TextContent | undefined;
199
- text.setText(
200
- fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", fc?.text?.slice(0, 120) ?? "0 files")}\n`),
201
- );
223
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${theme.fg("dim", fc?.text?.slice(0, 120) ?? "0 files")}`));
202
224
  return text;
203
225
  },
204
226
  } as unknown as ToolDefinition);
package/src/tools/grep.ts CHANGED
@@ -5,7 +5,13 @@ import { BG_ERROR, FG_DIM, RST, resolveBaseBackground, TOOL_RESULT_INDENT } from
5
5
  import { fffFormatGrepText } from "../fff-helpers.js";
6
6
  import { normalizeLineEndings, shortPath } from "../helpers.js";
7
7
  import { NOTICE_PARTIAL_FILE_INDEX } from "../notices.js";
8
- import { fillToolBackground, renderToolError } from "../render.js";
8
+ import {
9
+ fillToolBackground,
10
+ fillToolBody,
11
+ rememberToolTitle,
12
+ renderToolError,
13
+ setCollapsedToolTitle,
14
+ } from "../render.js";
9
15
  import { resolveTextCtor } from "../tui-text.js";
10
16
  import type { FffServiceWithCursor, GrepDetails, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
11
17
  import { wrapExecuteWithMetrics } from "./metrics.js";
@@ -40,7 +46,10 @@ export function registerGrepTool(
40
46
  const limit = typeof p.limit === "number" ? p.limit : 200;
41
47
  const literal = p.literal === true;
42
48
 
43
- if (fffService?.isAvailable && !path && !glob) {
49
+ // FFF exposes smart-case, not the SDK's explicit ignoreCase mode.
50
+ // Fall back when callers request case-insensitive matching so the SDK
51
+ // remains the semantic source of truth for that option.
52
+ if (fffService?.isAvailable && !path && !glob && p.ignoreCase !== true) {
44
53
  try {
45
54
  const fff = fffService.getFinder();
46
55
  if (!fff) throw new Error("FFF finder not available");
@@ -48,6 +57,7 @@ export function registerGrepTool(
48
57
  const grepResult = fff.grep(pattern, {
49
58
  pageSize: effectiveLimit,
50
59
  mode: literal ? "plain" : "regex",
60
+ smartCase: false,
51
61
  beforeContext: context,
52
62
  afterContext: context,
53
63
  });
@@ -68,7 +78,7 @@ export function registerGrepTool(
68
78
  content: [{ type: "text" as const, text }],
69
79
  details: {
70
80
  _type: "grepResult",
71
- text,
81
+ text: items.length > 0 ? text : "",
72
82
  pattern,
73
83
  matchCount: items.length,
74
84
  } as GrepDetails,
@@ -88,11 +98,12 @@ export function registerGrepTool(
88
98
  .filter((c) => c.type === "text")
89
99
  .map((c) => c.text)
90
100
  .join("\n") ?? "";
101
+ const normalizedText = tc.trim() === "No matches found" ? "" : tc;
91
102
  result.details = {
92
103
  _type: "grepResult",
93
- text: tc,
104
+ text: normalizedText,
94
105
  pattern,
95
- matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0,
106
+ matchCount: normalizedText ? normalizedText.trim().split("\n").filter(Boolean).length : 0,
96
107
  } as GrepDetails;
97
108
  return result;
98
109
  }),
@@ -111,7 +122,10 @@ export function registerGrepTool(
111
122
  if (limit !== undefined && limit !== null) out += theme.fg("dim", ` limit ${limit}`);
112
123
  if (literal) out += theme.fg("dim", ` (literal)`);
113
124
  if (caseInsensitive) out += theme.fg("dim", ` (case-insensitive)`);
114
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
125
+ const renderTitle = (suffix = ""): string =>
126
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}${suffix}\n`, ctx.isError ? BG_ERROR : undefined);
127
+ rememberToolTitle(ctx, text, renderTitle);
128
+ text.setText(renderTitle());
115
129
  return text;
116
130
  },
117
131
 
@@ -134,12 +148,9 @@ export function registerGrepTool(
134
148
  if (d?._type === "grepResult" && d.text) {
135
149
  const lines = d.text.split("\n");
136
150
  if (!ctx.expanded) {
137
- text.setText(
138
- fillToolBackground(
139
- `${TOOL_RESULT_INDENT}${FG_DIM}${lines.length} lines — ctrl+o to expand${RST}\n`,
140
- ctx.isError ? BG_ERROR : undefined,
141
- ),
142
- );
151
+ const summary = `${FG_DIM}${lines.length} lines — ctrl+o to expand${RST}`;
152
+ if (setCollapsedToolTitle(ctx, text, ` ${summary}`)) return text;
153
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${summary}`, ctx.isError ? BG_ERROR : undefined));
143
154
  return text;
144
155
  }
145
156
  const maxShow = lines.length;
@@ -153,14 +164,14 @@ export function registerGrepTool(
153
164
  if (remaining > 0) {
154
165
  out.push(theme.fg("muted", `… (${remaining} more ${remaining === 1 ? "line" : "lines"}, to expand)`));
155
166
  }
156
- const body = `${out.map((l) => `${TOOL_RESULT_INDENT}${l}`).join("\n")}\n\n`;
157
- text.setText(fillToolBackground(body, ctx.isError ? BG_ERROR : undefined));
167
+ const body = out.map((l) => `${TOOL_RESULT_INDENT}${l}`).join("\n");
168
+ text.setText(fillToolBody(body, ctx.isError ? BG_ERROR : undefined));
158
169
  return text;
159
170
  }
160
171
  const fc = result.content?.[0];
161
172
  const fallback = fc && "text" in fc ? String(fc.text).slice(0, 120) : "no matches";
162
173
  text.setText(
163
- fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", fallback)}`, ctx.isError ? BG_ERROR : undefined),
174
+ fillToolBody(`${TOOL_RESULT_INDENT}${theme.fg("dim", fallback)}`, ctx.isError ? BG_ERROR : undefined),
164
175
  );
165
176
  return text;
166
177
  },
package/src/tools/ls.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
4
  import { BG_ERROR, FG_DIM, RST, resolveBaseBackground, TOOL_RESULT_INDENT } from "../config.js";
5
5
  import { shortPath } from "../helpers.js";
6
- import { fillToolBackground, renderToolError, renderToolMetrics, renderTree } from "../render.js";
6
+ import {
7
+ fillToolBackground,
8
+ fillToolBody,
9
+ rememberToolTitle,
10
+ renderToolError,
11
+ renderToolMetrics,
12
+ renderTree,
13
+ setCollapsedToolTitle,
14
+ } from "../render.js";
7
15
  import { resolveTextCtor } from "../tui-text.js";
8
16
  import type { LsDetails, RenderCtxLike, SdkToolDef, TextContent, ThemeLike } from "../types.js";
9
17
  import { wrapExecuteWithMetrics } from "./metrics.js";
@@ -25,6 +33,7 @@ export function registerLsTool(
25
33
  label: "List",
26
34
  description: sdkTool.description ?? "List directory contents",
27
35
  parameters: sdkTool.parameters,
36
+ promptSnippet: sdkTool.promptSnippet,
28
37
  renderShell: "self",
29
38
 
30
39
  execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
@@ -51,7 +60,10 @@ export function registerLsTool(
51
60
  let out = theme.fg("toolTitle", theme.bold("ls"));
52
61
  if (path) out += ` ${theme.fg("accent", path)}`;
53
62
  if (limit !== undefined && limit !== null) out += theme.fg("toolOutput", ` (limit ${limit})`);
54
- text.setText(fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}`, ctx.isError ? BG_ERROR : undefined));
63
+ const renderTitle = (suffix = ""): string =>
64
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}${suffix}\n`, ctx.isError ? BG_ERROR : undefined);
65
+ rememberToolTitle(ctx, text, renderTitle);
66
+ text.setText(renderTitle());
55
67
  return text;
56
68
  },
57
69
 
@@ -66,11 +78,9 @@ export function registerLsTool(
66
78
  const d = result.details as LsDetails | undefined;
67
79
  if (d?._type === "lsResult" && d.text) {
68
80
  if (!ctx.expanded) {
69
- text.setText(
70
- fillToolBackground(
71
- `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries — ctrl+o to expand${RST}${renderToolMetrics(result)}\n`,
72
- ),
73
- );
81
+ const summary = `${FG_DIM}${d.entryCount} entries — ctrl+o to expand${RST}${renderToolMetrics(result)}`;
82
+ if (setCollapsedToolTitle(ctx, text, ` ${summary}`)) return text;
83
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${summary}`));
74
84
  return text;
75
85
  }
76
86
  const rendered = renderTree(d.text, d.path)
@@ -78,15 +88,15 @@ export function registerLsTool(
78
88
  .map((l) => `${TOOL_RESULT_INDENT}${l}`)
79
89
  .join("\n");
80
90
  text.setText(
81
- fillToolBackground(
82
- `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}\n`,
91
+ fillToolBody(
92
+ `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}`,
83
93
  ),
84
94
  );
85
95
  return text;
86
96
  }
87
97
  const fc = result.content?.[0];
88
98
  text.setText(
89
- fillToolBackground(
99
+ fillToolBody(
90
100
  `${TOOL_RESULT_INDENT}${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`,
91
101
  ),
92
102
  );
@@ -15,19 +15,39 @@ type ExecuteFn = (
15
15
  ctx: ExtensionContext,
16
16
  ) => Promise<AgentToolResult<Record<string, unknown>>>;
17
17
 
18
- export function wrapExecuteWithMetrics(execute: ExecuteFn): ExecuteFn {
18
+ export interface RejectedExecutionMetrics {
19
+ elapsedMs: number;
20
+ chars: number;
21
+ }
22
+
23
+ export type RejectedExecutionHandler = (toolCallId: string, metrics: RejectedExecutionMetrics) => void;
24
+
25
+ export function wrapExecuteWithMetrics(execute: ExecuteFn, onRejected?: RejectedExecutionHandler): ExecuteFn {
19
26
  return async (tid, params, sig, upd, ctx) => {
20
27
  const start = performance.now();
21
- const result = await execute(tid, params, sig, upd, ctx);
22
- const elapsedMs = performance.now() - start;
23
- const details = (result.details ?? {}) as Record<string, unknown>;
24
- details[ELAPSED_KEY] = elapsedMs;
25
- details[CHARS_KEY] = getOutputCharCount(result);
26
- (result as { details: Record<string, unknown> }).details = details;
27
- return result;
28
+ try {
29
+ const result = await execute(tid, params, sig, upd, ctx);
30
+ const elapsedMs = performance.now() - start;
31
+ const details = (result.details ?? {}) as Record<string, unknown>;
32
+ details[ELAPSED_KEY] = elapsedMs;
33
+ details[CHARS_KEY] = getOutputCharCount(result);
34
+ (result as { details: Record<string, unknown> }).details = details;
35
+ return result;
36
+ } catch (error) {
37
+ onRejected?.(tid, {
38
+ elapsedMs: performance.now() - start,
39
+ chars: getErrorCharCount(error),
40
+ });
41
+ throw error;
42
+ }
28
43
  };
29
44
  }
30
45
 
46
+ function getErrorCharCount(error: unknown): number {
47
+ const text = error instanceof Error ? error.message : String(error);
48
+ return text.replace(/\r/g, "").length;
49
+ }
50
+
31
51
  function getOutputCharCount(result: AgentToolResult<unknown>): number {
32
52
  const content = result.content;
33
53
  if (!Array.isArray(content)) return 0;