@heyhuynhgiabuu/pi-pretty 0.6.26 → 0.6.27

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 +18 -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 +73 -30
  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 +10 -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 +94 -31
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +9 -3
  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 +94 -32
  55. package/src/tools/find.ts +39 -17
  56. package/src/tools/grep.ts +26 -15
  57. package/src/tools/ls.ts +19 -10
  58. package/src/tools/metrics.ts +28 -8
  59. package/src/tools/read.ts +123 -31
  60. package/src/types.ts +10 -3
  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,
@@ -33,23 +90,12 @@ export function registerBashTool(
33
90
  parameters: sdkTool.parameters,
34
91
  renderShell: "self",
35
92
 
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
- }),
93
+ execute: wrapExecuteWithMetrics(async (tid, params, sig, upd, ctx: ExtensionContext) => {
94
+ // Let the host derive the tool-error flag from the rejected execution.
95
+ // AgentToolResult has no portable `isError` field, so converting a bash
96
+ // failure into a successful-looking result hides the failure from the model.
97
+ return (await sdkTool.execute(tid, params, sig, upd, ctx)) as Result;
98
+ }, rememberRejectedBashMetrics),
53
99
 
54
100
  renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
55
101
  resolveBaseBackground(theme);
@@ -65,9 +111,14 @@ export function registerBashTool(
65
111
  ? `${rawCmd.slice(0, Math.max(1, headerBudget))}…`
66
112
  : rawCmd;
67
113
  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
- );
114
+ const renderTitle = (suffix = ""): string =>
115
+ fillToolBackground(
116
+ `\n${TOOL_RESULT_INDENT}${commandLabel}${t}${suffix}\n`,
117
+ undefined,
118
+ ctx.expanded ? undefined : tw,
119
+ );
120
+ rememberToolTitle(ctx, text, renderTitle);
121
+ text.setText(renderTitle());
71
122
  return text;
72
123
  },
73
124
 
@@ -75,9 +126,12 @@ export function registerBashTool(
75
126
  resolveBaseBackground(theme);
76
127
 
77
128
  const text = ctx.lastComponent ?? new TC("", 0, 0);
129
+ restoreBashResultRender(ctx, text as ComponentLike);
130
+ const rejectedMetrics = takeRejectedBashMetrics(ctx);
131
+ const displayResult = rejectedMetrics ? addRejectedMetrics(result, rejectedMetrics) : result;
78
132
 
79
- const details = result.details;
80
- const tc = getText(result);
133
+ const details = displayResult.details;
134
+ const tc = getText(displayResult);
81
135
  const d: BashDetails | undefined =
82
136
  (details as BashDetails)?._type === "bashResult"
83
137
  ? (details as BashDetails)
@@ -95,29 +149,37 @@ export function registerBashTool(
95
149
  const cleaned = stripBashExitStatusLine(d.text);
96
150
  const output = isErr ? compactErrorLines(cleaned).join("\n") : cleaned;
97
151
  const lineCount = output.split("\n").length;
98
- const info = [`${lineCount} lines`, renderToolDuration(result), !ctx.expanded ? "ctrl+o to expand" : ""]
152
+ const info = [
153
+ `${lineCount} lines`,
154
+ renderToolDuration(displayResult),
155
+ rejectedMetrics ? formatCharCount(rejectedMetrics.chars) : "",
156
+ !ctx.expanded ? "ctrl+o to expand" : "",
157
+ ]
99
158
  .filter(Boolean)
100
159
  .map((part) => theme.fg("dim", part))
101
160
  .join(theme.fg("dim", " · "));
102
161
  const header = `${TOOL_RESULT_INDENT}${info}`;
103
162
  const rw = termWidth();
104
163
 
164
+ if (setCollapsedToolTitle(ctx, text, ` ${info}`)) return text;
165
+
105
166
  const renderFn = (w: number) => {
106
- if (!ctx.expanded) return fillToolBackground(`${header}\n`, undefined, w);
107
- if (!output.trim()) return fillToolBackground(`${header}\n`, undefined, w);
167
+ if (!ctx.expanded) return fillToolBody(header, undefined, w);
168
+ if (!output.trim()) return fillToolBody(header, undefined, w);
108
169
  const show = output.split("\n");
109
170
  const out = [header, "", ...show.map((line: string) => `${TOOL_RESULT_INDENT}${line}`)];
110
- return fillToolBackground(`${out.join("\n")}\n`, undefined, w);
171
+ return fillToolBody(out.join("\n"), undefined, w);
111
172
  };
112
173
 
113
174
  text.setText(renderFn(rw));
114
175
  const baseRender =
115
176
  typeof (text as ComponentLike).render === "function" ? (text as ComponentLike).render.bind(text) : null;
116
177
  if (baseRender) {
178
+ ctx.state[BASH_RESULT_RENDER_KEY] = baseRender;
117
179
  let key: string | undefined;
118
180
  (text as unknown as Record<string, unknown>).render = (w: number) => {
119
181
  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)}`;
182
+ const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolDuration(displayResult)}`;
121
183
  if (key !== k) {
122
184
  text.setText(renderFn(width));
123
185
  key = k;
@@ -132,9 +194,9 @@ export function registerBashTool(
132
194
  text.setText(renderToolError(tc || "Error", theme));
133
195
  return text;
134
196
  }
135
- const fc = result.content?.[0];
197
+ const fc = displayResult.content?.[0];
136
198
  text.setText(
137
- fillToolBackground(
199
+ fillToolBody(
138
200
  `${TOOL_RESULT_INDENT}${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`,
139
201
  ),
140
202
  );
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";
@@ -51,7 +59,10 @@ export function registerLsTool(
51
59
  let out = theme.fg("toolTitle", theme.bold("ls"));
52
60
  if (path) out += ` ${theme.fg("accent", path)}`;
53
61
  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));
62
+ const renderTitle = (suffix = ""): string =>
63
+ fillToolBackground(`\n${TOOL_RESULT_INDENT}${out}${suffix}\n`, ctx.isError ? BG_ERROR : undefined);
64
+ rememberToolTitle(ctx, text, renderTitle);
65
+ text.setText(renderTitle());
55
66
  return text;
56
67
  },
57
68
 
@@ -66,11 +77,9 @@ export function registerLsTool(
66
77
  const d = result.details as LsDetails | undefined;
67
78
  if (d?._type === "lsResult" && d.text) {
68
79
  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
- );
80
+ const summary = `${FG_DIM}${d.entryCount} entries — ctrl+o to expand${RST}${renderToolMetrics(result)}`;
81
+ if (setCollapsedToolTitle(ctx, text, ` ${summary}`)) return text;
82
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${summary}`));
74
83
  return text;
75
84
  }
76
85
  const rendered = renderTree(d.text, d.path)
@@ -78,15 +87,15 @@ export function registerLsTool(
78
87
  .map((l) => `${TOOL_RESULT_INDENT}${l}`)
79
88
  .join("\n");
80
89
  text.setText(
81
- fillToolBackground(
82
- `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}\n`,
90
+ fillToolBody(
91
+ `${TOOL_RESULT_INDENT}${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}`,
83
92
  ),
84
93
  );
85
94
  return text;
86
95
  }
87
96
  const fc = result.content?.[0];
88
97
  text.setText(
89
- fillToolBackground(
98
+ fillToolBody(
90
99
  `${TOOL_RESULT_INDENT}${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`,
91
100
  ),
92
101
  );
@@ -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;