@pi-kaush/pi-tool-call-markers 0.1.2 → 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 +8 -0
- package/README.md +20 -12
- package/package.json +4 -3
- package/src/index.ts +192 -139
- package/src/thinking-block-merger.ts +123 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.1.2
|
|
4
12
|
|
|
5
13
|
- Keep grouping within one assistant tool batch, so a later pending batch does not reopen completed groups.
|
package/README.md
CHANGED
|
@@ -7,15 +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
|
-
- **Tool batches settle before grouping.** A tool batch stays ungrouped while any sibling call is active.
|
|
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
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
15
|
- **Partial and image results stay visible.** Streaming progress and image-bearing results are not collapsed into text-only groups.
|
|
16
|
-
- **Errors
|
|
17
|
-
- **Ctrl+O restores full blocks.** Expanding tools (`setToolsExpanded(true)`) brings back Pi's individual full blocks,
|
|
18
|
-
|
|
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.
|
|
19
24
|
|
|
20
25
|
## Install
|
|
21
26
|
|
|
@@ -28,20 +33,23 @@ pi install npm:@pi-kaush/pi-tool-call-markers@0.1.0
|
|
|
28
33
|
For local development:
|
|
29
34
|
|
|
30
35
|
```bash
|
|
31
|
-
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
|
|
32
39
|
```
|
|
33
40
|
|
|
34
41
|
## Compatibility and risk
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
The tool presentation entrypoint currently relies on **guarded, reversible prototype patches** against two Pi component classes:
|
|
37
44
|
|
|
38
45
|
- `ToolExecutionComponent` (render + display presentation),
|
|
39
|
-
- `Container` (transcript grouping)
|
|
40
|
-
|
|
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.
|
|
41
49
|
|
|
42
|
-
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.
|
|
43
51
|
|
|
44
|
-
**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.
|
|
45
53
|
|
|
46
54
|
> TODO: migrate to a public Pi tool/transcript rendering API when one becomes available, and remove the prototype patches.
|
|
47
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
|
|
|
@@ -49,7 +47,8 @@ type ToolExecutionRow = {
|
|
|
49
47
|
isPartial?: boolean;
|
|
50
48
|
result?: {
|
|
51
49
|
isError?: boolean;
|
|
52
|
-
content?: Array<{ type?: unknown }>;
|
|
50
|
+
content?: Array<{ type?: unknown; text?: unknown }>;
|
|
51
|
+
details?: Record<string, unknown>;
|
|
53
52
|
};
|
|
54
53
|
contentBox?: ComponentContainer;
|
|
55
54
|
contentText?: TextComponent;
|
|
@@ -87,25 +86,6 @@ type GroupRenderCache = {
|
|
|
87
86
|
width: number;
|
|
88
87
|
};
|
|
89
88
|
|
|
90
|
-
type AssistantMessageLike = {
|
|
91
|
-
content?: unknown[];
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
type AssistantMessageRow = {
|
|
95
|
-
updateContent(message: AssistantMessageLike): void;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
type ThinkingGroupingPatchState = {
|
|
99
|
-
originalUpdateContent: (message: AssistantMessageLike) => void;
|
|
100
|
-
patchedUpdateContent?: (message: AssistantMessageLike) => void;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
type ThinkingContentLike = {
|
|
104
|
-
type: "thinking";
|
|
105
|
-
thinking: string;
|
|
106
|
-
[key: string]: unknown;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
89
|
function stripAnsi(text: string): string {
|
|
110
90
|
return text.replace(ANSI_RE, "");
|
|
111
91
|
}
|
|
@@ -206,7 +186,10 @@ function hideResultImages(row: ToolExecutionRow): void {
|
|
|
206
186
|
row.imageSpacers = [];
|
|
207
187
|
}
|
|
208
188
|
|
|
209
|
-
function collapseSuccessfulResult(
|
|
189
|
+
function collapseSuccessfulResult(
|
|
190
|
+
row: ToolExecutionRow,
|
|
191
|
+
theme?: ThemeLike,
|
|
192
|
+
): void {
|
|
210
193
|
if (
|
|
211
194
|
row.expanded !== false ||
|
|
212
195
|
row.isPartial !== false ||
|
|
@@ -220,7 +203,10 @@ function collapseSuccessfulResult(row: ToolExecutionRow): void {
|
|
|
220
203
|
const collapsed = row.hasRendererDefinition?.()
|
|
221
204
|
? removeResultComponent(row.contentBox)
|
|
222
205
|
: collapseGenericResult(row);
|
|
223
|
-
if (collapsed)
|
|
206
|
+
if (collapsed) {
|
|
207
|
+
hideResultImages(row);
|
|
208
|
+
decorateSuccessfulCall(row, theme);
|
|
209
|
+
}
|
|
224
210
|
}
|
|
225
211
|
|
|
226
212
|
function isToolExecutionRow(
|
|
@@ -284,39 +270,6 @@ function renderComponent(component: unknown, width: number): string[] {
|
|
|
284
270
|
return (component as ComponentLike).render(width);
|
|
285
271
|
}
|
|
286
272
|
|
|
287
|
-
function isThinkingContent(content: unknown): content is ThinkingContentLike {
|
|
288
|
-
return (
|
|
289
|
-
!!content &&
|
|
290
|
-
typeof content === "object" &&
|
|
291
|
-
(content as { type?: unknown }).type === "thinking" &&
|
|
292
|
-
typeof (content as { thinking?: unknown }).thinking === "string"
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function combineAdjacentThinking(
|
|
297
|
-
message: AssistantMessageLike,
|
|
298
|
-
): AssistantMessageLike {
|
|
299
|
-
if (!Array.isArray(message.content)) return message;
|
|
300
|
-
|
|
301
|
-
// Merge a display-only copy; the original provider blocks and their signatures stay untouched.
|
|
302
|
-
let changed = false;
|
|
303
|
-
const content: unknown[] = [];
|
|
304
|
-
for (const block of message.content) {
|
|
305
|
-
const previous = content.at(-1);
|
|
306
|
-
if (isThinkingContent(previous) && isThinkingContent(block)) {
|
|
307
|
-
content[content.length - 1] = {
|
|
308
|
-
...previous,
|
|
309
|
-
thinking: `${previous.thinking.trim()}\n\n${block.thinking.trim()}`,
|
|
310
|
-
};
|
|
311
|
-
changed = true;
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
content.push(block);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return changed ? { ...message, content } : message;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
273
|
function compactArgs(args: unknown): string {
|
|
321
274
|
if (args === undefined || args === null) return "";
|
|
322
275
|
try {
|
|
@@ -327,6 +280,169 @@ function compactArgs(args: unknown): string {
|
|
|
327
280
|
}
|
|
328
281
|
}
|
|
329
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
|
+
|
|
330
446
|
function removeTrailingExpandHint(text: string): string {
|
|
331
447
|
const plain = stripAnsi(text).trimEnd();
|
|
332
448
|
const hint = plain.match(/\s+\([^)]*to expand\)$/i);
|
|
@@ -409,20 +525,22 @@ function renderedCallSummary(
|
|
|
409
525
|
: theme.fg("muted", "(no arguments)");
|
|
410
526
|
}
|
|
411
527
|
|
|
412
|
-
function
|
|
528
|
+
function compactBulletLine(
|
|
413
529
|
summary: string,
|
|
530
|
+
outcome: string | undefined,
|
|
414
531
|
width: number,
|
|
415
532
|
theme: ThemeLike,
|
|
416
|
-
): string
|
|
533
|
+
): string {
|
|
417
534
|
const prefix = ` ${theme.fg("muted", "•")} `;
|
|
418
535
|
const indent = visibleWidth(prefix);
|
|
419
|
-
if (width <= indent) return
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
);
|
|
426
544
|
}
|
|
427
545
|
|
|
428
546
|
function groupedCallComponent(
|
|
@@ -445,8 +563,9 @@ function groupedCallComponent(
|
|
|
445
563
|
lines.push(truncateToWidth(heading, width, "", false));
|
|
446
564
|
previousToolName = row.toolName;
|
|
447
565
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
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));
|
|
450
569
|
}
|
|
451
570
|
return lines;
|
|
452
571
|
},
|
|
@@ -613,7 +732,10 @@ function renderContainerWithToolGroups(
|
|
|
613
732
|
candidateIndex++
|
|
614
733
|
) {
|
|
615
734
|
const candidate = children[candidateIndex];
|
|
616
|
-
if (isAssistantMessageRow(candidate))
|
|
735
|
+
if (isAssistantMessageRow(candidate)) {
|
|
736
|
+
if (renderAt(candidateIndex).some(hasVisibleContent)) break;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
617
739
|
if (isToolExecutionRow(candidate)) {
|
|
618
740
|
if (!isCollapsibleSuccess(candidate)) break;
|
|
619
741
|
group.push(candidate);
|
|
@@ -636,69 +758,6 @@ function renderContainerWithToolGroups(
|
|
|
636
758
|
}
|
|
637
759
|
|
|
638
760
|
// TODO: Replace prototype patching with a public Pi tool/transcript rendering API when available.
|
|
639
|
-
function installThinkingGroupingPatch():
|
|
640
|
-
| ThinkingGroupingPatchState
|
|
641
|
-
| undefined {
|
|
642
|
-
try {
|
|
643
|
-
const proto =
|
|
644
|
-
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
645
|
-
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
646
|
-
updateContent?: (message: AssistantMessageLike) => void;
|
|
647
|
-
};
|
|
648
|
-
if (!proto || typeof proto.updateContent !== "function") return undefined;
|
|
649
|
-
|
|
650
|
-
const existing = proto[THINKING_GROUPING_PATCHED];
|
|
651
|
-
if (existing) return existing;
|
|
652
|
-
|
|
653
|
-
const state: ThinkingGroupingPatchState = {
|
|
654
|
-
originalUpdateContent: proto.updateContent,
|
|
655
|
-
};
|
|
656
|
-
const patchedUpdateContent = function updateContentWithCombinedThinking(
|
|
657
|
-
this: AssistantMessageRow,
|
|
658
|
-
message: AssistantMessageLike,
|
|
659
|
-
): void {
|
|
660
|
-
// Combine adjacent thinking blocks for display, but fall back to the original
|
|
661
|
-
// message if combining throws. Either way, invoke Pi's renderer exactly once.
|
|
662
|
-
let combined = message;
|
|
663
|
-
try {
|
|
664
|
-
combined = combineAdjacentThinking(message);
|
|
665
|
-
} catch {
|
|
666
|
-
// Thinking grouping is cosmetic; preserve the original message intact.
|
|
667
|
-
}
|
|
668
|
-
state.originalUpdateContent.call(this, combined);
|
|
669
|
-
};
|
|
670
|
-
|
|
671
|
-
state.patchedUpdateContent = patchedUpdateContent;
|
|
672
|
-
proto.updateContent = patchedUpdateContent;
|
|
673
|
-
Object.defineProperty(proto, THINKING_GROUPING_PATCHED, {
|
|
674
|
-
configurable: true,
|
|
675
|
-
value: state,
|
|
676
|
-
});
|
|
677
|
-
return state;
|
|
678
|
-
} catch {
|
|
679
|
-
// Thinking grouping is cosmetic; preserve Pi's renderer if its internals change.
|
|
680
|
-
return undefined;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function uninstallThinkingGroupingPatch(
|
|
685
|
-
state: ThinkingGroupingPatchState | undefined,
|
|
686
|
-
): void {
|
|
687
|
-
if (!state) return;
|
|
688
|
-
const proto =
|
|
689
|
-
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
690
|
-
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
691
|
-
updateContent?: (message: AssistantMessageLike) => void;
|
|
692
|
-
};
|
|
693
|
-
if (
|
|
694
|
-
proto[THINKING_GROUPING_PATCHED] !== state ||
|
|
695
|
-
proto.updateContent !== state.patchedUpdateContent
|
|
696
|
-
)
|
|
697
|
-
return;
|
|
698
|
-
proto.updateContent = state.originalUpdateContent;
|
|
699
|
-
delete proto[THINKING_GROUPING_PATCHED];
|
|
700
|
-
}
|
|
701
|
-
|
|
702
761
|
function installGroupingPatch(
|
|
703
762
|
presentation: PresentationPatchState,
|
|
704
763
|
): GroupingPatchState | undefined {
|
|
@@ -800,11 +859,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
800
859
|
state.groupCache.delete(this);
|
|
801
860
|
state.originalUpdateDisplay.call(this);
|
|
802
861
|
try {
|
|
803
|
-
|
|
804
|
-
this.expanded = true;
|
|
805
|
-
state.originalUpdateDisplay.call(this);
|
|
806
|
-
}
|
|
807
|
-
collapseSuccessfulResult(this);
|
|
862
|
+
collapseSuccessfulResult(this, state.theme);
|
|
808
863
|
} catch {
|
|
809
864
|
// Presentation is cosmetic; preserve Pi's renderer if its internals change.
|
|
810
865
|
}
|
|
@@ -867,7 +922,6 @@ function uninstallPresentationPatch(
|
|
|
867
922
|
export default function (pi: ExtensionAPI) {
|
|
868
923
|
const patch = installPresentationPatch();
|
|
869
924
|
const grouping = patch ? installGroupingPatch(patch) : undefined;
|
|
870
|
-
const thinkingGrouping = installThinkingGroupingPatch();
|
|
871
925
|
|
|
872
926
|
pi.on("session_start", (_event, ctx) => {
|
|
873
927
|
if (patch) patch.theme = ctx.ui.theme;
|
|
@@ -875,7 +929,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
875
929
|
});
|
|
876
930
|
|
|
877
931
|
pi.on("session_shutdown", () => {
|
|
878
|
-
uninstallThinkingGroupingPatch(thinkingGrouping);
|
|
879
932
|
uninstallGroupingPatch(grouping);
|
|
880
933
|
uninstallPresentationPatch(patch);
|
|
881
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
|
+
}
|