@quandev104/pi-style 0.1.1 → 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 (39) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5881 -4929
  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/boxed-block.ts +16 -20
  12. package/extension-src/pi-style/features/messages/index.ts +97 -40
  13. package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
  14. package/extension-src/pi-style/features/startup/index.ts +4 -4
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  21. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  22. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  27. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  28. package/extension-src/pi-style/features/tools/index.ts +14 -0
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  32. package/extension-src/pi-style/pi/config-session.ts +0 -2
  33. package/extension-src/pi-style/pi/index.ts +35 -1
  34. package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
  35. package/extension-src/pi-style/shared/ansi.ts +3 -0
  36. package/extension-src/pi-style/shared/box.ts +210 -69
  37. package/extension-src/pi-style/shared/split-diff.ts +395 -86
  38. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  39. 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
+ }
@@ -2,16 +2,15 @@
2
2
  // (renderCall/renderResult only; no edit-core re-registration).
3
3
 
4
4
  import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
5
- import { Text } from "@earendil-works/pi-tui";
6
5
  import { stripAnsi } from "../../../shared/ansi.js";
7
- import { getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
6
+ import { type BoxTheme, getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
7
+ import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
8
8
  import {
9
+ AdaptiveDiffComponent,
9
10
  buildSplitRows,
10
11
  countDiffStats,
11
12
  extractEditedPath,
12
13
  firstText,
13
- renderDiffMeter,
14
- SplitDiffComponent,
15
14
  } from "../../../shared/split-diff.js";
16
15
  import {
17
16
  type BoxedToolContext,
@@ -19,6 +18,7 @@ import {
19
18
  displayPath,
20
19
  noteExecutionStart,
21
20
  resultFooterLines,
21
+ stateElapsedMs,
22
22
  } from "./shared.js";
23
23
 
24
24
  const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
@@ -26,11 +26,35 @@ const MAX_HIGHLIGHT_DIFF_ROWS = 120;
26
26
 
27
27
  type EditResultDetails = { diff?: string; path?: string } | undefined;
28
28
 
29
+ /** `Diff · +3 -0` divider label. */
30
+ function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
31
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
32
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
33
+ return `Diff · ${plus} ${minus}`;
34
+ }
35
+
36
+ /** Edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
37
+ function editDiffFooter(
38
+ theme: BoxTheme,
39
+ result: { content?: readonly unknown[]; details?: unknown },
40
+ context: BoxedToolContext,
41
+ stats: { additions: number; removals: number },
42
+ ): string {
43
+ const elapsedMs = getElapsedMs(result) ?? stateElapsedMs(context);
44
+ const parts: string[] = [];
45
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
46
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
47
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
48
+ parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
49
+ return parts.join(theme.fg("dim", " · "));
50
+ }
51
+
29
52
  export const editTool: BoxedToolDefinition = {
30
53
  call(args, theme, context) {
31
54
  noteExecutionStart(context);
32
55
  const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
33
- return renderBoxedToolCall(theme, "Edit", [`${theme.fg("dim", "Path: ")}${detail}`], {
56
+ return renderBoxedToolCall(theme, "Edit", [], {
57
+ headerDetail: detail,
34
58
  isError: Boolean(context.isError),
35
59
  isPartial: Boolean(context.isPartial),
36
60
  isPending: Boolean(context.isPartial),
@@ -71,40 +95,32 @@ export const editTool: BoxedToolDefinition = {
71
95
  const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
72
96
  const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
73
97
 
74
- // Build split-diff rows
98
+ // Build diff rows + adaptive layout
75
99
  const rows = buildSplitRows(diff);
76
100
  const expanded = options.expanded;
77
101
  const shouldHighlight =
78
102
  Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
103
+ const stats = countDiffStats(diff);
79
104
 
80
- // Build summary header with diff stats and meter
81
- const { additions, removals } = countDiffStats(diff);
82
- const meter = renderDiffMeter(theme, additions, removals);
83
- const summary =
84
- `${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
85
- ` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
86
- ` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
87
- ` ${theme.fg("muted", "split")}` +
88
- (meter ? ` ${meter}` : "");
89
-
90
- // Render split-diff with syntax colors for small outputs.
105
+ // Render adaptive diff (unified/split per width) with syntax colors for small outputs.
91
106
  const maxRows = expanded ? 160 : 36;
92
- const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
107
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
108
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
93
109
 
94
110
  return renderBoxedToolResult(
95
111
  theme,
96
112
  {
97
113
  render(width: number): string[] {
98
- const safeWidth = Math.max(20, width);
99
- const headerLines = new Text(summary, 0, 0).render(safeWidth);
100
- return [...headerLines, ...split.render(safeWidth)];
114
+ return diffView.render(width);
101
115
  },
102
116
  invalidate(): void {
103
- split.invalidate();
117
+ diffView.invalidate();
104
118
  },
105
119
  },
106
120
  {
107
- footerLines: resultFooterLines(theme, result, context),
121
+ dividerLabel: diffDividerLabel(theme, stats),
122
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
123
+ footerLines: [editDiffFooter(theme, result, context, stats)],
108
124
  },
109
125
  );
110
126
  },
@@ -6,11 +6,12 @@ import type { BoxTheme, MetricResultLike } from "../../../shared/box.js";
6
6
  import {
7
7
  formatBoxedFooter,
8
8
  formatToolName,
9
+ formatToolOutputLine,
9
10
  formatToolParamLines,
10
11
  getTextOutput,
11
12
  renderBoxedToolCall,
12
13
  renderBoxedToolResult,
13
- renderLines,
14
+ selectRenderLines,
14
15
  } from "../../../shared/box.js";
15
16
  import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
16
17
  import { type BoxedToolContext, noteExecutionStart } from "./shared.js";
@@ -43,20 +44,21 @@ export function renderFallbackResult(
43
44
  const maxLines = expanded ? getToolsRenderConfig().maxExpandedLines : MAX_FALLBACK_PREVIEW_LINES;
44
45
  const output = getTextOutput(result);
45
46
  const elapsedMs = getStateElapsedMs(context.state);
47
+ const { lines, omitted } = selectRenderLines(output, maxLines);
46
48
 
47
49
  return renderBoxedToolResult(
48
50
  theme,
49
- (contentWidth) => {
50
- const body = renderLines(theme, output, options, {
51
- maxLines,
52
- color: isError ? "error" : "toolOutput",
53
- width: contentWidth,
54
- });
55
- return body ? body.split("\n") : [];
51
+ () => {
52
+ const body = lines.map((line) => formatToolOutputLine(theme, line, isError ? "error" : "toolOutput"));
53
+ if (expanded && omitted > 0) {
54
+ body.push(theme.fg("muted", `… ${omitted} more lines omitted by render budget`));
55
+ }
56
+ return body;
56
57
  },
57
58
  {
58
59
  footerLines: [formatBoxedFooter(theme, result, [], elapsedMs)],
59
60
  renderLineBudget: maxLines,
61
+ ...(expanded || omitted <= 0 ? {} : { expandHint: "Ctrl+O for more" }),
60
62
  isError,
61
63
  isPartial: Boolean(options.isPartial),
62
64
  },