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 +14 -2
- package/extensions/compact-tools-codex-rows.ts +4 -2
- package/extensions/compact-tools-layout.ts +19 -9
- package/extensions/compact-tools-nested.ts +177 -0
- package/extensions/compact-tools-runtime.ts +7 -1
- package/extensions/compact-tools.ts +74 -13
- package/package.json +3 -3
- package/release-notes.json +18 -0
- package/themes/github-dark-pro.json +1 -1
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
|
|
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.
|
|
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) =>
|
|
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
|
|
220
|
-
* columns short of the terminal, so previews break where
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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))
|
|
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))
|
|
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.
|
|
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.
|
|
55
|
-
"@earendil-works/pi-tui": "1.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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",
|