@quandev104/pi-style 0.1.2 → 0.1.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5510 -4865
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -2
  6. package/extension-src/pi-style/app/index.ts +0 -1
  7. package/extension-src/pi-style/domain/config-authorization.ts +1 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -3
  9. package/extension-src/pi-style/domain/config-types.ts +2 -2
  10. package/extension-src/pi-style/domain/theme.ts +3 -0
  11. package/extension-src/pi-style/features/messages/index.ts +2 -8
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +384 -0
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  14. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  15. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  16. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  17. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  18. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  19. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  20. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  21. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  22. package/extension-src/pi-style/features/tools/index.ts +14 -0
  23. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  24. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  25. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  26. package/extension-src/pi-style/pi/config-session.ts +0 -2
  27. package/extension-src/pi-style/pi/index.ts +35 -1
  28. package/extension-src/pi-style/pi/session-coordinator.ts +39 -3
  29. package/extension-src/pi-style/shared/box.ts +41 -7
  30. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  31. package/package.json +1 -1
@@ -0,0 +1,459 @@
1
+ // Consecutive quiet-tool (read/ls/find) call batching.
2
+ //
3
+ // Groups back-to-back calls of the same quiet tool inside one assistant turn
4
+ // into a single collapsible, **boxless** tree panel instead of one boxed panel
5
+ // per call. The first call of a batch becomes its leader: the leader's call
6
+ // component renders the whole panel (header + tree), reading the live batch
7
+ // state on every render pass. Subsequent members render zero lines, so they
8
+ // consume no vertical space.
9
+ //
10
+ // Design notes:
11
+ // - No caching in the batch panel: it reads the module-level registry on every
12
+ // render, so member completions (which trigger ui.requestRender via Pi's
13
+ // tool_execution_end handler) are picked up without cross-component
14
+ // invalidation plumbing.
15
+ // - Batch boundaries: a new batch starts when the active batch is closed. The
16
+ // active batch closes when a non-batchable tool call is dispatched
17
+ // (boxed/index.ts), when a new message starts (pi/index.ts), and on session
18
+ // reset (session-coordinator.ts). Lone calls render the same boxless tree
19
+ // (a batch of one) — there is no boxed single-call special case.
20
+ // - No surrounding box: indentation and tree glyphs (├─/└─) carry the
21
+ // hierarchy; the header line is the summary (` Read (N) · 0.08s`).
22
+ // - Errors stay visible: failed members are always rendered inline (even in the
23
+ // collapsed state), with their error text indented beneath the path.
24
+ // - read members render a single path row. ls/find members render their parsed
25
+ // output as a file subtree (flat for a lone call, nested per member when
26
+ // batched) — see renderOutputBatchPanel. Pending/failed members without output
27
+ // fall back to the path row.
28
+
29
+ import type { Component } from "@earendil-works/pi-tui";
30
+ import { stripAnsi } from "../../../shared/ansi.js";
31
+ import { type BoxTheme, formatToolTitlePrefix } from "../../../shared/box.js";
32
+ import { safeTruncateToWidth } from "../../../shared/render-budget.js";
33
+ import {
34
+ fileIcon,
35
+ OUTPUT_TREE_HEAD_LIMIT,
36
+ pluralForm,
37
+ renderOutputTree,
38
+ SEARCH_ICON,
39
+ TREE_CHILD_INDENT,
40
+ TREE_INDENT,
41
+ } from "./output-tree.js";
42
+ import { getToolsRenderConfig } from "./session-config.js";
43
+ import type { BoxedToolContext } from "./shared.js";
44
+
45
+ /** Quiet tools whose calls group into a single batch panel. */
46
+ export const BATCHABLE_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "ls", "find"]);
47
+
48
+ export function isBatchableTool(toolName: unknown): boolean {
49
+ return typeof toolName === "string" && BATCHABLE_TOOL_NAMES.has(toolName);
50
+ }
51
+
52
+ export interface BatchToolMeta {
53
+ readonly toolName: string;
54
+ /** Human label shown in the batch header (e.g. "Read", "List", "Find"). */
55
+ readonly label: string;
56
+ /** Header label for output-tree panels: "Glob" for find, "List" for ls. */
57
+ readonly headerLabel?: string;
58
+ }
59
+
60
+ export type BatchMemberStatus = "pending" | "running" | "done";
61
+
62
+ export interface BatchMember {
63
+ readonly toolCallId: string;
64
+ detail: string;
65
+ status: BatchMemberStatus;
66
+ isError: boolean;
67
+ errorText?: string;
68
+ /** find glob pattern (header detail for output panels). */
69
+ pattern?: string;
70
+ /** Display path (header detail for output panels). */
71
+ pathLabel?: string;
72
+ /** Parsed output entries once the result arrives (ls/find). `undefined` until
73
+ * the result is registered; an empty array means a successful zero-entry
74
+ * result (e.g. an empty directory). */
75
+ outputEntries?: string[];
76
+ }
77
+
78
+ export interface BatchState {
79
+ readonly meta: BatchToolMeta;
80
+ readonly leaderId: string;
81
+ readonly startedAt: number;
82
+ completedAt?: number;
83
+ closed: boolean;
84
+ readonly members: BatchMember[];
85
+ }
86
+
87
+ /** Tree head limit: only the first few members are listed, the rest collapse. */
88
+ const BATCH_TREE_HEAD_LIMIT = 5;
89
+ /** Per-member file subtree head limit in a batched output panel. */
90
+ const BATCH_MEMBER_FILE_HEAD_LIMIT = 4;
91
+ const BATCH_ERROR_LINES = 2;
92
+ /** Indent for tree lines below the header. */
93
+ const BATCH_TREE_INDENT = TREE_INDENT;
94
+
95
+ /** Component rendered for non-leader batch members (zero height). */
96
+ export const EMPTY_BATCH_COMPONENT: Component = Object.freeze({
97
+ invalidate() {},
98
+ render() {
99
+ return [];
100
+ },
101
+ });
102
+
103
+ let activeBatch: BatchState | undefined;
104
+ const batchByCallId = new Map<string, BatchState>();
105
+
106
+ /** Close the current batch: no new members join; existing panels keep rendering. */
107
+ export function closeActiveBatch(): void {
108
+ if (!activeBatch) return;
109
+ activeBatch.closed = true;
110
+ activeBatch = undefined;
111
+ }
112
+
113
+ /** Reset all batch state (session start/shutdown). */
114
+ export function resetBatchRegistry(): void {
115
+ activeBatch = undefined;
116
+ batchByCallId.clear();
117
+ }
118
+
119
+ function createBatch(
120
+ meta: BatchToolMeta,
121
+ leaderId: string,
122
+ detail: string,
123
+ opts: { pattern?: string; pathLabel?: string } = {},
124
+ ): BatchState {
125
+ const batch: BatchState = {
126
+ meta,
127
+ leaderId,
128
+ startedAt: performance.now(),
129
+ closed: false,
130
+ members: [
131
+ {
132
+ toolCallId: leaderId,
133
+ detail,
134
+ status: "pending",
135
+ isError: false,
136
+ ...(opts.pattern ? { pattern: opts.pattern } : {}),
137
+ ...(opts.pathLabel ? { pathLabel: opts.pathLabel } : {}),
138
+ },
139
+ ],
140
+ };
141
+ activeBatch = batch;
142
+ batchByCallId.set(leaderId, batch);
143
+ return batch;
144
+ }
145
+
146
+ /**
147
+ * Register a call renderer invocation. Idempotent per toolCallId: re-fires
148
+ * (updateDisplay on the same component) reuse the call's existing batch, even
149
+ * after the batch was closed.
150
+ */
151
+ export function registerBatchCall(
152
+ meta: BatchToolMeta,
153
+ detail: string,
154
+ context: BoxedToolContext,
155
+ opts: { pattern?: string; pathLabel?: string } = {},
156
+ ): { batch: BatchState; isLeader: boolean } {
157
+ const existing = batchByCallId.get(context.toolCallId);
158
+ if (existing) {
159
+ const member = existing.members.find((entry) => entry.toolCallId === context.toolCallId);
160
+ if (member) {
161
+ member.detail = detail;
162
+ if (opts.pattern !== undefined) member.pattern = opts.pattern;
163
+ if (opts.pathLabel !== undefined) member.pathLabel = opts.pathLabel;
164
+ }
165
+ return { batch: existing, isLeader: existing.leaderId === context.toolCallId };
166
+ }
167
+ const current = activeBatch;
168
+ if (!current || current.closed || current.meta.toolName !== meta.toolName) {
169
+ closeActiveBatch();
170
+ return { batch: createBatch(meta, context.toolCallId, detail, opts), isLeader: true };
171
+ }
172
+ const member: BatchMember = {
173
+ toolCallId: context.toolCallId,
174
+ detail,
175
+ status: "pending",
176
+ isError: false,
177
+ ...(opts.pattern ? { pattern: opts.pattern } : {}),
178
+ ...(opts.pathLabel ? { pathLabel: opts.pathLabel } : {}),
179
+ };
180
+ current.members.push(member);
181
+ batchByCallId.set(context.toolCallId, current);
182
+ return { batch: current, isLeader: false };
183
+ }
184
+
185
+ export interface BatchResultData {
186
+ readonly isPartial: boolean;
187
+ readonly isError: boolean;
188
+ readonly errorText: string | undefined;
189
+ /** Parsed output entries (ls/find) stored on the member for tree rendering. */
190
+ readonly entries?: string[];
191
+ }
192
+
193
+ /**
194
+ * Register a result renderer invocation: updates the member's status/metadata
195
+ * and records batch completion once every member has settled. The member's
196
+ * display detail stays as registered by the call renderer (the result context's
197
+ * args may be normalized differently).
198
+ */
199
+ export function registerBatchResult(
200
+ meta: BatchToolMeta,
201
+ data: BatchResultData,
202
+ context: BoxedToolContext,
203
+ ): { batch: BatchState | undefined; isLeader: boolean } {
204
+ const batch = batchByCallId.get(context.toolCallId);
205
+ if (!batch || batch.meta.toolName !== meta.toolName) return { batch: undefined, isLeader: false };
206
+ const member = batch.members.find((entry) => entry.toolCallId === context.toolCallId);
207
+ if (member) {
208
+ member.status = data.isPartial ? "running" : "done";
209
+ member.isError = !data.isPartial && data.isError;
210
+ if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
211
+ else delete member.errorText;
212
+ if (data.entries !== undefined) member.outputEntries = data.entries;
213
+ }
214
+ if (batch.completedAt === undefined && batch.members.every((entry) => entry.status === "done")) {
215
+ batch.completedAt = performance.now();
216
+ }
217
+ return { batch, isLeader: batch.leaderId === context.toolCallId };
218
+ }
219
+
220
+ interface BatchStatus {
221
+ readonly total: number;
222
+ readonly done: number;
223
+ readonly failed: number;
224
+ readonly allDone: boolean;
225
+ readonly elapsedMs: number | undefined;
226
+ }
227
+
228
+ function batchStatus(batch: BatchState): BatchStatus {
229
+ let done = 0;
230
+ let failed = 0;
231
+ for (const member of batch.members) {
232
+ if (member.status !== "done") continue;
233
+ done++;
234
+ if (member.isError) failed++;
235
+ }
236
+ const total = batch.members.length;
237
+ const allDone = done === total;
238
+ return {
239
+ total,
240
+ done,
241
+ failed,
242
+ allDone,
243
+ elapsedMs: allDone && batch.completedAt !== undefined ? batch.completedAt - batch.startedAt : undefined,
244
+ };
245
+ }
246
+
247
+ function formatElapsed(theme: BoxTheme, elapsedMs: number): string {
248
+ return theme.fg("dim", ` · ${(elapsedMs / 1000).toFixed(2)}s`);
249
+ }
250
+
251
+ function bold(theme: BoxTheme, text: string): string {
252
+ return typeof theme?.bold === "function" ? theme.bold(text) : text;
253
+ }
254
+
255
+ function isOutputTool(meta: BatchToolMeta): boolean {
256
+ return meta.toolName === "ls" || meta.toolName === "find";
257
+ }
258
+
259
+ /** Header line: state glyph + batch label(count) + progress/elapsed (no box). */
260
+ function formatBatchHeader(theme: BoxTheme, batch: BatchState, status: BatchStatus): string {
261
+ const label = `${batch.meta.headerLabel ?? batch.meta.label} (${status.total})`;
262
+ if (status.failed > 0) return theme.fg("error", bold(theme, `✗ ${label} · ${status.failed} failed`));
263
+ if (status.allDone) {
264
+ const glyph = getToolsRenderConfig().batchOpenGlyph;
265
+ const elapsed = status.elapsedMs === undefined ? "" : formatElapsed(theme, status.elapsedMs);
266
+ return `${theme.fg("text", bold(theme, `${glyph} ${label}`))}${elapsed}`;
267
+ }
268
+ if (status.done > 0)
269
+ return `${theme.fg("text", bold(theme, `◌ ${label}`))}${theme.fg("dim", ` · ${status.done}/${status.total}`)}`;
270
+ return bold(theme, formatToolTitlePrefix(theme, label));
271
+ }
272
+
273
+ function memberGlyph(theme: BoxTheme, member: BatchMember, show: boolean): string {
274
+ if (!show) return "";
275
+ if (member.isError) return theme.fg("error", "✗");
276
+ if (member.status === "done") return theme.fg("success", "✓");
277
+ return theme.fg("text", "◌");
278
+ }
279
+
280
+ function renderErrorLines(theme: BoxTheme, errorText: string, width: number): string[] {
281
+ const raw = stripAnsi(errorText)
282
+ .split("\n")
283
+ .map((line) => line.trim())
284
+ .filter((line) => line.length > 0);
285
+ if (raw.length === 0) return [];
286
+ const prefix = `${theme.fg("borderMuted", " │ ")}`;
287
+ const out = raw
288
+ .slice(0, BATCH_ERROR_LINES)
289
+ .map((line) => safeTruncateToWidth(`${prefix}${theme.fg("error", line)}`, Math.max(1, width), "…"));
290
+ if (raw.length > BATCH_ERROR_LINES)
291
+ out.push(safeTruncateToWidth(`${prefix}${theme.fg("error", "…")}`, Math.max(1, width), "…"));
292
+ return out;
293
+ }
294
+
295
+ function renderBatchTree(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
296
+ const showGlyphs = !status.allDone || status.failed > 0;
297
+ const visible = batch.members.slice(0, BATCH_TREE_HEAD_LIMIT);
298
+ const more = batch.members.length - visible.length;
299
+ const lastIndex = visible.length - 1;
300
+ const out: string[] = [];
301
+ for (let i = 0; i < visible.length; i++) {
302
+ const member = visible[i];
303
+ if (!member) continue;
304
+ const branch = i < lastIndex || more > 0 ? "├─" : "└─";
305
+ const glyph = memberGlyph(theme, member, showGlyphs);
306
+ // Primary color for files read successfully, error red for failures.
307
+ const pathColor = member.isError ? "error" : member.status === "done" ? "accent" : "text";
308
+ const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", branch)}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`;
309
+ out.push(safeTruncateToWidth(line, Math.max(1, width), "…"));
310
+ if (member.isError && member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
311
+ }
312
+ if (more > 0) {
313
+ out.push(
314
+ safeTruncateToWidth(
315
+ `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
316
+ Math.max(1, width),
317
+ "…",
318
+ ),
319
+ );
320
+ }
321
+ return out;
322
+ }
323
+
324
+ /** Header for a lone (batch-of-one) ls/find output panel: `Glob: <pattern> <N> files · in <path>`. */
325
+ function formatLoneOutputHeader(theme: BoxTheme, meta: BatchToolMeta, member: BatchMember): string {
326
+ const label = meta.headerLabel ?? meta.label;
327
+ const count = member.outputEntries?.length ?? 0;
328
+ const filesPart = theme.fg("accent", `${count} ${count === 1 ? "file" : "files"}`);
329
+ const patternPart = meta.toolName === "find" && member.pattern ? `${theme.fg("text", member.pattern)} ` : "";
330
+ const pathPart = member.pathLabel ? theme.fg("dim", ` · in ${member.pathLabel}`) : "";
331
+ // ls/find headers carry the magnifying-glass icon in Nerd Font mode,
332
+ // matching find/grep.
333
+ const icon = getToolsRenderConfig().nerdFonts ? `${SEARCH_ICON} ` : "";
334
+ return `${icon}${bold(theme, `${label}:`)} ${patternPart}${filesPart}${pathPart}`;
335
+ }
336
+
337
+ /** Nested file subtree for one member inside a batched (2+) output panel. */
338
+ function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember: boolean, width: number): string[] {
339
+ const safeWidth = Math.max(1, width);
340
+ const trunk = isLastMember ? " " : theme.fg("borderMuted", "│");
341
+ const out: string[] = [];
342
+
343
+ // Member header row: path + file count (or status glyph when not done).
344
+ const entries = member.outputEntries ?? [];
345
+ if (member.isError) {
346
+ const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("error", "✗")} ${theme.fg("error", member.pathLabel ?? member.detail)}`;
347
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
348
+ if (member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
349
+ return out;
350
+ }
351
+ if (member.status !== "done" || member.outputEntries === undefined) {
352
+ const glyph = member.status === "done" ? theme.fg("success", "✓") : theme.fg("text", "◌");
353
+ const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${glyph} ${theme.fg("text", member.pathLabel ?? member.detail)}`;
354
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
355
+ return out;
356
+ }
357
+
358
+ const countLabel = theme.fg("dim", ` · ${entries.length} ${pluralForm("file", entries.length)}`);
359
+ const headerLine = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("accent", member.pathLabel ?? member.detail)}${countLabel}`;
360
+ out.push(safeTruncateToWidth(headerLine, safeWidth, "…"));
361
+
362
+ const visible = entries.slice(0, BATCH_MEMBER_FILE_HEAD_LIMIT);
363
+ const more = entries.length - visible.length;
364
+ const lastIndex = visible.length - 1;
365
+ const icons = getToolsRenderConfig().nerdFonts;
366
+ for (let i = 0; i < visible.length; i++) {
367
+ const entry = visible[i] ?? "";
368
+ const label = icons && entry ? `${fileIcon(entry)} ${entry}` : entry;
369
+ const branch = i < lastIndex || more > 0 ? "├─" : "└─";
370
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", branch)} ${theme.fg("toolOutput", label)}`;
371
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
372
+ }
373
+ if (more > 0) {
374
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`;
375
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /** ls/find output panel: lone call renders a flat tree; a batch renders nested subtrees. */
381
+ function renderOutputBatchPanel(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
382
+ const safeWidth = Math.max(1, width);
383
+
384
+ // Lone successful call with output: flat tree under a `Glob:/List:` header.
385
+ if (batch.members.length === 1) {
386
+ const member = batch.members[0];
387
+ if (member && member.outputEntries !== undefined && !member.isError) {
388
+ const header = safeTruncateToWidth(formatLoneOutputHeader(theme, batch.meta, member), safeWidth, "…");
389
+ return renderOutputTree(theme, header, member.outputEntries, safeWidth, {
390
+ headLimit: OUTPUT_TREE_HEAD_LIMIT,
391
+ moreUnit: "file",
392
+ entryColor: "toolOutput",
393
+ indent: BATCH_TREE_INDENT,
394
+ withIcons: getToolsRenderConfig().nerdFonts,
395
+ });
396
+ }
397
+ // Pending/error/empty-without-entries: fall through to the path-only panel.
398
+ }
399
+
400
+ // Batched (2+) or a not-yet-ready lone call: per-member rows/subtrees.
401
+ const header = safeTruncateToWidth(formatBatchHeader(theme, batch, status), safeWidth, "…");
402
+ const out: string[] = [header];
403
+ const visible = batch.members.slice(0, BATCH_TREE_HEAD_LIMIT);
404
+ const more = batch.members.length - visible.length;
405
+ visible.forEach((member, index) => {
406
+ const isLast = index === visible.length - 1 && more <= 0;
407
+ out.push(...renderMemberSubtree(theme, member, isLast, safeWidth));
408
+ });
409
+ if (more > 0) {
410
+ out.push(
411
+ safeTruncateToWidth(
412
+ `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
413
+ safeWidth,
414
+ "…",
415
+ ),
416
+ );
417
+ }
418
+ return out;
419
+ }
420
+
421
+ function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
422
+ // The tree stays open in every state, including for a lone call: no boxed
423
+ // single-call special case, no collapsed single-line summary.
424
+ if (isOutputTool(batch.meta) && batch.members.some((member) => member.outputEntries !== undefined)) {
425
+ return renderOutputBatchPanel(theme, batch, status, width);
426
+ }
427
+ const header = safeTruncateToWidth(formatBatchHeader(theme, batch, status), Math.max(1, width), "…");
428
+ const lines = [header];
429
+ lines.push(...renderBatchTree(theme, batch, status, width));
430
+ return lines;
431
+ }
432
+
433
+ /**
434
+ * Leader call component: renders the live batch panel (header + tree) reading
435
+ * the registry on every render pass. Members render EMPTY_BATCH_COMPONENT.
436
+ */
437
+ export function renderBatchAwareCall(theme: BoxTheme, batch: BatchState): Component {
438
+ return {
439
+ invalidate() {},
440
+ render(width: number): string[] {
441
+ return renderBatchPanelLines(theme, batch, batchStatus(batch), width);
442
+ },
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Empty result component for the batch leader. The panel lives in the call
448
+ * component; the result adds nothing. Deliberately NOT the shared member
449
+ * singleton, so the decoration's hideBatchMember (identity-compared to
450
+ * EMPTY_BATCH_COMPONENT) never hides the leader.
451
+ */
452
+ export function emptyBatchResult(): Component {
453
+ return {
454
+ invalidate() {},
455
+ render() {
456
+ return [];
457
+ },
458
+ };
459
+ }
@@ -1,65 +1,65 @@
1
1
  // Boxed find tool renderer.
2
+ //
3
+ // find calls render as a boxless tree panel — a lone find shows its parsed
4
+ // output as a flat `Glob: <pattern> <N> files · in <path>` tree; consecutive
5
+ // find calls group into one panel with per-member nested subtrees (see
6
+ // batch.ts). Pending/failed calls without output fall back to a path row.
2
7
 
3
8
  import { stripAnsi } from "../../../shared/ansi.js";
9
+ import { getTextOutput, shortenPath } from "../../../shared/box.js";
4
10
  import {
5
- boxedToolWidthKey,
6
- countLines,
7
- getTextOutput,
8
- renderBoxedToolResult,
9
- shortenPath,
10
- stripTrailingNotice,
11
- } from "../../../shared/box.js";
12
- import {
13
- type BoxedToolDefinition,
14
- clearFooterState,
15
- compactCall,
16
- compactFooterWithState,
17
- noteExecutionStart,
18
- resultFooterLines,
19
- truncationOutputLines,
20
- } from "./shared.js";
11
+ type BatchToolMeta,
12
+ EMPTY_BATCH_COMPONENT,
13
+ emptyBatchResult,
14
+ registerBatchCall,
15
+ registerBatchResult,
16
+ renderBatchAwareCall,
17
+ } from "./batch.js";
18
+ import { parseFindOutput } from "./output-tree.js";
19
+ import { type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
21
20
 
22
- function queryDetail(pattern: string, rawPath: string): string {
21
+ const FIND_META: BatchToolMeta = Object.freeze({
22
+ toolName: "find",
23
+ label: "Find",
24
+ headerLabel: "Glob",
25
+ });
26
+
27
+ function pathLabel(rawPath: string): string {
23
28
  const displayPath = String(rawPath ?? ".");
24
- const path = displayPath === "." || displayPath === "" ? "current directory" : shortenPath(displayPath);
29
+ return displayPath === "." || displayPath === "" ? "current directory" : shortenPath(displayPath);
30
+ }
31
+
32
+ function queryDetail(pattern: string, rawPath: string): string {
33
+ const path = pathLabel(rawPath);
25
34
  return pattern ? `${pattern} in ${path}` : path;
26
35
  }
27
36
 
28
37
  export const findTool: BoxedToolDefinition = {
29
38
  call(args, theme, context) {
30
39
  noteExecutionStart(context);
31
- const detail = queryDetail(String(args?.pattern ?? ""), String(args?.path ?? "."));
32
- return compactCall(theme, "Find", `${theme.fg("dim", "Query: ")}${detail}`, {
33
- detailKey: detail,
34
- context,
40
+ const pattern = String(args?.pattern ?? "");
41
+ const rawPath = String(args?.path ?? ".");
42
+ const detail = queryDetail(pattern, rawPath);
43
+ const { isLeader, batch } = registerBatchCall(FIND_META, detail, context, {
44
+ pattern,
45
+ pathLabel: pathLabel(rawPath),
35
46
  });
47
+ if (!isLeader) return EMPTY_BATCH_COMPONENT;
48
+ return renderBatchAwareCall(theme, batch);
36
49
  },
37
- result(result, options, theme, context) {
38
- clearFooterState(context);
50
+ result(result, options, _theme, context) {
39
51
  const output = stripAnsi(getTextOutput(result)).trimEnd();
40
- const detail = queryDetail(String(context?.args?.pattern ?? ""), String(context?.args?.path ?? "."));
41
- const widthKey = boxedToolWidthKey("Find", detail);
42
-
43
- if (context.isError) {
44
- return renderBoxedToolResult(theme, () => [theme.fg("error", output || "Error")], {
45
- widthKey,
46
- footerLines: resultFooterLines(theme, result, context),
47
- isError: true,
48
- });
49
- }
50
-
51
- if (!options.expanded) return compactFooterWithState(theme, result, context);
52
-
53
- let fileCount = 0;
54
- if (output && output !== "No files found matching pattern") {
55
- const stripped = stripTrailingNotice(output);
56
- fileCount = truncationOutputLines(result) ?? countLines(stripped);
57
- }
58
-
59
- const summary = `↳ Found ${fileCount} ${fileCount === 1 ? "file" : "files"}.`;
60
- return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
61
- widthKey,
62
- footerLines: resultFooterLines(theme, result, context),
63
- });
52
+ const entries = context.isError ? undefined : parseFindOutput(output);
53
+ registerBatchResult(
54
+ FIND_META,
55
+ {
56
+ isPartial: Boolean(options.isPartial),
57
+ isError: Boolean(context.isError),
58
+ errorText: context.isError ? output || undefined : undefined,
59
+ ...(entries !== undefined ? { entries } : {}),
60
+ },
61
+ context,
62
+ );
63
+ return emptyBatchResult();
64
64
  },
65
65
  };