@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5510 -4865
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -2
- package/extension-src/pi-style/app/index.ts +0 -1
- package/extension-src/pi-style/domain/config-authorization.ts +1 -2
- package/extension-src/pi-style/domain/config-normalization.ts +3 -3
- package/extension-src/pi-style/domain/config-types.ts +2 -2
- package/extension-src/pi-style/domain/theme.ts +3 -0
- package/extension-src/pi-style/features/messages/index.ts +2 -8
- package/extension-src/pi-style/features/tools/boxed/bash.ts +384 -0
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
- package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
- package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
- package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
- package/extension-src/pi-style/features/tools/index.ts +14 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
- package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
- package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
- package/extension-src/pi-style/pi/config-session.ts +0 -2
- package/extension-src/pi-style/pi/index.ts +35 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +39 -3
- package/extension-src/pi-style/shared/box.ts +41 -7
- package/extension-src/pi-style/shared/theme-extras.ts +0 -2
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
12
|
-
import {
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
38
|
-
clearFooterState(context);
|
|
50
|
+
result(result, options, _theme, context) {
|
|
39
51
|
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
};
|