pi-compact-tools 0.15.0 → 0.16.0

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/README.md CHANGED
@@ -12,7 +12,7 @@ pi install npm:pi-compact-tools
12
12
 
13
13
  Restart Pi or run `/reload`, then run `/compact-tools` to choose a style. To try it for one session without installing: `pi -e npm:pi-compact-tools`.
14
14
 
15
- Requires Node.js 22.19+ and Pi 0.85.1+. **Pi 1.0.2 or later is recommended** for native renderer integration and compact HTML exports. It changes only how tool calls are drawn, not what they run or what the model sees.
15
+ Requires Node.js 22.19+ and Pi 0.85.1+. **Pi 1.2.0 or later is recommended** for native renderer integration and compact HTML exports. It changes only how tool calls are drawn, not what they run or what the model sees.
16
16
 
17
17
  ## Styles
18
18
 
@@ -88,6 +88,18 @@ Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.js
88
88
  - `auto_compact`: `true` starts a Compact row collapsed; `false` shows up to `previewLines` rows. Custom tool names work too.
89
89
  - `custom_tools`: rows for tools from other packages. `exclude` keeps a tool's own look.
90
90
 
91
+ ## Codemode summaries
92
+
93
+ Completed `codemode` rows in **Compact, Claude, and Codex** list the outcomes of the tools the script actually called, beside the duration and output-line count:
94
+
95
+ ```text
96
+ Done in 1.234s (42 lines) · ✓ read(3) ✗ bash(1) edit(3) [+10 -3|✗|+5 -7]
97
+ ```
98
+
99
+ Most calls are grouped by tool and outcome, in first-call order. A script that catches a failed call can finish successfully while still showing `✗ bash(1)`. Edits instead use one ordered list with the total attempt count: each successful call shows its actual diff without a checkmark, each failed call shows `✗`, and an unfinished call shows `?`. A successful edit without a saved diff shows `ok`; it does not hide other calls’ known changes. Diff entries use a dim `|` separator with no surrounding whitespace; added and removed counts keep their diff colors. Collapsed rows show the first three edit attempts and `… +N more`; omitted failures or unfinished calls are counted beside that marker so the preview cannot hide them. Expanded rows show every recorded edit attempt. Incomplete records show `(partial)`. Long summaries wrap to the pane width. Previews and author-provided expanded views remain available. Claude and Codex place the `Done` / `Failed` summary after the output, without another branch marker and aligned with the output continuation rows. The disclosure stays last: Claude’s `… +N lines (ctrl+o to expand)` or Codex’s `+ Show details` / `− Show less`. Completion rows do not consume Claude’s output-preview limit.
100
+
101
+ The summary uses the host’s nested-call events and saved records when available, without changing what the model sees. Exact per-call edit diffs survive an in-process `/reload`. After restarting or resuming, Pi’s saved nested records restore call counts, but do not contain edit result diffs, so edits show per-call statuses such as `edit(3) [ok|✗|ok]` rather than invented changes. `/tree` navigation restores the selected branch’s records. Hosts without nested-call data keep the previous row rendering.
102
+
91
103
  ## UI-only behavior
92
104
 
93
105
  This package never registers or activates agent tools and does not add model-facing
@@ -118,7 +130,7 @@ npm run check
118
130
  pi -e .
119
131
  ```
120
132
 
121
- Development dependencies are pinned to Pi 1.1.0; the peer minimum remains Pi 0.85.1. CI checks Pi 0.85.1, 0.99.1, 1.0.0, 1.0.2, and 1.1.0 on Node 22.19.0 and 24. Schema validation and deterministic test clocks are development-only dependencies, not runtime dependencies. Development scripts and tests are not included in the published package. `npm test` runs the rendering tests first and the resource/packaging checks separately so their subprocesses do not disturb animation tests.
133
+ Development dependencies are pinned to Pi 1.2.0; the peer minimum remains Pi 0.85.1. CI checks Pi 0.85.1, 0.99.1, 1.0.0, 1.0.2, 1.1.0, and 1.2.0 on Node 22.19.0 and 24. Schema validation and deterministic test clocks are development-only dependencies, not runtime dependencies. Development scripts and tests are not included in the published package. `npm test` runs the rendering tests first and the resource/packaging checks separately so their subprocesses do not disturb animation tests.
122
134
 
123
135
  For a local before/after performance comparison, run `npx tsx tests/benchmark.ts <clean-baseline-directory>` with the same host dependencies in both directories. Measurements are informational, not timing-sensitive test assertions.
124
136
 
@@ -272,6 +272,8 @@ type ResultInput = {
272
272
  expanded: boolean;
273
273
  toolCallId: string;
274
274
  author?: (expanded: boolean) => Component | undefined;
275
+ /** A codemode completion summary, in this style's native indentation. */
276
+ completion?: Component;
275
277
  };
276
278
 
277
279
  /**
@@ -425,11 +427,11 @@ function customResult(input: ResultInput, width: number): string[] {
425
427
  const start = lines.findIndex((line) => stripTerminalSequences(line).trim() !== "");
426
428
  rows = (start < 0 ? [] : lines.slice(start)).map((line, index) => (index === 0 ? first : CODEX_INDENT) + line);
427
429
  } else if (text || isError) rows = prefixedRows(textLines(outputLines(isError ? `Error: ${text}` : text).join("\n"), "dim"), first, CODEX_INDENT, width, theme);
428
- return [...rows, dimDisclosure(theme, width, true)];
430
+ return [...rows, ...(input.completion?.render(width) ?? []), dimDisclosure(theme, width, true)];
429
431
  }
430
432
  let lines = text ? tailLines(text) : [];
431
433
  if (isError && outputLines(text).length <= CODEX_PREVIEW_ROWS) lines = lines.length ? [`Error: ${lines[0]}`, ...lines.slice(1)] : ["Error: "];
432
- return [...previewRows(lines, theme, width), dimDisclosure(theme, width, false)];
434
+ return [...previewRows(lines, theme, width), ...(input.completion?.render(width) ?? []), dimDisclosure(theme, width, false)];
433
435
  }
434
436
 
435
437
  /** Everything a Codex row draws beneath its head. */
@@ -203,12 +203,18 @@ export function prefixedLines(text: string, firstPrefix: string, continuationPre
203
203
  * A sentence under a tree prefix, wrapped at words as a terminal UI wraps prose:
204
204
  * a word moves to the next row whole, and only a word longer than a row is split.
205
205
  */
206
- export function prefixedSentence(text: string, firstPrefix: string, continuationPrefix: string): Component {
206
+ export function prefixedSentence(text: string, firstPrefix: string, continuationPrefix: string, trimWrappedIndent = false): Component {
207
207
  const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
208
208
  const lines = text.replace(/\t/g, " ").split("\n");
209
209
  return new CachedComponent((width) => {
210
210
  const available = Math.max(1, width - prefixWidth);
211
- return lines.flatMap((line) => wrapTextWithAnsi(line, available).map((row) => truncateToWidth(row, available, "")))
211
+ return lines.flatMap((line) => wrapTextWithAnsi(line, available).map((row, index) => {
212
+ if (trimWrappedIndent && index > 0) {
213
+ const spaces = stripTerminalSequences(row).match(/^ +/u)?.[0].length ?? 0;
214
+ if (spaces) row = sliceByColumn(row, spaces, visibleWidth(row) - spaces, true);
215
+ }
216
+ return truncateToWidth(row, available, "");
217
+ }))
212
218
  .map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
213
219
  });
214
220
  }
@@ -216,8 +222,9 @@ export function prefixedSentence(text: string, firstPrefix: string, continuation
216
222
  /**
217
223
  * Output as Claude Code previews it. Each line is wrapped to the width, and rows
218
224
  * rather than lines are counted: collapsed, `maxRows` show, all of them when only
219
- * one more would be hidden, and then `more(hidden)`. Claude Code wraps ten
220
- * columns short of the terminal, so previews break where its own do.
225
+ * one more would be hidden, then an optional completion and `more(hidden)`.
226
+ * Claude Code wraps ten columns short of the terminal, so previews break where
227
+ * its own do. Completion rows never count toward the output-preview limit.
221
228
  */
222
229
  export function claudeRows(
223
230
  lines: readonly string[],
@@ -225,17 +232,20 @@ export function claudeRows(
225
232
  continuationPrefix: string,
226
233
  maxRows: number | undefined,
227
234
  more: (hidden: number) => string,
235
+ completion?: Component,
228
236
  ): Component {
229
237
  const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
230
238
  return new CachedComponent((width) => {
231
239
  const available = Math.max(1, width - prefixWidth);
232
240
  const wrapWidth = maxRows === undefined ? available : Math.min(available, Math.max(width - 10, 10));
233
241
  let rows = lines.flatMap((line) => hardWrapTextWithAnsi(line, wrapWidth).map((row) => row.trimEnd()));
234
- if (maxRows !== undefined && rows.length > maxRows + 1) {
235
- rows = [...rows.slice(0, maxRows), truncateToWidth(more(rows.length - maxRows), available, "…")];
236
- }
237
- return rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
238
- });
242
+ const hidden = maxRows !== undefined && rows.length > maxRows + 1 ? rows.length - maxRows : 0;
243
+ if (hidden) rows = rows.slice(0, maxRows);
244
+ const rendered = rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
245
+ if (completion) rendered.push(...completion.render(width));
246
+ if (hidden) rendered.push((rendered.length ? continuationPrefix : firstPrefix) + truncateToWidth(more(hidden), available, "…"));
247
+ return rendered;
248
+ }, () => completion?.invalidate());
239
249
  }
240
250
 
241
251
  export function hardWrapTextWithAnsi(text: string, width: number): string[] {
@@ -0,0 +1,177 @@
1
+ import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+ import { countEditChanges } from "./compact-tools-invocation.ts";
4
+
5
+ const SUMMARY_KEY = Symbol.for("pi-compact-tools.nested-summaries.v1");
6
+ const MAX_PARENTS = 2_000;
7
+ const MAX_CALLS = 256;
8
+ const EDIT_PREVIEW_CALLS = 3;
9
+ const MAX_NAME_LENGTH = 256;
10
+ const MAX_ID_LENGTH = 1_024;
11
+ type Status = "ok" | "error" | "unfinished";
12
+ type Call = { name: string; status: Status; changes?: { added: number; removed: number } };
13
+ type Summary = { calls: Map<string, Call>; partial: boolean; finished: boolean; live: boolean; revision: object };
14
+ type Shared = typeof globalThis & { [SUMMARY_KEY]?: Map<string, Summary> };
15
+ type NestedEvent = { toolCallId: string; toolName: string; parentToolCallId?: string; isError?: boolean; result?: unknown };
16
+
17
+ /** UI-only, bounded bookkeeping. Never retain arguments, results, or full diffs. */
18
+ export class NestedCallSummaries {
19
+ private readonly summaries: Map<string, Summary>;
20
+
21
+ constructor() {
22
+ const shared = globalThis as Shared;
23
+ this.summaries = shared[SUMMARY_KEY] ??= new Map();
24
+ }
25
+
26
+ clear(): void {
27
+ this.summaries.clear();
28
+ }
29
+
30
+ begin(id: string, name: string): void {
31
+ if (name !== "codemode" || id.length > MAX_ID_LENGTH) return;
32
+ this.summaries.delete(id);
33
+ this.summaries.set(id, { calls: new Map(), partial: false, finished: false, live: true, revision: {} });
34
+ this.trim();
35
+ }
36
+
37
+ finish(id: string): void {
38
+ const summary = this.summaries.get(id);
39
+ if (summary) {
40
+ summary.finished = true;
41
+ summary.revision = {};
42
+ }
43
+ }
44
+
45
+ /** A revision is also read at draw time, so even a late child end cannot freeze a footer. */
46
+ revision(id: string): object | undefined {
47
+ return this.summaries.get(id)?.revision;
48
+ }
49
+
50
+ /** Host-assigned descendant ids are `<caller id>/<n>`; retain the whole provider root id. */
51
+ private root(parent: string): string | undefined {
52
+ let id = parent;
53
+ while (id) {
54
+ if (this.summaries.has(id)) return id;
55
+ const slash = id.lastIndexOf("/");
56
+ if (slash < 0) return undefined;
57
+ id = id.slice(0, slash);
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Returns true only when a finished parent needs a fresh terminal frame. */
63
+ note(event: NestedEvent, ended: boolean): boolean {
64
+ if (typeof event.parentToolCallId !== "string" || event.parentToolCallId.length > MAX_ID_LENGTH) return false;
65
+ const root = this.root(event.parentToolCallId);
66
+ const summary = root === undefined ? undefined : this.summaries.get(root);
67
+ if (!summary) return false;
68
+ if (typeof event.toolCallId !== "string" || typeof event.toolName !== "string"
69
+ || event.toolCallId.length > MAX_ID_LENGTH || event.toolName.length > MAX_NAME_LENGTH) {
70
+ if (!summary.partial) { summary.partial = true; summary.revision = {}; }
71
+ return summary.finished;
72
+ }
73
+ const previous = summary.calls.get(event.toolCallId);
74
+ // A duplicate start must not turn an already settled call back into a running one.
75
+ if (!ended && previous) return false;
76
+ if (!previous && summary.calls.size >= MAX_CALLS) {
77
+ if (!summary.partial) { summary.partial = true; summary.revision = {}; }
78
+ return summary.finished;
79
+ }
80
+ const status = ended ? event.isError ? "error" : "ok" : "unfinished";
81
+ const result = event.result;
82
+ const changes = status === "ok" && event.toolName === "edit" && result && typeof result === "object"
83
+ ? countEditChanges(result as AgentToolResult<unknown>) : undefined;
84
+ if (previous?.name === event.toolName && previous.status === status
85
+ && previous.changes?.added === changes?.added && previous.changes?.removed === changes?.removed) return false;
86
+ summary.calls.set(event.toolCallId, { name: event.toolName, status, ...(changes ? { changes } : {}) });
87
+ summary.live = true;
88
+ summary.revision = {};
89
+ return summary.finished;
90
+ }
91
+
92
+ /** Pi persists `{calls: [{id, name, status}], complete}`, deliberately without results. */
93
+ restore(message: unknown): boolean {
94
+ if (!message || typeof message !== "object") return false;
95
+ const value = message as { role?: string; toolName?: string; toolCallId?: string; nestedCalls?: { calls?: unknown; complete?: unknown } };
96
+ if (value.role !== "toolResult" || value.toolName !== "codemode" || typeof value.toolCallId !== "string"
97
+ || value.toolCallId.length > MAX_ID_LENGTH || !Array.isArray(value.nestedCalls?.calls)) return false;
98
+ // A bounded persisted snapshot must not overwrite exact live edit totals or drop observed calls.
99
+ const existing = this.summaries.get(value.toolCallId);
100
+ if (existing?.live && existing.calls.size > 0) return false;
101
+ const summary: Summary = { calls: new Map(), partial: value.nestedCalls.complete !== true, finished: true, live: false, revision: {} };
102
+ let inspected = 0;
103
+ for (const item of value.nestedCalls.calls) {
104
+ if (inspected++ >= MAX_CALLS) { summary.partial = true; break; }
105
+ if (!item || typeof item !== "object") { summary.partial = true; continue; }
106
+ const call = item as { id?: unknown; name?: unknown; status?: unknown };
107
+ if (typeof call.id !== "string" || typeof call.name !== "string"
108
+ || call.id.length > MAX_ID_LENGTH || call.name.length > MAX_NAME_LENGTH
109
+ || (call.status !== "ok" && call.status !== "error" && call.status !== "unfinished")) { summary.partial = true; continue; }
110
+ if (!summary.calls.has(call.id) && summary.calls.size >= MAX_CALLS) { summary.partial = true; break; }
111
+ summary.calls.set(call.id, { name: call.name, status: call.status as Status });
112
+ }
113
+ this.summaries.set(value.toolCallId, summary);
114
+ this.trim();
115
+ return true;
116
+ }
117
+
118
+ /** The live branch only, never abandoned entries; callers clear on non-reload session changes. */
119
+ restoreBranch(entries: readonly unknown[]): void {
120
+ for (const entry of entries) {
121
+ if (entry && typeof entry === "object") this.restore((entry as { message?: unknown }).message);
122
+ }
123
+ }
124
+
125
+ /** Most tools group by outcome; edits keep every call's status and diff in start order. */
126
+ format(id: string, theme: Theme, expanded = false): string | undefined {
127
+ const summary = this.summaries.get(id);
128
+ if (!summary || (summary.calls.size === 0 && !summary.partial)) return undefined;
129
+ const groups = new Map<string, { name: string; status: Status; calls: Call[] }>();
130
+ for (const call of summary.calls.values()) {
131
+ const key = JSON.stringify([call.name, call.name === "edit" ? "all" : call.status]);
132
+ let group = groups.get(key);
133
+ if (!group) groups.set(key, group = { name: call.name, status: call.status, calls: [] });
134
+ group.calls.push(call);
135
+ }
136
+ const parts: string[] = [];
137
+ for (const group of groups.values()) {
138
+ const color = group.status === "ok" ? "success" : group.status === "error" ? "error" : "warning";
139
+ const glyph = group.status === "ok" ? "✓" : group.status === "error" ? "✗" : "?";
140
+ const name = stripTerminalSequences(group.name).replace(/[\x00-\x20\x7f-\x9f]/gu, " ").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/gu, "").trim() || "tool";
141
+ if (group.name === "edit") {
142
+ // Include failures and unfinished calls in the same ordered list. A
143
+ // missing successful diff loses only its numbers, not other calls' diffs.
144
+ const shown = expanded ? group.calls : group.calls.slice(0, EDIT_PREVIEW_CALLS);
145
+ const entries = shown.map((call) => {
146
+ if (call.status === "error") return theme.fg("error", "✗");
147
+ if (call.status === "unfinished") return theme.fg("warning", "?");
148
+ return call.changes
149
+ ? theme.fg("toolDiffAdded", `+${call.changes.added}`) + " " + theme.fg("toolDiffRemoved", `-${call.changes.removed}`)
150
+ : theme.fg("dim", "ok");
151
+ });
152
+ const hidden = group.calls.slice(shown.length);
153
+ if (hidden.length > 0) {
154
+ // The preview must not hide that an omitted edit failed or is unfinished.
155
+ const failed = hidden.filter((call) => call.status === "error").length;
156
+ const unfinished = hidden.filter((call) => call.status === "unfinished").length;
157
+ let more = theme.fg("dim", `… +${hidden.length} more`);
158
+ const statuses = [failed ? theme.fg("error", `${failed} failed`) : "", unfinished ? theme.fg("warning", `${unfinished} unfinished`) : ""].filter(Boolean);
159
+ if (statuses.length > 0) more += " (" + statuses.join(", ") + ")";
160
+ entries.push(more);
161
+ }
162
+ parts.push(`${name}(${group.calls.length}) [` + entries.join(theme.fg("dim", "|")) + "]");
163
+ } else parts.push(theme.fg(color, glyph) + ` ${name}(${group.calls.length})`);
164
+ }
165
+ if (summary.partial || [...summary.calls.values()].some((call) => call.status === "unfinished")) parts.push(theme.fg("warning", "(partial)"));
166
+ return parts.join(" ");
167
+ }
168
+
169
+ private trim(): void {
170
+ if (this.summaries.size <= MAX_PARENTS) return;
171
+ for (const [id, summary] of this.summaries) {
172
+ if (summary.finished) { this.summaries.delete(id); return; }
173
+ }
174
+ const oldest = this.summaries.keys().next().value;
175
+ if (oldest !== undefined) this.summaries.delete(oldest);
176
+ }
177
+ }
@@ -51,7 +51,8 @@ export class ToolRuntime {
51
51
  private readonly currentCalls = new Set<string>();
52
52
  /**
53
53
  * Calls a tool is making through `ctx.executeTool()`, as codemode scripts do. They
54
- * never get a row of their own, so nothing is timed or remembered for them.
54
+ * never get a row of their own, so no execution timing is kept for them.
55
+ * The independent nested-summary observer keeps only their outcome counts.
55
56
  */
56
57
  private readonly nestedCalls = new Set<string>();
57
58
 
@@ -98,6 +99,11 @@ export class ToolRuntime {
98
99
  this.requestRender = requestRender;
99
100
  }
100
101
 
102
+ /** A late summary changes only a draw-time footer, not every row's layout. */
103
+ repaint(): void {
104
+ if (!isSilent()) this.requestRender?.();
105
+ }
106
+
101
107
  /**
102
108
  * The agent started or stopped working. Once it stops nothing can still be
103
109
  * running, so a row that never got its result stops pulsing and waits instead.
@@ -48,7 +48,8 @@ import {
48
48
  WithinWidth,
49
49
  } from "./compact-tools-layout.ts";
50
50
  import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_INDENT, CLAUDE_OUTPUT_ROWS, CLAUDE_RESULT_PREFIX, CLAUDE_TITLE_INDENT, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
51
- import { CODEX_BULLET } from "./compact-tools-codex.ts";
51
+ import { CODEX_BULLET, CODEX_INDENT } from "./compact-tools-codex.ts";
52
+ import { NestedCallSummaries } from "./compact-tools-nested.ts";
52
53
  import { renderCodexCall, renderCodexResult } from "./compact-tools-codex-rows.ts";
53
54
  import { ToolGroupController } from "./compact-tools-grouping.ts";
54
55
  import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
@@ -70,6 +71,7 @@ import {
70
71
  const PREVIEW_DIFF_CONTEXT_LINES = 1;
71
72
 
72
73
  const runtime = new ToolRuntime();
74
+ const nestedSummaries = new NestedCallSummaries();
73
75
  const RENDER_WIDGET_KEY = "compact-tools-render";
74
76
  let registeredConfiguration: string | undefined;
75
77
  /**
@@ -179,6 +181,9 @@ function renderControls(
179
181
  isError: boolean,
180
182
  failureReason?: string,
181
183
  changes?: { added: number; removed: number },
184
+ nestedSummary?: string,
185
+ prefix = " └ ",
186
+ indent = " ",
182
187
  ): Component {
183
188
  const duration = running ? undefined : formatDuration(state);
184
189
  const status = running
@@ -196,7 +201,8 @@ function renderControls(
196
201
  }
197
202
  // A failed call produced no result worth counting; "(0 lines)" would only mislead.
198
203
  else if (state.resultLineSummary && !running && !isError) details += chrome(` (${state.resultLineSummary})`);
199
- return prefixedText(details, chrome(" └ "), " ");
204
+ if (nestedSummary && !running) details += chrome(" · ") + nestedSummary;
205
+ return nestedSummary ? prefixedSentence(details, chrome(prefix), indent, true) : prefixedText(details, chrome(prefix), indent);
200
206
  }
201
207
 
202
208
  /** Arguments a one-line summary cannot show: objects, or lists of them. */
@@ -306,14 +312,14 @@ function claudeMoreLines(theme: Theme, hidden: number): string {
306
312
  }
307
313
 
308
314
  /** Output previewed in rows the way Claude Code previews it, all of it once opened. */
309
- function claudeOutput(output: string, expanded: boolean, theme: Theme, isError: boolean, empty: string, firstPrefix: string): Component {
315
+ function claudeOutput(output: string, expanded: boolean, theme: Theme, isError: boolean, empty: string, firstPrefix: string, completion?: Component): Component {
310
316
  const normalized = normalizeLineEndings(output).trimEnd();
311
317
  // Pi's bash reports an empty run as "(no output)"; Claude Code dims its own words for it.
312
318
  const lines = !normalized || normalized === "(no output)"
313
319
  ? [chromePainter(theme)(empty)]
314
320
  : normalized.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line));
315
321
  return claudeRows(lines, firstPrefix, CLAUDE_INDENT, expanded ? undefined : CLAUDE_OUTPUT_ROWS,
316
- (hidden) => claudeMoreLines(theme, hidden));
322
+ (hidden) => claudeMoreLines(theme, hidden), completion);
317
323
  }
318
324
 
319
325
  /**
@@ -363,6 +369,7 @@ function renderClaudeResult(
363
369
  expanded: boolean,
364
370
  output: string,
365
371
  author: AuthorResultRenderer | undefined,
372
+ completion?: Component,
366
373
  ): Component {
367
374
  const chrome = chromePainter(theme);
368
375
  const container = new CachedContainer();
@@ -378,11 +385,12 @@ function renderClaudeResult(
378
385
  // shortened to one line, and a click shows it whole in its place.
379
386
  if (failure.detail) {
380
387
  container.addChild(line(theme.fg("error", failure.headline)));
381
- container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
388
+ container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT, completion));
382
389
  } else if (expanded && output.trim()) {
383
- container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX)));
390
+ container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX), completion));
384
391
  } else {
385
392
  container.addChild(line(theme.fg("error", failure.headline)));
393
+ if (completion) container.addChild(completion);
386
394
  }
387
395
  return container;
388
396
  }
@@ -392,8 +400,9 @@ function renderClaudeResult(
392
400
  ? formatResultLineSummary(name, args, result, output) ?? "Done"
393
401
  : claudeOutcome(name, args, result, output, (value) => theme.bold(value));
394
402
  if (outcome !== undefined) container.addChild(line(theme.fg("toolOutput", outcome)));
395
- else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX)));
403
+ else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX), completion));
396
404
  if (body) container.addChild(body);
405
+ if (completion && outcome !== undefined) container.addChild(completion);
397
406
  return container;
398
407
  }
399
408
 
@@ -409,6 +418,7 @@ function renderRowResult(
409
418
  theme: Theme,
410
419
  ctx: RenderContext,
411
420
  author?: AuthorResultRenderer,
421
+ watchNested = true,
412
422
  ): Component {
413
423
  const state = runtime.syncRow(ctx, !options.isPartial);
414
424
  runtime.syncExpansion(state, ctx.expanded, name);
@@ -417,14 +427,23 @@ function renderRowResult(
417
427
  const output = kind === "file" && !author ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
418
428
  const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
419
429
  runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
430
+ const nestedSummary = name === "codemode" && !options.isPartial ? nestedSummaries.format(ctx.toolCallId, theme, state.expanded === true) : undefined;
431
+ if (nestedSummary && !state.resultLineSummaryComputed) {
432
+ state.resultLineSummary = output ? formatResultLineSummary(name, ctx.args, result, output) : undefined;
433
+ state.resultLineSummaryComputed = true;
434
+ }
420
435
  let component: Component;
421
436
  if (isCodex()) {
422
437
  component = renderCodexResult({
423
438
  name, kind: author ? "custom" : kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
424
439
  isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
440
+ completion: nestedSummary ? renderControls(theme, state, false, ctx.isError, undefined, undefined, nestedSummary, CODEX_INDENT, CODEX_INDENT) : undefined,
425
441
  });
426
442
  } else if (isClaude()) {
427
- component = new WithinWidth(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author));
443
+ const container = new CachedContainer();
444
+ const completion = nestedSummary ? renderControls(theme, state, false, ctx.isError, undefined, undefined, nestedSummary, CLAUDE_INDENT, CLAUDE_INDENT) : undefined;
445
+ container.addChild(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author, completion));
446
+ component = new WithinWidth(container);
428
447
  } else {
429
448
  if (!options.isPartial && !state.resultLineSummaryComputed) {
430
449
  // A custom tool with no text has nothing to count.
@@ -440,13 +459,28 @@ function renderRowResult(
440
459
  // the output shows, it already says why, and repeating it only doubles the error.
441
460
  const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
442
461
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
443
- name === "edit" ? countEditChanges(result) : undefined));
462
+ name === "edit" ? countEditChanges(result) : undefined, nestedSummary));
444
463
  component = new WithinWidth(container);
445
464
  }
446
465
  rememberResult(state, result, options, ctx.isError, theme);
447
466
  state.lastResultDurationMs = state.durationMs;
448
467
  state.lastResultOutputPad = ctx.outputPad;
449
- return withOutputPad(component, ctx.outputPad);
468
+ let drawn = withOutputPad(component, ctx.outputPad);
469
+ if (name !== "codemode" || options.isPartial || !watchNested) return drawn;
470
+ // The host can deliver a final nested snapshot (or a cancelled child's end)
471
+ // after the parent's result. Read through at draw time, not only on updateResult.
472
+ let revision = nestedSummaries.revision(ctx.toolCallId);
473
+ return {
474
+ render(width) {
475
+ const next = nestedSummaries.revision(ctx.toolCallId);
476
+ if (next !== revision) {
477
+ revision = next;
478
+ drawn = renderRowResult(name, result, options, theme, { ...ctx, lastComponent: undefined }, author, false);
479
+ }
480
+ return drawn.render(width);
481
+ },
482
+ invalidate() { drawn.invalidate(); },
483
+ };
450
484
  }
451
485
 
452
486
  /**
@@ -670,7 +704,12 @@ export default function compactTools(pi: ExtensionAPI): void {
670
704
  const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
671
705
  configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
672
706
  pi.on("session_start", (event, ctx) => {
673
- if (event.reason !== "reload") runtime.clearTimings();
707
+ if (event.reason !== "reload") {
708
+ runtime.clearTimings();
709
+ nestedSummaries.clear();
710
+ }
711
+ const branch = ctx.sessionManager?.getBranch?.();
712
+ if (Array.isArray(branch)) nestedSummaries.restoreBranch(branch);
674
713
  // Rows restored from the session are drawn next; while idle none of them is running.
675
714
  if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
676
715
  const trusted = ctx.isProjectTrusted();
@@ -703,18 +742,39 @@ export default function compactTools(pi: ExtensionAPI): void {
703
742
  groups.dispose();
704
743
  }
705
744
  });
745
+ // /tree changes the branch without session_start. Fill sibling-branch records
746
+ // before Pi rebuilds its transcript, while retaining richer same-process deltas.
747
+ pi.on("session_tree", (_event, ctx) => {
748
+ const branch = ctx.sessionManager?.getBranch?.();
749
+ if (Array.isArray(branch)) nestedSummaries.restoreBranch(branch);
750
+ runtime.repaint();
751
+ });
706
752
  pi.on("agent_start", () => runtime.setBusy(true));
707
753
  pi.on("agent_end", () => runtime.setBusy(false));
708
754
  // The calls of the run going on, read from each update as Pi reads them to make
709
755
  // their rows, and told to extensions before Pi makes them.
710
756
  pi.on("message_update", (event) => noteCalls(event.message));
757
+ // Notification-only: read the host's nested record without changing any message.
758
+ pi.on("message_end", (event) => {
759
+ if (nestedSummaries.restore(event.message)) runtime.repaint();
760
+ });
711
761
  pi.on("tool_execution_start", (event) => {
712
- if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, true);
762
+ if (madeByTool(event)) {
763
+ runtime.noteNestedCall(event.toolCallId, true);
764
+ nestedSummaries.note(event, false);
765
+ return;
766
+ }
767
+ nestedSummaries.begin(event.toolCallId, event.toolName);
713
768
  runtime.noteCall(event.toolCallId);
714
769
  runtime.noteExecutionStart(event.toolCallId);
715
770
  });
716
771
  pi.on("tool_execution_end", (event) => {
717
- if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, false);
772
+ if (madeByTool(event)) {
773
+ runtime.noteNestedCall(event.toolCallId, false);
774
+ if (nestedSummaries.note(event, true)) runtime.repaint();
775
+ return;
776
+ }
777
+ nestedSummaries.finish(event.toolCallId);
718
778
  runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
719
779
  });
720
780
  pi.on("session_shutdown", (event) => {
@@ -726,6 +786,7 @@ export default function compactTools(pi: ExtensionAPI): void {
726
786
  unbindRenderer(renderContext);
727
787
  renderContext = undefined;
728
788
  runtime.reset(event.reason !== "reload");
789
+ if (event.reason !== "reload") nestedSummaries.clear();
729
790
  // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
730
791
  if (legacyResolver) clearRowResolver(legacyResolver);
731
792
  refreshTranscriptRows = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Tool rows for Pi in three styles: Claude, Codex, and Compact. Switch with /compact-tools; includes silent mode and a GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,8 +51,8 @@
51
51
  "@earendil-works/pi-tui": ">=0.85.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "1.1.0",
55
- "@earendil-works/pi-tui": "1.1.0",
54
+ "@earendil-works/pi-coding-agent": "1.2.0",
55
+ "@earendil-works/pi-tui": "1.2.0",
56
56
  "@sinonjs/fake-timers": "15.0.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "@types/sinonjs__fake-timers": "15.0.1",
@@ -1,4 +1,22 @@
1
1
  {
2
+ "0.16.0": {
3
+ "New": [
4
+ "Completed codemode rows summarize nested tool successes and failures beside their duration and output-line count without changing model input",
5
+ "Reloads retain exact live edit diffs; resumed sessions and /tree branches restore saved per-call outcomes without guessing changes",
6
+ "Codemode summaries show every edit attempt in order: edit(3) [+10 -3|✗|+5 -7], with no checkmark on successful changes",
7
+ "Compact, Claude, and Codex show the first three edit attempts when collapsed and every recorded attempt when expanded",
8
+ "Diff separators are dim and have no surrounding whitespace; additions and removals retain their diff colors"
9
+ ],
10
+ "Fix": [
11
+ "Updated the GitHub Dark theme and theme fixtures to Pi 1.2's published schema; validated both startup and reload resource loading",
12
+ "Verified on Pi 1.2.0 while preserving the Pi 0.85.1 minimum and the existing 0.15.0 word-diff and platform key-label features",
13
+ "Per-call edit diffs keep first-call order even when nested calls finish out of order",
14
+ "Claude and Codex keep codemode output, an indented completion summary without a separate branch marker, and the disclosure in that order",
15
+ "Failed and unfinished edit attempts remain visible in previews, including omitted-attempt status counts",
16
+ "A missing successful edit diff shows ok for that call without hiding other calls’ known changes or guessing totals"
17
+ ],
18
+ "Deprecated": []
19
+ },
2
20
  "0.15.0": {
3
21
  "New": [
4
22
  "Claude style now marks the changed words in an edit diff, like Claude Code 2.1.296: a removed line and the added line that replaces it get a brighter tint on the words that differ. Lines that are mostly rewritten are left alone",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
2
+ "$schema": "https://pi.dev/schemas/theme.schema.json",
3
3
  "name": "github-dark-pro",
4
4
  "vars": {
5
5
  "surfaceMuted": "#21262D",