pi-compact-tools 0.15.0 → 0.16.1

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 2
97
+ ```
98
+
99
+ Each tool appears once with plain colored counts and no count parentheses or pipe: `read 3 · bash 1 · edit 3 2`. Single-outcome calls show one green success count, red failure count, or yellow unfinished-only count. Mixed success/failure calls show the green success count followed by the red failure count, separated only by a space; zero-count placeholders are omitted. Unfinished calls alongside settled calls add a separate yellow note such as `edit 3 2 1 unfinished`. Summary names retain their theme title hue at 75% brightness without bold, while outcome numbers keep their hues at 80% brightness. The main tool title and code preview remain unchanged. `Done in … (N lines)` uses a neutral tone one step brighter than the frame; spaces and `·` separators retain the dim frame chrome. There are no circles, leading bars, underlines, patch totals, or per-call diff lists. Non-edit groups retain first-occurrence order, and edit always appears last. Incomplete records show `(partial)`. Long summaries wrap to the pane width. Previews and author-provided expanded views remain available; expansion does not add patch details. Claude and Codex place the indented completion summary after output and keep disclosure last. Completion rows do not consume Claude’s output-preview limit.
100
+
101
+ The summary uses the host’s nested-call events and saved records without changing what the model sees. It retains only tool names and statuses, never arguments, results, or edit diff totals. An in-process `/reload`, resumed sessions, and `/tree` navigation preserve or restore call counts and outcomes. 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. */
@@ -141,7 +141,7 @@ export function paletteOf(theme: Theme): object {
141
141
 
142
142
  /** Every token the derived colors are made from. */
143
143
  const PALETTE_TOKENS: readonly ThemeColor[] = [
144
- "text", "dim", "muted", "toolDiffAdded", "toolDiffRemoved",
144
+ "text", "dim", "muted", "toolTitle", "success", "error", "warning", "toolDiffAdded", "toolDiffRemoved",
145
145
  "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
146
146
  ];
147
147
  /** One object per palette seen; a session switches among a handful of themes at most. */
@@ -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,169 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+ import { chromePainter, nestedCountPainter, nestedTitlePainter } from "./compact-tools-palette.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 MAX_NAME_LENGTH = 256;
9
+ const MAX_ID_LENGTH = 1_024;
10
+ type Status = "ok" | "error" | "unfinished";
11
+ type Call = { name: string; status: Status };
12
+ type Summary = { calls: Map<string, Call>; partial: boolean; finished: boolean; live: boolean; revision: object };
13
+ type Shared = typeof globalThis & { [SUMMARY_KEY]?: Map<string, Summary> };
14
+ type NestedEvent = { toolCallId: string; toolName: string; parentToolCallId?: string; isError?: boolean; result?: unknown };
15
+
16
+ /** UI-only, bounded bookkeeping. Never retain arguments, results, or full diffs. */
17
+ export class NestedCallSummaries {
18
+ private readonly summaries: Map<string, Summary>;
19
+
20
+ constructor() {
21
+ const shared = globalThis as Shared;
22
+ this.summaries = shared[SUMMARY_KEY] ??= new Map();
23
+ }
24
+
25
+ clear(): void {
26
+ this.summaries.clear();
27
+ }
28
+
29
+ begin(id: string, name: string): void {
30
+ if (name !== "codemode" || id.length > MAX_ID_LENGTH) return;
31
+ this.summaries.delete(id);
32
+ this.summaries.set(id, { calls: new Map(), partial: false, finished: false, live: true, revision: {} });
33
+ this.trim();
34
+ }
35
+
36
+ finish(id: string): void {
37
+ const summary = this.summaries.get(id);
38
+ if (summary) {
39
+ summary.finished = true;
40
+ summary.revision = {};
41
+ }
42
+ }
43
+
44
+ /** A revision is also read at draw time, so even a late child end cannot freeze a footer. */
45
+ revision(id: string): object | undefined {
46
+ return this.summaries.get(id)?.revision;
47
+ }
48
+
49
+ /** Host-assigned descendant ids are `<caller id>/<n>`; retain the whole provider root id. */
50
+ private root(parent: string): string | undefined {
51
+ let id = parent;
52
+ while (id) {
53
+ if (this.summaries.has(id)) return id;
54
+ const slash = id.lastIndexOf("/");
55
+ if (slash < 0) return undefined;
56
+ id = id.slice(0, slash);
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ /** Returns true only when a finished parent needs a fresh terminal frame. */
62
+ note(event: NestedEvent, ended: boolean): boolean {
63
+ if (typeof event.parentToolCallId !== "string" || event.parentToolCallId.length > MAX_ID_LENGTH) return false;
64
+ const root = this.root(event.parentToolCallId);
65
+ const summary = root === undefined ? undefined : this.summaries.get(root);
66
+ if (!summary) return false;
67
+ if (typeof event.toolCallId !== "string" || typeof event.toolName !== "string"
68
+ || event.toolCallId.length > MAX_ID_LENGTH || event.toolName.length > MAX_NAME_LENGTH) {
69
+ if (!summary.partial) { summary.partial = true; summary.revision = {}; }
70
+ return summary.finished;
71
+ }
72
+ const previous = summary.calls.get(event.toolCallId);
73
+ // A duplicate start must not turn an already settled call back into a running one.
74
+ if (!ended && previous) return false;
75
+ if (!previous && summary.calls.size >= MAX_CALLS) {
76
+ if (!summary.partial) { summary.partial = true; summary.revision = {}; }
77
+ return summary.finished;
78
+ }
79
+ const status = ended ? event.isError ? "error" : "ok" : "unfinished";
80
+ if (previous?.name === event.toolName && previous.status === status) return false;
81
+ summary.calls.set(event.toolCallId, { name: event.toolName, status });
82
+ summary.live = true;
83
+ summary.revision = {};
84
+ return summary.finished;
85
+ }
86
+
87
+ /** Pi persists `{calls: [{id, name, status}], complete}`, deliberately without results. */
88
+ restore(message: unknown): boolean {
89
+ if (!message || typeof message !== "object") return false;
90
+ const value = message as { role?: string; toolName?: string; toolCallId?: string; nestedCalls?: { calls?: unknown; complete?: unknown } };
91
+ if (value.role !== "toolResult" || value.toolName !== "codemode" || typeof value.toolCallId !== "string"
92
+ || value.toolCallId.length > MAX_ID_LENGTH || !Array.isArray(value.nestedCalls?.calls)) return false;
93
+ // A bounded persisted snapshot must not drop observed live calls.
94
+ const existing = this.summaries.get(value.toolCallId);
95
+ if (existing?.live && existing.calls.size > 0) return false;
96
+ const summary: Summary = { calls: new Map(), partial: value.nestedCalls.complete !== true, finished: true, live: false, revision: {} };
97
+ let inspected = 0;
98
+ for (const item of value.nestedCalls.calls) {
99
+ if (inspected++ >= MAX_CALLS) { summary.partial = true; break; }
100
+ if (!item || typeof item !== "object") { summary.partial = true; continue; }
101
+ const call = item as { id?: unknown; name?: unknown; status?: unknown };
102
+ if (typeof call.id !== "string" || typeof call.name !== "string"
103
+ || call.id.length > MAX_ID_LENGTH || call.name.length > MAX_NAME_LENGTH
104
+ || (call.status !== "ok" && call.status !== "error" && call.status !== "unfinished")) { summary.partial = true; continue; }
105
+ if (!summary.calls.has(call.id) && summary.calls.size >= MAX_CALLS) { summary.partial = true; break; }
106
+ summary.calls.set(call.id, { name: call.name, status: call.status as Status });
107
+ }
108
+ this.summaries.set(value.toolCallId, summary);
109
+ this.trim();
110
+ return true;
111
+ }
112
+
113
+ /** The live branch only, never abandoned entries; callers clear on non-reload session changes. */
114
+ restoreBranch(entries: readonly unknown[]): void {
115
+ for (const entry of entries) {
116
+ if (entry && typeof entry === "object") this.restore((entry as { message?: unknown }).message);
117
+ }
118
+ }
119
+
120
+ /** Other tool groups keep first-occurrence order; edit counts always come last. */
121
+ format(id: string, theme: Theme, _expanded = false): string | undefined {
122
+ const summary = this.summaries.get(id);
123
+ if (!summary || (summary.calls.size === 0 && !summary.partial)) return undefined;
124
+ type Group = { name: string; calls: Call[] };
125
+ const groups: Group[] = [];
126
+ const byTool = new Map<string, Group>();
127
+ for (const call of summary.calls.values()) {
128
+ const key = call.name;
129
+ let group = byTool.get(key);
130
+ if (!group) {
131
+ group = { name: call.name, calls: [] };
132
+ byTool.set(key, group);
133
+ groups.push(group);
134
+ }
135
+ group.calls.push(call);
136
+ }
137
+ const editIndex = groups.findIndex((group) => group.name === "edit");
138
+ if (editIndex >= 0) groups.push(...groups.splice(editIndex, 1));
139
+ const title = nestedTitlePainter(theme);
140
+ const chrome = chromePainter(theme);
141
+ const success = nestedCountPainter(theme, "success");
142
+ const error = nestedCountPainter(theme, "error");
143
+ const warning = nestedCountPainter(theme, "warning");
144
+ const parts = groups.map((group) => {
145
+ const name = stripTerminalSequences(group.name).replace(/[\x00-\x20\x7f-\x9f]/gu, " ").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/gu, "").trim() || "tool";
146
+
147
+ const successes = group.calls.filter((call) => call.status === "ok").length;
148
+ const failures = group.calls.filter((call) => call.status === "error").length;
149
+ const unfinished = group.calls.filter((call) => call.status === "unfinished").length;
150
+ const counts = [successes ? success(String(successes)) : "",
151
+ failures ? error(String(failures)) : ""].filter(Boolean);
152
+ const settled = counts.length > 0;
153
+ if (!settled) counts.push(warning(String(unfinished)));
154
+ return title(name) + chrome(" ") + counts.join(chrome(" "))
155
+ + (settled && unfinished ? chrome(" ") + warning(`${unfinished} unfinished`) : "");
156
+ });
157
+ const partial = summary.partial || [...summary.calls.values()].some((call) => call.status === "unfinished");
158
+ return parts.join(chrome(" · ")) + (partial ? (parts.length ? " " : "") + warning("(partial)") : "");
159
+ }
160
+
161
+ private trim(): void {
162
+ if (this.summaries.size <= MAX_PARENTS) return;
163
+ for (const [id, summary] of this.summaries) {
164
+ if (summary.finished) { this.summaries.delete(id); return; }
165
+ }
166
+ const oldest = this.summaries.keys().next().value;
167
+ if (oldest !== undefined) this.summaries.delete(oldest);
168
+ }
169
+ }
@@ -38,10 +38,11 @@ type ContrastBand = { color: ThemeColor; keepHue: number; minimum: number; maxim
38
38
  type ContrastRange = { minimum: number; maximum: number };
39
39
 
40
40
  /**
41
- * One tone for the whole frame — rails, the result connector, and the status line it leads
42
- * to — so the frame reads as a single quiet object rather than competing with the output.
41
+ * A quiet frame tone for rails, connectors, and separators. Completion metadata
42
+ * sits one small brightness step above it so duration and line counts remain legible.
43
43
  */
44
44
  const CHROME: ContrastBand = { color: "dim", keepHue: 0.35, minimum: 0.22, maximum: 0.28 };
45
+ const COMPLETION: ContrastBand = { ...CHROME, minimum: 0.26, maximum: 0.32 };
45
46
  /** The indicator breathes either side of the chrome tone, dipping below it and peaking above. */
46
47
  const PULSE_LOW: ContrastBand = { color: "dim", keepHue: 0.4, minimum: 0.18, maximum: 0.3 };
47
48
  const PULSE_HIGH: ContrastBand = { color: "muted", keepHue: 0.4, minimum: 0.4, maximum: 0.6 };
@@ -105,7 +106,34 @@ export function chromePainter(theme: Theme): (text: string) => string {
105
106
  });
106
107
  }
107
108
 
108
- /** Paint one rail, connector, or status line in the chrome tone. */
109
+ /** Duration and line-count metadata is slightly brighter than rails and separators. */
110
+ export function completionPainter(theme: Theme): (text: string) => string {
111
+ return perPalette(theme, "completion-metadata", () => {
112
+ const color = bandedRgb(theme, COMPLETION);
113
+ return color ? rgbPainter(theme, color) : (text: string) => theme.fg("dim", text);
114
+ });
115
+ }
116
+
117
+ /** Nested names keep the title hue, slightly darker than ordinary titles, without bold. */
118
+ export function nestedTitlePainter(theme: Theme): (text: string) => string {
119
+ return perPalette(theme, "nested-title", () => {
120
+ const title = themeColorRgb(theme, "toolTitle");
121
+ const paint = title ? rgbPainter(theme, interpolateRgb(title, { r: 0, g: 0, b: 0 }, 0.25))
122
+ : (text: string) => theme.fg("toolTitle", text);
123
+ return paint;
124
+ });
125
+ }
126
+
127
+ /** Quiet summary numbers retain outcome hues at 80% brightness. */
128
+ export function nestedCountPainter(theme: Theme, color: "success" | "error" | "warning"): (text: string) => string {
129
+ return perPalette(theme, `nested-count:${color}`, () => {
130
+ const rgb = themeColorRgb(theme, color);
131
+ return rgb ? rgbPainter(theme, interpolateRgb(rgb, { r: 0, g: 0, b: 0 }, 0.20))
132
+ : (text: string) => theme.fg(color, text);
133
+ });
134
+ }
135
+
136
+ /** Paint frame rails, connectors, separators, or running status in the quiet chrome tone. */
109
137
  export function paintChrome(theme: Theme, text: string): string {
110
138
  return chromePainter(theme)(text);
111
139
  }
@@ -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,10 +48,11 @@ 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
- import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
55
+ import { chromePainter, completionPainter, paintIndicator } from "./compact-tools-palette.ts";
55
56
  import { ProgressController } from "./compact-tools-progress.ts";
56
57
  import { showReleaseNotice } from "./compact-tools-release.ts";
57
58
  import { ToolRuntime } from "./compact-tools-runtime.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,15 +181,19 @@ 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
185
190
  ? (duration ?? "Running")
186
191
  : `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
187
192
  // A failed row already reads as an error through its output color and Pi's row
188
- // background, so the status word stays chrome rather than repeating that signal.
193
+ // background, so completion metadata stays neutral rather than repeating that signal.
189
194
  const chrome = chromePainter(theme);
190
- let details = chrome(status);
195
+ const metadata = running ? chrome : completionPainter(theme);
196
+ let details = metadata(status);
191
197
  if (failureReason && isError && !running) details += chrome(" · ") + theme.fg("error", failureReason);
192
198
  // An edit reports what it changed the way a diff does: lines added and removed.
193
199
  else if (changes && !running && !isError) {
@@ -195,8 +201,9 @@ function renderControls(
195
201
  + theme.fg("toolDiffRemoved", `-${changes.removed}`) + chrome(")");
196
202
  }
197
203
  // A failed call produced no result worth counting; "(0 lines)" would only mislead.
198
- else if (state.resultLineSummary && !running && !isError) details += chrome(` (${state.resultLineSummary})`);
199
- return prefixedText(details, chrome(" └ "), " ");
204
+ else if (state.resultLineSummary && !running && !isError) details += metadata(` (${state.resultLineSummary})`);
205
+ if (nestedSummary && !running) details += chrome(" · ") + nestedSummary;
206
+ return nestedSummary ? prefixedSentence(details, chrome(prefix), indent, true) : prefixedText(details, chrome(prefix), indent);
200
207
  }
201
208
 
202
209
  /** Arguments a one-line summary cannot show: objects, or lists of them. */
@@ -306,14 +313,14 @@ function claudeMoreLines(theme: Theme, hidden: number): string {
306
313
  }
307
314
 
308
315
  /** 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 {
316
+ function claudeOutput(output: string, expanded: boolean, theme: Theme, isError: boolean, empty: string, firstPrefix: string, completion?: Component): Component {
310
317
  const normalized = normalizeLineEndings(output).trimEnd();
311
318
  // Pi's bash reports an empty run as "(no output)"; Claude Code dims its own words for it.
312
319
  const lines = !normalized || normalized === "(no output)"
313
320
  ? [chromePainter(theme)(empty)]
314
321
  : normalized.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line));
315
322
  return claudeRows(lines, firstPrefix, CLAUDE_INDENT, expanded ? undefined : CLAUDE_OUTPUT_ROWS,
316
- (hidden) => claudeMoreLines(theme, hidden));
323
+ (hidden) => claudeMoreLines(theme, hidden), completion);
317
324
  }
318
325
 
319
326
  /**
@@ -363,6 +370,7 @@ function renderClaudeResult(
363
370
  expanded: boolean,
364
371
  output: string,
365
372
  author: AuthorResultRenderer | undefined,
373
+ completion?: Component,
366
374
  ): Component {
367
375
  const chrome = chromePainter(theme);
368
376
  const container = new CachedContainer();
@@ -378,11 +386,12 @@ function renderClaudeResult(
378
386
  // shortened to one line, and a click shows it whole in its place.
379
387
  if (failure.detail) {
380
388
  container.addChild(line(theme.fg("error", failure.headline)));
381
- container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
389
+ container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT, completion));
382
390
  } else if (expanded && output.trim()) {
383
- container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX)));
391
+ container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX), completion));
384
392
  } else {
385
393
  container.addChild(line(theme.fg("error", failure.headline)));
394
+ if (completion) container.addChild(completion);
386
395
  }
387
396
  return container;
388
397
  }
@@ -392,8 +401,9 @@ function renderClaudeResult(
392
401
  ? formatResultLineSummary(name, args, result, output) ?? "Done"
393
402
  : claudeOutcome(name, args, result, output, (value) => theme.bold(value));
394
403
  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)));
404
+ else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX), completion));
396
405
  if (body) container.addChild(body);
406
+ if (completion && outcome !== undefined) container.addChild(completion);
397
407
  return container;
398
408
  }
399
409
 
@@ -409,6 +419,7 @@ function renderRowResult(
409
419
  theme: Theme,
410
420
  ctx: RenderContext,
411
421
  author?: AuthorResultRenderer,
422
+ watchNested = true,
412
423
  ): Component {
413
424
  const state = runtime.syncRow(ctx, !options.isPartial);
414
425
  runtime.syncExpansion(state, ctx.expanded, name);
@@ -417,14 +428,23 @@ function renderRowResult(
417
428
  const output = kind === "file" && !author ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
418
429
  const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
419
430
  runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
431
+ const nestedSummary = name === "codemode" && !options.isPartial ? nestedSummaries.format(ctx.toolCallId, theme, state.expanded === true) : undefined;
432
+ if (nestedSummary && !state.resultLineSummaryComputed) {
433
+ state.resultLineSummary = output ? formatResultLineSummary(name, ctx.args, result, output) : undefined;
434
+ state.resultLineSummaryComputed = true;
435
+ }
420
436
  let component: Component;
421
437
  if (isCodex()) {
422
438
  component = renderCodexResult({
423
439
  name, kind: author ? "custom" : kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
424
440
  isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
441
+ completion: nestedSummary ? renderControls(theme, state, false, ctx.isError, undefined, undefined, nestedSummary, CODEX_INDENT, CODEX_INDENT) : undefined,
425
442
  });
426
443
  } else if (isClaude()) {
427
- component = new WithinWidth(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author));
444
+ const container = new CachedContainer();
445
+ const completion = nestedSummary ? renderControls(theme, state, false, ctx.isError, undefined, undefined, nestedSummary, CLAUDE_INDENT, CLAUDE_INDENT) : undefined;
446
+ container.addChild(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author, completion));
447
+ component = new WithinWidth(container);
428
448
  } else {
429
449
  if (!options.isPartial && !state.resultLineSummaryComputed) {
430
450
  // A custom tool with no text has nothing to count.
@@ -440,13 +460,28 @@ function renderRowResult(
440
460
  // the output shows, it already says why, and repeating it only doubles the error.
441
461
  const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
442
462
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
443
- name === "edit" ? countEditChanges(result) : undefined));
463
+ name === "edit" ? countEditChanges(result) : undefined, nestedSummary));
444
464
  component = new WithinWidth(container);
445
465
  }
446
466
  rememberResult(state, result, options, ctx.isError, theme);
447
467
  state.lastResultDurationMs = state.durationMs;
448
468
  state.lastResultOutputPad = ctx.outputPad;
449
- return withOutputPad(component, ctx.outputPad);
469
+ let drawn = withOutputPad(component, ctx.outputPad);
470
+ if (name !== "codemode" || options.isPartial || !watchNested) return drawn;
471
+ // The host can deliver a final nested snapshot (or a cancelled child's end)
472
+ // after the parent's result. Read through at draw time, not only on updateResult.
473
+ let revision = nestedSummaries.revision(ctx.toolCallId);
474
+ return {
475
+ render(width) {
476
+ const next = nestedSummaries.revision(ctx.toolCallId);
477
+ if (next !== revision) {
478
+ revision = next;
479
+ drawn = renderRowResult(name, result, options, theme, { ...ctx, lastComponent: undefined }, author, false);
480
+ }
481
+ return drawn.render(width);
482
+ },
483
+ invalidate() { drawn.invalidate(); },
484
+ };
450
485
  }
451
486
 
452
487
  /**
@@ -670,7 +705,12 @@ export default function compactTools(pi: ExtensionAPI): void {
670
705
  const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
671
706
  configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
672
707
  pi.on("session_start", (event, ctx) => {
673
- if (event.reason !== "reload") runtime.clearTimings();
708
+ if (event.reason !== "reload") {
709
+ runtime.clearTimings();
710
+ nestedSummaries.clear();
711
+ }
712
+ const branch = ctx.sessionManager?.getBranch?.();
713
+ if (Array.isArray(branch)) nestedSummaries.restoreBranch(branch);
674
714
  // Rows restored from the session are drawn next; while idle none of them is running.
675
715
  if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
676
716
  const trusted = ctx.isProjectTrusted();
@@ -703,18 +743,39 @@ export default function compactTools(pi: ExtensionAPI): void {
703
743
  groups.dispose();
704
744
  }
705
745
  });
746
+ // /tree changes the branch without session_start. Fill sibling-branch records
747
+ // before Pi rebuilds its transcript, while retaining richer same-process deltas.
748
+ pi.on("session_tree", (_event, ctx) => {
749
+ const branch = ctx.sessionManager?.getBranch?.();
750
+ if (Array.isArray(branch)) nestedSummaries.restoreBranch(branch);
751
+ runtime.repaint();
752
+ });
706
753
  pi.on("agent_start", () => runtime.setBusy(true));
707
754
  pi.on("agent_end", () => runtime.setBusy(false));
708
755
  // The calls of the run going on, read from each update as Pi reads them to make
709
756
  // their rows, and told to extensions before Pi makes them.
710
757
  pi.on("message_update", (event) => noteCalls(event.message));
758
+ // Notification-only: read the host's nested record without changing any message.
759
+ pi.on("message_end", (event) => {
760
+ if (nestedSummaries.restore(event.message)) runtime.repaint();
761
+ });
711
762
  pi.on("tool_execution_start", (event) => {
712
- if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, true);
763
+ if (madeByTool(event)) {
764
+ runtime.noteNestedCall(event.toolCallId, true);
765
+ nestedSummaries.note(event, false);
766
+ return;
767
+ }
768
+ nestedSummaries.begin(event.toolCallId, event.toolName);
713
769
  runtime.noteCall(event.toolCallId);
714
770
  runtime.noteExecutionStart(event.toolCallId);
715
771
  });
716
772
  pi.on("tool_execution_end", (event) => {
717
- if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, false);
773
+ if (madeByTool(event)) {
774
+ runtime.noteNestedCall(event.toolCallId, false);
775
+ if (nestedSummaries.note(event, true)) runtime.repaint();
776
+ return;
777
+ }
778
+ nestedSummaries.finish(event.toolCallId);
718
779
  runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
719
780
  });
720
781
  pi.on("session_shutdown", (event) => {
@@ -726,6 +787,7 @@ export default function compactTools(pi: ExtensionAPI): void {
726
787
  unbindRenderer(renderContext);
727
788
  renderContext = undefined;
728
789
  runtime.reset(event.reason !== "reload");
790
+ if (event.reason !== "reload") nestedSummaries.clear();
729
791
  // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
730
792
  if (legacyResolver) clearRowResolver(legacyResolver);
731
793
  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.1",
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,36 @@
1
1
  {
2
+ "0.16.1": {
3
+ "New": [
4
+ "Minimal codemode summaries show each tool once: read 3 · bash 1 · edit 3 2; non-edit tools keep first-call order and edit always comes last",
5
+ "Plain counts use green for success, red for failure, and yellow for unfinished calls, without zero placeholders, count parentheses, status icons, pipes, or edit diff lists",
6
+ "Summary names retain their theme title hue at 75% brightness without bold; outcome counts retain their hues at 80% brightness",
7
+ "Completion duration and output-line metadata is one step brighter; rails, connectors, separators, main tool titles, and code previews are unchanged"
8
+ ],
9
+ "Fix": [
10
+ "Interleaved nested calls aggregate into one group per tool, with success counts before failure counts",
11
+ "Reloads, resumed sessions, and /tree navigation preserve or restore nested counts and outcomes without retaining tool arguments, results, or edit diff bookkeeping",
12
+ "Unfinished calls and incomplete records remain visible, including when mixed with settled outcomes"
13
+ ],
14
+ "Deprecated": []
15
+ },
16
+ "0.16.0": {
17
+ "New": [
18
+ "Completed codemode rows summarize nested tool successes and failures beside their duration and output-line count without changing model input",
19
+ "Reloads retain exact live edit diffs; resumed sessions and /tree branches restore saved per-call outcomes without guessing changes",
20
+ "Codemode summaries show every edit attempt in order: edit(3) [+10 -3|✗|+5 -7], with no checkmark on successful changes",
21
+ "Compact, Claude, and Codex show the first three edit attempts when collapsed and every recorded attempt when expanded",
22
+ "Diff separators are dim and have no surrounding whitespace; additions and removals retain their diff colors"
23
+ ],
24
+ "Fix": [
25
+ "Updated the GitHub Dark theme and theme fixtures to Pi 1.2's published schema; validated both startup and reload resource loading",
26
+ "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",
27
+ "Per-call edit diffs keep first-call order even when nested calls finish out of order",
28
+ "Claude and Codex keep codemode output, an indented completion summary without a separate branch marker, and the disclosure in that order",
29
+ "Failed and unfinished edit attempts remain visible in previews, including omitted-attempt status counts",
30
+ "A missing successful edit diff shows ok for that call without hiding other calls’ known changes or guessing totals"
31
+ ],
32
+ "Deprecated": []
33
+ },
2
34
  "0.15.0": {
3
35
  "New": [
4
36
  "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",