@pi-kaush/pi-tool-call-markers 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +22 -12
- package/package.json +4 -3
- package/src/index.ts +226 -149
- package/src/thinking-block-merger.ts +123 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Keep failed calls collapsed by default while retaining their native error background and Ctrl+O expansion.
|
|
6
|
+
- Add compact result tails for common successful tools in singleton and grouped summaries.
|
|
7
|
+
- Keep grouped bullets to one line and preserve the useful result tail on narrow terminals.
|
|
8
|
+
- Merge settled calls across assistant turns when no visible prose separates them, without reopening earlier groups while a later batch is active.
|
|
9
|
+
- Move adjacent thinking-block merging to a separate extension entrypoint bundled in this package.
|
|
10
|
+
|
|
11
|
+
## 0.1.2
|
|
12
|
+
|
|
13
|
+
- Keep grouping within one assistant tool batch, so a later pending batch does not reopen completed groups.
|
|
14
|
+
- Wait for every tool in a batch to settle, including tools beyond failed or expanded siblings.
|
|
15
|
+
- Preserve partial and image-bearing results instead of collapsing them.
|
|
16
|
+
- Treat self-rendered tools as owning their full shell and use only their stable header line in grouped summaries.
|
|
17
|
+
|
|
3
18
|
## 0.1.1
|
|
4
19
|
|
|
5
20
|
- Wait for every call in a contiguous parallel run to settle before grouping successful calls, which prevents completed prefixes and suffixes from repeatedly collapsing around active calls.
|
package/README.md
CHANGED
|
@@ -7,13 +7,20 @@ Collapse Pi's adjacent successful tool calls into one compact, gear-headed block
|
|
|
7
7
|
When several tool calls of the same type succeed in a row, Pi normally renders each one as its own expanded block. This extension groups them:
|
|
8
8
|
|
|
9
9
|
- **One gear header per contiguous tool type.** A run of `read` calls shares a single `⚙️ read` header; the following `write` run gets its own `⚙️ write` header.
|
|
10
|
-
- **Bulleted call summaries.** Each call in a group becomes one bullet with a short summary
|
|
10
|
+
- **Bulleted call summaries.** Each call in a group becomes one bullet with a short summary and, for common tools, a compact outcome such as `→ done`, `→ 42 lines`, or `→ +2/-1`.
|
|
11
11
|
- **Vertical spacing between tool types.** A blank line separates one tool group from the next.
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
12
|
+
- **One-line, width-safe summaries.** Long targets truncate before their useful outcome tail instead of wrapping into taller blocks.
|
|
13
|
+
- **Tool batches settle before grouping.** A tool batch stays ungrouped while any sibling call is active. Earlier groups remain stable while a later batch runs, then visually adjacent settled calls merge across assistant turns that rendered no prose.
|
|
14
|
+
- **Self-rendered tools keep their shell.** Tools that own their framing keep singleton previews intact. Grouped summaries use only their stable header instead of scraping preview or diff lines.
|
|
15
|
+
- **Partial and image results stay visible.** Streaming progress and image-bearing results are not collapsed into text-only groups.
|
|
16
|
+
- **Errors stay compact and visibly failed.** A failed call keeps its own error-colored block and native collapsed detail until expanded.
|
|
17
|
+
- **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks, including complete error details and successful results.
|
|
18
|
+
|
|
19
|
+
## Bundled thinking-block extension
|
|
20
|
+
|
|
21
|
+
The package ships a second extension entrypoint, `src/thinking-block-merger.ts`. Pi loads it independently from the tool presentation extension, so its `AssistantMessageComponent` patch and shutdown lifecycle stay isolated while install, update, and removal remain one package operation.
|
|
22
|
+
|
|
23
|
+
It combines only directly adjacent `thinking` blocks for display. Tool calls, text, and other content remain boundaries; provider blocks and signatures are not modified.
|
|
17
24
|
|
|
18
25
|
## Install
|
|
19
26
|
|
|
@@ -26,20 +33,23 @@ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
|
|
|
26
33
|
For local development:
|
|
27
34
|
|
|
28
35
|
```bash
|
|
29
|
-
pi
|
|
36
|
+
pi \
|
|
37
|
+
-e ./extensions/pi-tool-call-markers/src/index.ts \
|
|
38
|
+
-e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
|
|
30
39
|
```
|
|
31
40
|
|
|
32
41
|
## Compatibility and risk
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
The tool presentation entrypoint currently relies on **guarded, reversible prototype patches** against two Pi component classes:
|
|
35
44
|
|
|
36
45
|
- `ToolExecutionComponent` (render + display presentation),
|
|
37
|
-
- `Container` (transcript grouping)
|
|
38
|
-
|
|
46
|
+
- `Container` (transcript grouping).
|
|
47
|
+
|
|
48
|
+
The bundled thinking-block entrypoint separately patches `AssistantMessageComponent.updateContent`. Its patch and lifecycle do not share state with tool presentation.
|
|
39
49
|
|
|
40
|
-
Pi exposes no public transcript or tool-grouping hook today, so the extension patches those prototypes
|
|
50
|
+
Pi exposes no public transcript or tool-grouping hook today, so the extension patches those prototypes and restores the originals on `session_shutdown`. Every patch is wrapped in `try`/`catch` with an idempotency guard (`Symbol.for(...)` markers), so if Pi's internals change the extension silently no-ops and Pi's default rendering is preserved.
|
|
41
51
|
|
|
42
|
-
**Compatible Pi version:** `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` `>=0.80.6`. Because the patches touch internal prototype methods, a future Pi release that renames or restructures those methods can silently disable the
|
|
52
|
+
**Compatible Pi version:** `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` `>=0.80.6`. Because the patches touch internal prototype methods, a future Pi release that renames or restructures those methods can silently disable the affected presentation until this package is updated. All original methods are restored on shutdown.
|
|
43
53
|
|
|
44
54
|
> TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
|
|
45
55
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-kaush/pi-tool-call-markers",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kaushik Gopal",
|
|
7
7
|
"type": "module",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
],
|
|
32
32
|
"pi": {
|
|
33
33
|
"extensions": [
|
|
34
|
-
"./src/index.ts"
|
|
34
|
+
"./src/index.ts",
|
|
35
|
+
"./src/thinking-block-merger.ts"
|
|
35
36
|
]
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
sliceByColumn,
|
|
10
10
|
truncateToWidth,
|
|
11
11
|
visibleWidth,
|
|
12
|
-
wrapTextWithAnsi,
|
|
13
12
|
} from "@earendil-works/pi-tui";
|
|
14
13
|
|
|
15
14
|
const BADGE = " ⚙️";
|
|
@@ -17,7 +16,6 @@ const BADGE_WIDTH = visibleWidth(BADGE);
|
|
|
17
16
|
const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
|
|
18
17
|
const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
|
|
19
18
|
const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
|
|
20
|
-
const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
|
|
21
19
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
22
20
|
const BOLD_ON_RE = /\u001b\[1m/g;
|
|
23
21
|
|
|
@@ -47,7 +45,11 @@ type ToolExecutionRow = {
|
|
|
47
45
|
args?: unknown;
|
|
48
46
|
expanded?: boolean;
|
|
49
47
|
isPartial?: boolean;
|
|
50
|
-
result?: {
|
|
48
|
+
result?: {
|
|
49
|
+
isError?: boolean;
|
|
50
|
+
content?: Array<{ type?: unknown; text?: unknown }>;
|
|
51
|
+
details?: Record<string, unknown>;
|
|
52
|
+
};
|
|
51
53
|
contentBox?: ComponentContainer;
|
|
52
54
|
contentText?: TextComponent;
|
|
53
55
|
selfRenderContainer?: ComponentContainer;
|
|
@@ -84,25 +86,6 @@ type GroupRenderCache = {
|
|
|
84
86
|
width: number;
|
|
85
87
|
};
|
|
86
88
|
|
|
87
|
-
type AssistantMessageLike = {
|
|
88
|
-
content?: unknown[];
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
type AssistantMessageRow = {
|
|
92
|
-
updateContent(message: AssistantMessageLike): void;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
type ThinkingGroupingPatchState = {
|
|
96
|
-
originalUpdateContent: (message: AssistantMessageLike) => void;
|
|
97
|
-
patchedUpdateContent?: (message: AssistantMessageLike) => void;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
type ThinkingContentLike = {
|
|
101
|
-
type: "thinking";
|
|
102
|
-
thinking: string;
|
|
103
|
-
[key: string]: unknown;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
89
|
function stripAnsi(text: string): string {
|
|
107
90
|
return text.replace(ANSI_RE, "");
|
|
108
91
|
}
|
|
@@ -203,17 +186,27 @@ function hideResultImages(row: ToolExecutionRow): void {
|
|
|
203
186
|
row.imageSpacers = [];
|
|
204
187
|
}
|
|
205
188
|
|
|
206
|
-
function collapseSuccessfulResult(
|
|
207
|
-
|
|
189
|
+
function collapseSuccessfulResult(
|
|
190
|
+
row: ToolExecutionRow,
|
|
191
|
+
theme?: ThemeLike,
|
|
192
|
+
): void {
|
|
193
|
+
if (
|
|
194
|
+
row.expanded !== false ||
|
|
195
|
+
row.isPartial !== false ||
|
|
196
|
+
!row.result ||
|
|
197
|
+
row.result.isError ||
|
|
198
|
+
hasImageResult(row) ||
|
|
199
|
+
row.getRenderShell?.() === "self"
|
|
200
|
+
)
|
|
201
|
+
return;
|
|
208
202
|
|
|
209
203
|
const collapsed = row.hasRendererDefinition?.()
|
|
210
|
-
? removeResultComponent(
|
|
211
|
-
row.getRenderShell?.() === "self"
|
|
212
|
-
? row.selfRenderContainer
|
|
213
|
-
: row.contentBox,
|
|
214
|
-
)
|
|
204
|
+
? removeResultComponent(row.contentBox)
|
|
215
205
|
: collapseGenericResult(row);
|
|
216
|
-
if (collapsed)
|
|
206
|
+
if (collapsed) {
|
|
207
|
+
hideResultImages(row);
|
|
208
|
+
decorateSuccessfulCall(row, theme);
|
|
209
|
+
}
|
|
217
210
|
}
|
|
218
211
|
|
|
219
212
|
function isToolExecutionRow(
|
|
@@ -222,12 +215,25 @@ function isToolExecutionRow(
|
|
|
222
215
|
return component instanceof ToolExecutionComponent;
|
|
223
216
|
}
|
|
224
217
|
|
|
218
|
+
function isAssistantMessageRow(component: unknown): boolean {
|
|
219
|
+
return component instanceof AssistantMessageComponent;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hasImageResult(row: ToolExecutionRow): boolean {
|
|
223
|
+
return (
|
|
224
|
+
row.result?.content?.some((content) => content.type === "image") === true ||
|
|
225
|
+
(row.imageComponents?.length ?? 0) > 0 ||
|
|
226
|
+
(row.imageSpacers?.length ?? 0) > 0
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
225
230
|
function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
|
|
226
231
|
return (
|
|
227
232
|
row.expanded === false &&
|
|
228
233
|
row.isPartial === false &&
|
|
229
234
|
!!row.result &&
|
|
230
|
-
!row.result.isError
|
|
235
|
+
!row.result.isError &&
|
|
236
|
+
!hasImageResult(row)
|
|
231
237
|
);
|
|
232
238
|
}
|
|
233
239
|
|
|
@@ -235,7 +241,7 @@ function isSettledToolRow(row: ToolExecutionRow): boolean {
|
|
|
235
241
|
return row.isPartial === false && !!row.result;
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
function
|
|
244
|
+
function hasUnsettledToolInBatch(
|
|
239
245
|
children: unknown[],
|
|
240
246
|
index: number,
|
|
241
247
|
renderAt: (index: number) => string[],
|
|
@@ -247,9 +253,9 @@ function hasUnsettledToolInGroupRun(
|
|
|
247
253
|
candidateIndex += direction
|
|
248
254
|
) {
|
|
249
255
|
const candidate = children[candidateIndex];
|
|
256
|
+
if (isAssistantMessageRow(candidate)) break;
|
|
250
257
|
if (isToolExecutionRow(candidate)) {
|
|
251
258
|
if (!isSettledToolRow(candidate)) return true;
|
|
252
|
-
if (!isCollapsibleSuccess(candidate)) break;
|
|
253
259
|
continue;
|
|
254
260
|
}
|
|
255
261
|
if (renderAt(candidateIndex).some(hasVisibleContent)) break;
|
|
@@ -264,39 +270,6 @@ function renderComponent(component: unknown, width: number): string[] {
|
|
|
264
270
|
return (component as ComponentLike).render(width);
|
|
265
271
|
}
|
|
266
272
|
|
|
267
|
-
function isThinkingContent(content: unknown): content is ThinkingContentLike {
|
|
268
|
-
return (
|
|
269
|
-
!!content &&
|
|
270
|
-
typeof content === "object" &&
|
|
271
|
-
(content as { type?: unknown }).type === "thinking" &&
|
|
272
|
-
typeof (content as { thinking?: unknown }).thinking === "string"
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function combineAdjacentThinking(
|
|
277
|
-
message: AssistantMessageLike,
|
|
278
|
-
): AssistantMessageLike {
|
|
279
|
-
if (!Array.isArray(message.content)) return message;
|
|
280
|
-
|
|
281
|
-
// Merge a display-only copy; the original provider blocks and their signatures stay untouched.
|
|
282
|
-
let changed = false;
|
|
283
|
-
const content: unknown[] = [];
|
|
284
|
-
for (const block of message.content) {
|
|
285
|
-
const previous = content.at(-1);
|
|
286
|
-
if (isThinkingContent(previous) && isThinkingContent(block)) {
|
|
287
|
-
content[content.length - 1] = {
|
|
288
|
-
...previous,
|
|
289
|
-
thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
|
|
290
|
-
};
|
|
291
|
-
changed = true;
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
content.push(block);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return changed ? { ...message, content } : message;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
273
|
function compactArgs(args: unknown): string {
|
|
301
274
|
if (args === undefined || args === null) return "";
|
|
302
275
|
try {
|
|
@@ -307,6 +280,169 @@ function compactArgs(args: unknown): string {
|
|
|
307
280
|
}
|
|
308
281
|
}
|
|
309
282
|
|
|
283
|
+
function resultText(row: ToolExecutionRow): string {
|
|
284
|
+
const contentText = row.result?.content?.find(
|
|
285
|
+
(content) => content.type === "text" && typeof content.text === "string",
|
|
286
|
+
)?.text;
|
|
287
|
+
if (typeof contentText === "string") return contentText;
|
|
288
|
+
return row.getTextOutput?.() ?? "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function textLineCount(text: string): number {
|
|
292
|
+
if (!text) return 0;
|
|
293
|
+
const lines = text.split("\n");
|
|
294
|
+
return text.endsWith("\n") ? lines.length - 1 : lines.length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function resultCount(row: ToolExecutionRow, text: string): number {
|
|
298
|
+
const totalMatched = row.result?.details?.totalMatched;
|
|
299
|
+
if (typeof totalMatched === "number" && Number.isFinite(totalMatched))
|
|
300
|
+
return Math.max(0, totalMatched);
|
|
301
|
+
|
|
302
|
+
const trimmed = text.trim();
|
|
303
|
+
if (
|
|
304
|
+
!trimmed ||
|
|
305
|
+
/^(?:No matches found|No files found|\(empty directory\))/i.test(trimmed)
|
|
306
|
+
)
|
|
307
|
+
return 0;
|
|
308
|
+
|
|
309
|
+
if (row.toolName === "grep" || row.toolName === "ffgrep") {
|
|
310
|
+
const lines = trimmed.split("\n");
|
|
311
|
+
const matches = lines.filter(
|
|
312
|
+
(line) => /^.+:\d+:/.test(line) || /^\s+\d+:/.test(line),
|
|
313
|
+
).length;
|
|
314
|
+
if (matches > 0) return matches;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return trimmed
|
|
318
|
+
.split("\n")
|
|
319
|
+
.filter((line) => line.trim() && !/^\s*\[.*\]\s*$/.test(line)).length;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readLineCount(row: ToolExecutionRow, text: string): number {
|
|
323
|
+
const outputLines = (
|
|
324
|
+
row.result?.details?.truncation as { outputLines?: unknown } | undefined
|
|
325
|
+
)?.outputLines;
|
|
326
|
+
if (typeof outputLines === "number" && Number.isFinite(outputLines))
|
|
327
|
+
return Math.max(0, outputLines);
|
|
328
|
+
|
|
329
|
+
const content = text.replace(
|
|
330
|
+
/\n\n\[[^\n]*(?:more lines|showing lines)[^\n]*\]\s*$/i,
|
|
331
|
+
"",
|
|
332
|
+
);
|
|
333
|
+
return textLineCount(content);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function diffCounts(diff: string): { added: number; removed: number } {
|
|
337
|
+
let added = 0;
|
|
338
|
+
let removed = 0;
|
|
339
|
+
for (const line of diff.split("\n")) {
|
|
340
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added++;
|
|
341
|
+
if (line.startsWith("-") && !line.startsWith("---")) removed++;
|
|
342
|
+
}
|
|
343
|
+
return { added, removed };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function outcomeSummary(row: ToolExecutionRow): string | undefined {
|
|
347
|
+
if (!isCollapsibleSuccess(row)) return undefined;
|
|
348
|
+
|
|
349
|
+
const text = resultText(row);
|
|
350
|
+
switch (row.toolName) {
|
|
351
|
+
case "bash":
|
|
352
|
+
return "done";
|
|
353
|
+
case "read": {
|
|
354
|
+
const lines = readLineCount(row, text);
|
|
355
|
+
return `${lines} ${lines === 1 ? "line" : "lines"}`;
|
|
356
|
+
}
|
|
357
|
+
case "write": {
|
|
358
|
+
const content = (row.args as { content?: unknown } | undefined)?.content;
|
|
359
|
+
if (typeof content !== "string") return "written";
|
|
360
|
+
const lines = textLineCount(content);
|
|
361
|
+
return `${lines} ${lines === 1 ? "line" : "lines"}`;
|
|
362
|
+
}
|
|
363
|
+
case "edit": {
|
|
364
|
+
const diff = row.result?.details?.diff;
|
|
365
|
+
if (typeof diff !== "string") return "applied";
|
|
366
|
+
const { added, removed } = diffCounts(diff);
|
|
367
|
+
return `+${added}/-${removed}`;
|
|
368
|
+
}
|
|
369
|
+
case "grep":
|
|
370
|
+
case "ffgrep":
|
|
371
|
+
case "find":
|
|
372
|
+
case "fffind":
|
|
373
|
+
case "ls": {
|
|
374
|
+
const results = resultCount(row, text);
|
|
375
|
+
return `${results} ${results === 1 ? "result" : "results"}`;
|
|
376
|
+
}
|
|
377
|
+
default:
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function renderedOutcome(
|
|
383
|
+
row: ToolExecutionRow,
|
|
384
|
+
theme: ThemeLike,
|
|
385
|
+
): string | undefined {
|
|
386
|
+
const summary = outcomeSummary(row);
|
|
387
|
+
if (!summary) return undefined;
|
|
388
|
+
return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function truncatePlain(text: string, width: number, suffix = "…"): string {
|
|
392
|
+
const max = Math.max(0, width);
|
|
393
|
+
const plain = stripAnsi(text);
|
|
394
|
+
if (visibleWidth(plain) <= max) return plain;
|
|
395
|
+
|
|
396
|
+
const suffixWidth = visibleWidth(suffix);
|
|
397
|
+
if (suffixWidth >= max) return sliceByColumn(suffix, 0, max, true);
|
|
398
|
+
return `${sliceByColumn(plain, 0, max - suffixWidth, true)}${suffix}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function fitSummary(summary: string, width: number): string {
|
|
402
|
+
const max = Math.max(1, width);
|
|
403
|
+
return visibleWidth(summary) <= max ? summary : truncatePlain(summary, max);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function fitSummaryTail(head: string, tail: string, width: number): string {
|
|
407
|
+
const max = Math.max(1, width);
|
|
408
|
+
const summary = `${head.trimEnd()} ${tail}`;
|
|
409
|
+
if (visibleWidth(summary) <= max) return summary;
|
|
410
|
+
|
|
411
|
+
const tailWidth = visibleWidth(tail);
|
|
412
|
+
if (tailWidth >= max) return truncatePlain(tail, max);
|
|
413
|
+
return `${truncatePlain(head, max - tailWidth - 1)} ${tail}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function decorateSuccessfulCall(
|
|
417
|
+
row: ToolExecutionRow,
|
|
418
|
+
theme?: ThemeLike,
|
|
419
|
+
): void {
|
|
420
|
+
if (!theme || !Array.isArray(row.contentBox?.children)) return;
|
|
421
|
+
const original = row.contentBox.children[0] as ComponentLike | undefined;
|
|
422
|
+
const outcome = renderedOutcome(row, theme);
|
|
423
|
+
if (!original || typeof original.render !== "function" || !outcome) return;
|
|
424
|
+
|
|
425
|
+
row.contentBox.children[0] = {
|
|
426
|
+
render(width: number): string[] {
|
|
427
|
+
const lines = original.render(width);
|
|
428
|
+
const lineIndex = lines.findIndex(hasVisibleContent);
|
|
429
|
+
if (lineIndex === -1) return lines;
|
|
430
|
+
const line = lines[lineIndex];
|
|
431
|
+
if (line === undefined) return lines;
|
|
432
|
+
const next = [...lines];
|
|
433
|
+
next[lineIndex] = fitSummaryTail(
|
|
434
|
+
trimRenderedLine(line),
|
|
435
|
+
outcome,
|
|
436
|
+
Math.max(1, width - BADGE_WIDTH),
|
|
437
|
+
);
|
|
438
|
+
return next;
|
|
439
|
+
},
|
|
440
|
+
invalidate() {
|
|
441
|
+
original.invalidate();
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
310
446
|
function removeTrailingExpandHint(text: string): string {
|
|
311
447
|
const plain = stripAnsi(text).trimEnd();
|
|
312
448
|
const hint = plain.match(/\s+\([^)]*to expand\)$/i);
|
|
@@ -340,11 +476,12 @@ function renderedCallSummary(
|
|
|
340
476
|
component = row.contentBox.children[0] as ComponentLike | undefined;
|
|
341
477
|
}
|
|
342
478
|
|
|
479
|
+
const selfRendered = row.getRenderShell?.() === "self";
|
|
343
480
|
if (component && typeof component.render === "function") {
|
|
344
481
|
const visibleLines = component
|
|
345
482
|
.render(Math.max(1, width))
|
|
346
483
|
.filter(hasVisibleContent);
|
|
347
|
-
const rendered = visibleLines.slice(0, 3);
|
|
484
|
+
const rendered = visibleLines.slice(0, selfRendered ? 1 : 3);
|
|
348
485
|
const line = rendered[0];
|
|
349
486
|
if (line) {
|
|
350
487
|
const first = trimRenderedLine(line);
|
|
@@ -380,26 +517,30 @@ function renderedCallSummary(
|
|
|
380
517
|
}
|
|
381
518
|
}
|
|
382
519
|
|
|
520
|
+
if (selfRendered) return theme.fg("muted", "(details omitted)");
|
|
521
|
+
|
|
383
522
|
const fallback = compactArgs(row.args);
|
|
384
523
|
return fallback
|
|
385
524
|
? theme.fg("accent", fallback)
|
|
386
525
|
: theme.fg("muted", "(no arguments)");
|
|
387
526
|
}
|
|
388
527
|
|
|
389
|
-
function
|
|
528
|
+
function compactBulletLine(
|
|
390
529
|
summary: string,
|
|
530
|
+
outcome: string | undefined,
|
|
391
531
|
width: number,
|
|
392
532
|
theme: ThemeLike,
|
|
393
|
-
): string
|
|
533
|
+
): string {
|
|
394
534
|
const prefix = ` ${theme.fg("muted", "•")} `;
|
|
395
535
|
const indent = visibleWidth(prefix);
|
|
396
|
-
if (width <= indent) return
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
536
|
+
if (width <= indent) return truncateToWidth(prefix, width, "", false);
|
|
537
|
+
const available = width - indent;
|
|
538
|
+
return (
|
|
539
|
+
prefix +
|
|
540
|
+
(outcome
|
|
541
|
+
? fitSummaryTail(summary, outcome, available)
|
|
542
|
+
: fitSummary(summary, available))
|
|
543
|
+
);
|
|
403
544
|
}
|
|
404
545
|
|
|
405
546
|
function groupedCallComponent(
|
|
@@ -422,8 +563,9 @@ function groupedCallComponent(
|
|
|
422
563
|
lines.push(truncateToWidth(heading, width, "", false));
|
|
423
564
|
previousToolName = row.toolName;
|
|
424
565
|
}
|
|
425
|
-
const
|
|
426
|
-
|
|
566
|
+
const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
|
|
567
|
+
const outcome = renderedOutcome(row, theme);
|
|
568
|
+
lines.push(compactBulletLine(call, outcome, width, theme));
|
|
427
569
|
}
|
|
428
570
|
return lines;
|
|
429
571
|
},
|
|
@@ -577,7 +719,7 @@ function renderContainerWithToolGroups(
|
|
|
577
719
|
lines.push(...renderAt(index));
|
|
578
720
|
continue;
|
|
579
721
|
}
|
|
580
|
-
if (
|
|
722
|
+
if (hasUnsettledToolInBatch(children, index, renderAt)) {
|
|
581
723
|
lines.push(...renderAt(index));
|
|
582
724
|
continue;
|
|
583
725
|
}
|
|
@@ -590,6 +732,10 @@ function renderContainerWithToolGroups(
|
|
|
590
732
|
candidateIndex++
|
|
591
733
|
) {
|
|
592
734
|
const candidate = children[candidateIndex];
|
|
735
|
+
if (isAssistantMessageRow(candidate)) {
|
|
736
|
+
if (renderAt(candidateIndex).some(hasVisibleContent)) break;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
593
739
|
if (isToolExecutionRow(candidate)) {
|
|
594
740
|
if (!isCollapsibleSuccess(candidate)) break;
|
|
595
741
|
group.push(candidate);
|
|
@@ -612,69 +758,6 @@ function renderContainerWithToolGroups(
|
|
|
612
758
|
}
|
|
613
759
|
|
|
614
760
|
// TODO: Replace prototype patching with a public Pi tool/transcript rendering API when available.
|
|
615
|
-
function installThinkingGroupingPatch():
|
|
616
|
-
| ThinkingGroupingPatchState
|
|
617
|
-
| undefined {
|
|
618
|
-
try {
|
|
619
|
-
const proto =
|
|
620
|
-
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
621
|
-
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
622
|
-
updateContent?: (message: AssistantMessageLike) => void;
|
|
623
|
-
};
|
|
624
|
-
if (!proto || typeof proto.updateContent !== "function") return undefined;
|
|
625
|
-
|
|
626
|
-
const existing = proto[THINKING_GROUPING_PATCHED];
|
|
627
|
-
if (existing) return existing;
|
|
628
|
-
|
|
629
|
-
const state: ThinkingGroupingPatchState = {
|
|
630
|
-
originalUpdateContent: proto.updateContent,
|
|
631
|
-
};
|
|
632
|
-
const patchedUpdateContent = function updateContentWithCombinedThinking(
|
|
633
|
-
this: AssistantMessageRow,
|
|
634
|
-
message: AssistantMessageLike,
|
|
635
|
-
): void {
|
|
636
|
-
// Combine adjacent thinking blocks for display, but fall back to the original
|
|
637
|
-
// message if combining throws. Either way, invoke Pi's renderer exactly once.
|
|
638
|
-
let combined = message;
|
|
639
|
-
try {
|
|
640
|
-
combined = combineAdjacentThinking(message);
|
|
641
|
-
} catch {
|
|
642
|
-
// Thinking grouping is cosmetic; preserve the original message intact.
|
|
643
|
-
}
|
|
644
|
-
state.originalUpdateContent.call(this, combined);
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
state.patchedUpdateContent = patchedUpdateContent;
|
|
648
|
-
proto.updateContent = patchedUpdateContent;
|
|
649
|
-
Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
|
|
650
|
-
configurable: true,
|
|
651
|
-
value: state,
|
|
652
|
-
});
|
|
653
|
-
return state;
|
|
654
|
-
} catch {
|
|
655
|
-
// Thinking grouping is cosmetic; preserve Pi's renderer if its internals change.
|
|
656
|
-
return undefined;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function uninstallThinkingGroupingPatch(
|
|
661
|
-
state: ThinkingGroupingPatchState | undefined,
|
|
662
|
-
): void {
|
|
663
|
-
if (!state) return;
|
|
664
|
-
const proto =
|
|
665
|
-
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
666
|
-
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
667
|
-
updateContent?: (message: AssistantMessageLike) => void;
|
|
668
|
-
};
|
|
669
|
-
if (
|
|
670
|
-
proto[THINKING_GROUPING_PATCHED] !== state ||
|
|
671
|
-
proto.updateContent !== state.patchedUpdateContent
|
|
672
|
-
)
|
|
673
|
-
return;
|
|
674
|
-
proto.updateContent = state.originalUpdateContent;
|
|
675
|
-
delete proto[THINKING_GROUPING_PATCHED];
|
|
676
|
-
}
|
|
677
|
-
|
|
678
761
|
function installGroupingPatch(
|
|
679
762
|
presentation: PresentationPatchState,
|
|
680
763
|
): GroupingPatchState | undefined {
|
|
@@ -776,11 +859,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
776
859
|
state.groupCache.delete(this);
|
|
777
860
|
state.originalUpdateDisplay.call(this);
|
|
778
861
|
try {
|
|
779
|
-
|
|
780
|
-
this.expanded = true;
|
|
781
|
-
state.originalUpdateDisplay.call(this);
|
|
782
|
-
}
|
|
783
|
-
collapseSuccessfulResult(this);
|
|
862
|
+
collapseSuccessfulResult(this, state.theme);
|
|
784
863
|
} catch {
|
|
785
864
|
// Presentation is cosmetic; preserve Pi's renderer if its internals change.
|
|
786
865
|
}
|
|
@@ -843,7 +922,6 @@ function uninstallPresentationPatch(
|
|
|
843
922
|
export default function (pi: ExtensionAPI) {
|
|
844
923
|
const patch = installPresentationPatch();
|
|
845
924
|
const grouping = patch ? installGroupingPatch(patch) : undefined;
|
|
846
|
-
const thinkingGrouping = installThinkingGroupingPatch();
|
|
847
925
|
|
|
848
926
|
pi.on("session_start", (_event, ctx) => {
|
|
849
927
|
if (patch) patch.theme = ctx.ui.theme;
|
|
@@ -851,7 +929,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
851
929
|
});
|
|
852
930
|
|
|
853
931
|
pi.on("session_shutdown", () => {
|
|
854
|
-
uninstallThinkingGroupingPatch(thinkingGrouping);
|
|
855
932
|
uninstallGroupingPatch(grouping);
|
|
856
933
|
uninstallPresentationPatch(patch);
|
|
857
934
|
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
|
|
5
|
+
|
|
6
|
+
type AssistantMessageLike = {
|
|
7
|
+
content?: unknown[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type AssistantMessageRow = {
|
|
11
|
+
updateContent(message: AssistantMessageLike): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ThinkingGroupingPatchState = {
|
|
15
|
+
originalUpdateContent: (message: AssistantMessageLike) => void;
|
|
16
|
+
patchedUpdateContent?: (message: AssistantMessageLike) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ThinkingContentLike = {
|
|
20
|
+
type: "thinking";
|
|
21
|
+
thinking: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isThinkingContent(content: unknown): content is ThinkingContentLike {
|
|
26
|
+
return (
|
|
27
|
+
!!content &&
|
|
28
|
+
typeof content === "object" &&
|
|
29
|
+
(content as { type?: unknown }).type === "thinking" &&
|
|
30
|
+
typeof (content as { thinking?: unknown }).thinking === "string"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function combineAdjacentThinking(
|
|
35
|
+
message: AssistantMessageLike,
|
|
36
|
+
): AssistantMessageLike {
|
|
37
|
+
if (!Array.isArray(message.content)) return message;
|
|
38
|
+
|
|
39
|
+
// Merge a display-only copy; provider blocks and signatures stay untouched.
|
|
40
|
+
let changed = false;
|
|
41
|
+
const content: unknown[] = [];
|
|
42
|
+
for (const block of message.content) {
|
|
43
|
+
const previous = content.at(-1);
|
|
44
|
+
if (isThinkingContent(previous) && isThinkingContent(block)) {
|
|
45
|
+
content[content.length - 1] = {
|
|
46
|
+
...previous,
|
|
47
|
+
thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
|
|
48
|
+
};
|
|
49
|
+
changed = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
content.push(block);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return changed ? { ...message, content } : message;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// TODO: Replace prototype patching with a public assistant-message rendering API.
|
|
59
|
+
function installThinkingGroupingPatch():
|
|
60
|
+
| ThinkingGroupingPatchState
|
|
61
|
+
| undefined {
|
|
62
|
+
try {
|
|
63
|
+
const proto =
|
|
64
|
+
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
65
|
+
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
66
|
+
updateContent?: (message: AssistantMessageLike) => void;
|
|
67
|
+
};
|
|
68
|
+
if (!proto || typeof proto.updateContent !== "function") return undefined;
|
|
69
|
+
|
|
70
|
+
const existing = proto[THINKING_GROUPING_PATCHED];
|
|
71
|
+
if (existing) return existing;
|
|
72
|
+
|
|
73
|
+
const state: ThinkingGroupingPatchState = {
|
|
74
|
+
originalUpdateContent: proto.updateContent,
|
|
75
|
+
};
|
|
76
|
+
const patchedUpdateContent = function updateContentWithCombinedThinking(
|
|
77
|
+
this: AssistantMessageRow,
|
|
78
|
+
message: AssistantMessageLike,
|
|
79
|
+
): void {
|
|
80
|
+
// Thinking grouping is cosmetic. If it fails, render the original once.
|
|
81
|
+
let combined = message;
|
|
82
|
+
try {
|
|
83
|
+
combined = combineAdjacentThinking(message);
|
|
84
|
+
} catch {
|
|
85
|
+
// Preserve the original message intact.
|
|
86
|
+
}
|
|
87
|
+
state.originalUpdateContent.call(this, combined);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
state.patchedUpdateContent = patchedUpdateContent;
|
|
91
|
+
proto.updateContent = patchedUpdateContent;
|
|
92
|
+
Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
|
|
93
|
+
configurable: true,
|
|
94
|
+
value: state,
|
|
95
|
+
});
|
|
96
|
+
return state;
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uninstallThinkingGroupingPatch(
|
|
103
|
+
state: ThinkingGroupingPatchState | undefined,
|
|
104
|
+
): void {
|
|
105
|
+
if (!state) return;
|
|
106
|
+
const proto =
|
|
107
|
+
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
108
|
+
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
109
|
+
updateContent?: (message: AssistantMessageLike) => void;
|
|
110
|
+
};
|
|
111
|
+
if (
|
|
112
|
+
proto[THINKING_GROUPING_PATCHED] !== state ||
|
|
113
|
+
proto.updateContent !== state.patchedUpdateContent
|
|
114
|
+
)
|
|
115
|
+
return;
|
|
116
|
+
proto.updateContent = state.originalUpdateContent;
|
|
117
|
+
delete proto[THINKING_GROUPING_PATCHED];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default function (pi: ExtensionAPI) {
|
|
121
|
+
const patch = installThinkingGroupingPatch();
|
|
122
|
+
pi.on("session_shutdown", () => uninstallThinkingGroupingPatch(patch));
|
|
123
|
+
}
|