@pi-kaush/pi-tool-call-markers 0.2.4 → 0.2.6
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 +21 -14
- package/README.md +58 -31
- package/package.json +1 -1
- package/src/container-hooks.ts +57 -0
- package/src/index.ts +475 -422
- package/src/thinking-block-merger.ts +113 -8
package/src/index.ts
CHANGED
|
@@ -4,26 +4,25 @@ import {
|
|
|
4
4
|
ToolExecutionComponent,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import {
|
|
7
|
-
Box,
|
|
8
7
|
Container,
|
|
9
8
|
sliceByColumn,
|
|
10
9
|
truncateToWidth,
|
|
11
10
|
visibleWidth,
|
|
11
|
+
wrapTextWithAnsi,
|
|
12
12
|
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { runChatContainerHooks } from "./container-hooks.ts";
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
-
const
|
|
15
|
+
const OUTER_INSET = 2;
|
|
16
|
+
const GROUP_MARKER = "%";
|
|
16
17
|
const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
|
|
17
18
|
const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
|
|
18
19
|
const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
|
|
19
20
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
20
|
-
const BOLD_ON_RE = /\u001b\[1m/g;
|
|
21
21
|
const COLLAPSE_PARALLEL_ENV = "PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL";
|
|
22
22
|
|
|
23
23
|
type ThemeLike = {
|
|
24
24
|
bold(text: string): string;
|
|
25
25
|
fg(color: string, text: string): string;
|
|
26
|
-
bg(color: string, text: string): string;
|
|
27
26
|
};
|
|
28
27
|
|
|
29
28
|
type ComponentLike = {
|
|
@@ -33,12 +32,6 @@ type ComponentLike = {
|
|
|
33
32
|
|
|
34
33
|
type ComponentContainer = ComponentLike & {
|
|
35
34
|
children?: unknown[];
|
|
36
|
-
removeChild?(component: unknown): void;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
type TextComponent = {
|
|
40
|
-
text?: string;
|
|
41
|
-
setText?(text: string): void;
|
|
42
35
|
};
|
|
43
36
|
|
|
44
37
|
type ToolExecutionRow = {
|
|
@@ -57,15 +50,11 @@ type ToolExecutionRow = {
|
|
|
57
50
|
compactTitle?: unknown;
|
|
58
51
|
};
|
|
59
52
|
contentBox?: ComponentContainer;
|
|
60
|
-
contentText?: TextComponent;
|
|
61
|
-
selfRenderContainer?: ComponentContainer;
|
|
62
53
|
callRendererComponent?: ComponentLike;
|
|
63
54
|
imageComponents?: unknown[];
|
|
64
55
|
imageSpacers?: unknown[];
|
|
65
|
-
hasRendererDefinition?(): boolean;
|
|
66
56
|
getRenderShell?(): "default" | "self";
|
|
67
57
|
getTextOutput?(): string;
|
|
68
|
-
removeChild?(component: unknown): void;
|
|
69
58
|
};
|
|
70
59
|
|
|
71
60
|
type PresentationPatchState = {
|
|
@@ -85,6 +74,7 @@ type GroupingPatchState = {
|
|
|
85
74
|
presentation: PresentationPatchState;
|
|
86
75
|
originalRender: (width: number) => string[];
|
|
87
76
|
patchedRender?: (width: number) => string[];
|
|
77
|
+
disabled?: boolean;
|
|
88
78
|
};
|
|
89
79
|
|
|
90
80
|
type GroupRenderCache = {
|
|
@@ -107,157 +97,46 @@ function stripAnsi(text: string): string {
|
|
|
107
97
|
return text.replace(ANSI_RE, "");
|
|
108
98
|
}
|
|
109
99
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
return line.slice(0, index) + BADGE + line.slice(index);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function hasGearBadge(line: string): boolean {
|
|
125
|
-
return stripAnsi(line).trimStart().startsWith("⚙️");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function boldLeadingToolToken(
|
|
129
|
-
line: string,
|
|
130
|
-
token: string,
|
|
131
|
-
theme: ThemeLike,
|
|
132
|
-
): string {
|
|
133
|
-
const visible = stripAnsi(line);
|
|
134
|
-
const prefix = visible.match(/^\s*(?:⚙️\s*)?/)?.[0] ?? "";
|
|
135
|
-
if (!visible.startsWith(token, prefix.length)) return line;
|
|
136
|
-
|
|
137
|
-
const start = visibleWidth(prefix);
|
|
138
|
-
const tokenWidth = visibleWidth(token);
|
|
139
|
-
const before = sliceByColumn(line, 0, start);
|
|
140
|
-
const styledToken = sliceByColumn(line, start, tokenWidth);
|
|
141
|
-
const after = sliceByColumn(
|
|
142
|
-
line,
|
|
143
|
-
start + tokenWidth,
|
|
144
|
-
visibleWidth(line),
|
|
145
|
-
).replace(BOLD_ON_RE, "");
|
|
146
|
-
return before + theme.bold(styledToken) + "\x1b[22m" + after;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function decorateHeader(
|
|
150
|
-
row: ToolExecutionRow,
|
|
151
|
-
lines: string[],
|
|
152
|
-
width: number,
|
|
153
|
-
theme?: ThemeLike,
|
|
154
|
-
): string[] {
|
|
155
|
-
const lineIndex = lines.findIndex(hasVisibleContent);
|
|
156
|
-
if (lineIndex === -1) return lines;
|
|
157
|
-
|
|
158
|
-
const next = [...lines];
|
|
159
|
-
let header = next[lineIndex];
|
|
160
|
-
if (header === undefined) return lines;
|
|
161
|
-
if (!hasGearBadge(header) && width > BADGE_WIDTH) {
|
|
162
|
-
header = truncateToWidth(prefixBadge(header), width, "", false);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const token = row.toolName === "bash" ? "$" : row.toolName;
|
|
166
|
-
if (theme && token) header = boldLeadingToolToken(header, token, theme);
|
|
167
|
-
next[lineIndex] = header;
|
|
168
|
-
return next;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function removeResultComponent(container?: ComponentContainer): boolean {
|
|
172
|
-
if (
|
|
173
|
-
!container ||
|
|
174
|
-
!Array.isArray(container.children) ||
|
|
175
|
-
typeof container.removeChild !== "function"
|
|
176
|
-
)
|
|
177
|
-
return false;
|
|
178
|
-
for (const child of container.children.slice(1)) container.removeChild(child);
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
100
|
+
// Command output can carry cursor-moving control bytes (git progress writes
|
|
101
|
+
// \r) and raw terminal sequences. Collapsed rows are single-line, so result
|
|
102
|
+
// and scraped text is stripped of display sequences and control bytes before
|
|
103
|
+
// it can reach a row; a bare \r would otherwise return the cursor to column 0
|
|
104
|
+
// and overwrite the row's own marker.
|
|
105
|
+
const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
106
|
+
// OSC first: the two-byte alternative would otherwise consume `\x1b]` and
|
|
107
|
+
// leave the hyperlink payload behind as visible text.
|
|
108
|
+
const DISPLAY_ANSI_RE =
|
|
109
|
+
/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
|
|
181
110
|
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
typeof text !== "string" ||
|
|
186
|
-
typeof row.contentText?.setText !== "function"
|
|
187
|
-
)
|
|
188
|
-
return false;
|
|
189
|
-
|
|
190
|
-
const output = row.getTextOutput?.();
|
|
191
|
-
if (!output) return true;
|
|
192
|
-
const suffix = `\n${output}`;
|
|
193
|
-
if (!text.endsWith(suffix)) return false;
|
|
194
|
-
row.contentText.setText(text.slice(0, -suffix.length));
|
|
195
|
-
return true;
|
|
111
|
+
function sanitizeInline(text: string): string {
|
|
112
|
+
return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
|
|
196
113
|
}
|
|
197
114
|
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
for (const image of row.imageComponents ?? []) row.removeChild(image);
|
|
201
|
-
for (const spacer of row.imageSpacers ?? []) row.removeChild(spacer);
|
|
202
|
-
row.imageComponents = [];
|
|
203
|
-
row.imageSpacers = [];
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function collapseSuccessfulResult(
|
|
207
|
-
row: ToolExecutionRow,
|
|
208
|
-
theme?: ThemeLike,
|
|
209
|
-
): void {
|
|
210
|
-
if (
|
|
211
|
-
row.expanded !== false ||
|
|
212
|
-
row.isPartial !== false ||
|
|
213
|
-
!row.result ||
|
|
214
|
-
hasImageResult(row)
|
|
215
|
-
)
|
|
216
|
-
return;
|
|
217
|
-
|
|
218
|
-
if (row.getRenderShell?.() === "self") {
|
|
219
|
-
collapseSelfRenderedRow(row, theme);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Default-shell failures keep Pi's native error render, which already
|
|
224
|
-
// applies the error background. rowHasFailed also catches MCP tools that
|
|
225
|
-
// use the default shell (e.g. mcpScript) and report via details.error.
|
|
226
|
-
if (rowHasFailed(row)) return;
|
|
227
|
-
|
|
228
|
-
const collapsed = row.hasRendererDefinition?.()
|
|
229
|
-
? removeResultComponent(row.contentBox)
|
|
230
|
-
: collapseGenericResult(row);
|
|
231
|
-
if (collapsed) {
|
|
232
|
-
hideResultImages(row);
|
|
233
|
-
decorateSuccessfulCall(row, theme);
|
|
234
|
-
}
|
|
115
|
+
function hasVisibleContent(line: string): boolean {
|
|
116
|
+
return stripAnsi(line).trim().length > 0;
|
|
235
117
|
}
|
|
236
118
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
// Failures collapse too, with the error background and the first error line,
|
|
242
|
-
// since MCP error output tends to be a huge markdown blob.
|
|
243
|
-
function collapseSelfRenderedRow(
|
|
244
|
-
row: ToolExecutionRow,
|
|
245
|
-
theme?: ThemeLike,
|
|
246
|
-
): void {
|
|
247
|
-
const container = row.selfRenderContainer;
|
|
248
|
-
if (!theme || !container || !Array.isArray(container.children)) return;
|
|
119
|
+
type InsetLayout = {
|
|
120
|
+
contentWidth: number;
|
|
121
|
+
left: number;
|
|
122
|
+
};
|
|
249
123
|
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
124
|
+
function insetLayout(width: number): InsetLayout {
|
|
125
|
+
const max = Math.max(1, width);
|
|
126
|
+
// Keep enough room for "% x → …" before spending columns on decoration.
|
|
127
|
+
// Normal terminal widths receive the full two-column inset on both sides.
|
|
128
|
+
const decoration = Math.min(OUTER_INSET * 2, Math.max(0, max - 8));
|
|
129
|
+
const left = Math.min(OUTER_INSET, Math.ceil(decoration / 2));
|
|
130
|
+
const right = decoration - left;
|
|
131
|
+
return { contentWidth: Math.max(1, max - left - right), left };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function insetLines(lines: string[], width: number): string[] {
|
|
135
|
+
const layout = insetLayout(width);
|
|
136
|
+
const prefix = " ".repeat(layout.left);
|
|
137
|
+
return lines.map(
|
|
138
|
+
(line) => prefix + truncateToWidth(line, layout.contentWidth, "", false),
|
|
259
139
|
);
|
|
260
|
-
container.children = [box];
|
|
261
140
|
}
|
|
262
141
|
|
|
263
142
|
// The MCP adapter reports outcomes in details.error but only promotes
|
|
@@ -306,35 +185,10 @@ function rowHasFailed(row: ToolExecutionRow): boolean {
|
|
|
306
185
|
function renderedErrorOutcome(row: ToolExecutionRow, theme: ThemeLike): string {
|
|
307
186
|
const firstLine =
|
|
308
187
|
resultText(row)
|
|
309
|
-
.split(
|
|
310
|
-
.map((line) => line.trim())
|
|
188
|
+
.split(/\r\n|[\r\n]/)
|
|
189
|
+
.map((line) => sanitizeInline(line).trim())
|
|
311
190
|
.find((line) => line.length > 0) ?? "error";
|
|
312
|
-
return `${theme.fg("
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function selfRenderedSummaryComponent(
|
|
316
|
-
row: ToolExecutionRow,
|
|
317
|
-
theme: ThemeLike,
|
|
318
|
-
tail: string,
|
|
319
|
-
maxTailShare = 1,
|
|
320
|
-
): ComponentLike {
|
|
321
|
-
return {
|
|
322
|
-
render(width: number): string[] {
|
|
323
|
-
// The Box already shrinks our width by its horizontal padding before
|
|
324
|
-
// calling render, so only the badge deducts from this headline;
|
|
325
|
-
// counting the padding again starves the label at narrow widths.
|
|
326
|
-
const budget = Math.max(1, width - BADGE_WIDTH);
|
|
327
|
-
const summary = selfRenderedSummary(row, budget);
|
|
328
|
-
// Long error lines would otherwise evict the call summary entirely;
|
|
329
|
-
// cap the tail's share so the label always survives.
|
|
330
|
-
const cappedTail =
|
|
331
|
-
maxTailShare >= 1
|
|
332
|
-
? tail
|
|
333
|
-
: capFailureTail(tail, Math.floor(budget * maxTailShare), theme);
|
|
334
|
-
return [fitSummaryTail(summary, cappedTail, budget)];
|
|
335
|
-
},
|
|
336
|
-
invalidate() {},
|
|
337
|
-
};
|
|
191
|
+
return `${theme.fg("error", "→")} ${theme.fg("error", firstLine)}`;
|
|
338
192
|
}
|
|
339
193
|
|
|
340
194
|
// Trim a failure tail to its quota of the budget, counting the appended
|
|
@@ -346,7 +200,7 @@ function selfRenderedSummaryComponent(
|
|
|
346
200
|
function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
|
|
347
201
|
const tailWidth = visibleWidth(tail);
|
|
348
202
|
if (tailWidth <= cap) return tail;
|
|
349
|
-
const ellipsis = theme.fg("
|
|
203
|
+
const ellipsis = theme.fg("error", "…");
|
|
350
204
|
const ellipsisWidth = visibleWidth(ellipsis);
|
|
351
205
|
if (cap <= 0) return "";
|
|
352
206
|
if (ellipsisWidth >= cap) return sliceByColumn(tail, 0, cap, true);
|
|
@@ -354,7 +208,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
|
|
|
354
208
|
}
|
|
355
209
|
|
|
356
210
|
function renderedGenericOutcome(theme: ThemeLike): string {
|
|
357
|
-
return
|
|
211
|
+
return theme.fg("muted", "→ done");
|
|
358
212
|
}
|
|
359
213
|
|
|
360
214
|
// The MCP adapter stashes its call's first line in renderer state before
|
|
@@ -503,6 +357,17 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
|
|
|
503
357
|
const json = squashJsonish(args);
|
|
504
358
|
return json ? `mcp ${json}` : "mcp status";
|
|
505
359
|
}
|
|
360
|
+
// Pi's edit tool renders its own shell and carries { path, edits } args;
|
|
361
|
+
// mirror Pi's native `edit <path>` call line instead of dumping the payload.
|
|
362
|
+
if (
|
|
363
|
+
token === "edit" &&
|
|
364
|
+
args &&
|
|
365
|
+
typeof args === "object" &&
|
|
366
|
+
!Array.isArray(args)
|
|
367
|
+
) {
|
|
368
|
+
const path = (args as Record<string, unknown>).path;
|
|
369
|
+
if (typeof path === "string" && path.length > 0) return `${token} ${path}`;
|
|
370
|
+
}
|
|
506
371
|
const title = selfRenderedCallTitle(row);
|
|
507
372
|
const json = squashJsonish(args);
|
|
508
373
|
// Direct tools put only the bare name in their own title, so append the
|
|
@@ -567,91 +432,6 @@ function isLiveRow(row: ToolExecutionRow): boolean {
|
|
|
567
432
|
);
|
|
568
433
|
}
|
|
569
434
|
|
|
570
|
-
function liveTailText(
|
|
571
|
-
row: ToolExecutionRow,
|
|
572
|
-
droppedVisible: boolean,
|
|
573
|
-
theme?: ThemeLike,
|
|
574
|
-
): string | undefined {
|
|
575
|
-
if (!theme) return undefined;
|
|
576
|
-
const elapsed = liveElapsedText(row);
|
|
577
|
-
if (!droppedVisible && !elapsed) return undefined;
|
|
578
|
-
const parts: string[] = [];
|
|
579
|
-
if (droppedVisible) parts.push("…");
|
|
580
|
-
if (elapsed) parts.push("·", elapsed);
|
|
581
|
-
return theme.fg("muted", parts.join(" "));
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function fitLiveHeader(
|
|
585
|
-
header: string,
|
|
586
|
-
tail: string,
|
|
587
|
-
width: number,
|
|
588
|
-
droppedVisible: boolean,
|
|
589
|
-
): string {
|
|
590
|
-
const max = Math.max(1, width);
|
|
591
|
-
const head = header.trimEnd();
|
|
592
|
-
if (visibleWidth(head) + visibleWidth(tail) + 1 <= max)
|
|
593
|
-
return `${head} ${tail}`;
|
|
594
|
-
const headWidth = Math.max(1, max - visibleWidth(tail) - 1);
|
|
595
|
-
const headSuffix = droppedVisible ? "" : "…";
|
|
596
|
-
return `${truncateToWidth(head, headWidth, headSuffix, false)} ${tail}`;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// While a call streams or runs, pin it to a single header line with the
|
|
600
|
-
// elapsed time inline, so the block never grows and never collapses on
|
|
601
|
-
// completion; the header text simply settles into the outcome summary. The
|
|
602
|
-
// cap is composed before the Box applies background and padding, so the live
|
|
603
|
-
// line keeps the tool background edge to edge and the block's bottom padding.
|
|
604
|
-
function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
|
|
605
|
-
// Pin live self-rendered rows (e.g. MCP calls) to the same one-line summary
|
|
606
|
-
// they settle into, so the block never grows and hops on completion.
|
|
607
|
-
if (row.getRenderShell?.() === "self") {
|
|
608
|
-
const container = row.selfRenderContainer;
|
|
609
|
-
if (!theme || !container || !Array.isArray(container.children)) return;
|
|
610
|
-
const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
|
|
611
|
-
box.addChild(
|
|
612
|
-
selfRenderedSummaryComponent(row, theme, theme.fg("muted", "…")),
|
|
613
|
-
);
|
|
614
|
-
container.children = [box];
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
if (row.hasRendererDefinition?.()) {
|
|
618
|
-
const container = row.contentBox;
|
|
619
|
-
if (!container || !Array.isArray(container.children)) return;
|
|
620
|
-
const hadExtraChildren = container.children.length > 1;
|
|
621
|
-
removeResultComponent(container);
|
|
622
|
-
const original = container.children[0] as ComponentLike | undefined;
|
|
623
|
-
if (!original || typeof original.render !== "function") return;
|
|
624
|
-
container.children[0] = {
|
|
625
|
-
render(width: number): string[] {
|
|
626
|
-
const lines = original.render(width);
|
|
627
|
-
const headerIndex = lines.findIndex(hasVisibleContent);
|
|
628
|
-
if (headerIndex === -1) return lines;
|
|
629
|
-
const header = lines[headerIndex] ?? "";
|
|
630
|
-
const droppedVisible =
|
|
631
|
-
hadExtraChildren ||
|
|
632
|
-
lines.slice(headerIndex + 1).some(hasVisibleContent);
|
|
633
|
-
const tail = liveTailText(row, droppedVisible, theme);
|
|
634
|
-
return [
|
|
635
|
-
tail ? fitLiveHeader(header, tail, width, droppedVisible) : header,
|
|
636
|
-
];
|
|
637
|
-
},
|
|
638
|
-
invalidate() {
|
|
639
|
-
original.invalidate();
|
|
640
|
-
},
|
|
641
|
-
};
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const text = row.contentText;
|
|
646
|
-
if (typeof text?.text !== "string" || typeof text.setText !== "function")
|
|
647
|
-
return;
|
|
648
|
-
const lines = text.text.split("\n");
|
|
649
|
-
const title = lines[0] ?? "";
|
|
650
|
-
const droppedVisible = lines.slice(1).some((line) => line.trim().length > 0);
|
|
651
|
-
const tail = liveTailText(row, droppedVisible, theme);
|
|
652
|
-
text.setText(tail ? `${title} ${tail}` : title);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
435
|
function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
|
|
656
436
|
return (
|
|
657
437
|
row.expanded === false &&
|
|
@@ -689,6 +469,7 @@ function isGroupableToolRow(
|
|
|
689
469
|
renderAt: (index: number) => string[],
|
|
690
470
|
state: PresentationPatchState,
|
|
691
471
|
): boolean {
|
|
472
|
+
if (row.toolName === "subagent") return false;
|
|
692
473
|
if (!isLiveRow(row) && !isCollapsibleSuccess(row)) return false;
|
|
693
474
|
return (
|
|
694
475
|
state.collapseParallel ||
|
|
@@ -823,7 +604,7 @@ function renderedOutcome(
|
|
|
823
604
|
): string | undefined {
|
|
824
605
|
const summary = outcomeSummary(row);
|
|
825
606
|
if (!summary) return undefined;
|
|
826
|
-
return
|
|
607
|
+
return theme.fg("muted", `→ ${summary}`);
|
|
827
608
|
}
|
|
828
609
|
|
|
829
610
|
function renderedGroupedOutcome(
|
|
@@ -834,7 +615,7 @@ function renderedGroupedOutcome(
|
|
|
834
615
|
if (outcome) return outcome;
|
|
835
616
|
if (isLiveRow(row)) {
|
|
836
617
|
const elapsed = liveElapsedText(row);
|
|
837
|
-
return theme.fg("
|
|
618
|
+
return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
|
|
838
619
|
}
|
|
839
620
|
// Self-rendered tools have no per-tool outcome summary; keep the group's
|
|
840
621
|
// settled tail consistent with collapsed singletons.
|
|
@@ -842,70 +623,46 @@ function renderedGroupedOutcome(
|
|
|
842
623
|
return undefined;
|
|
843
624
|
}
|
|
844
625
|
|
|
845
|
-
// A budget too small for the suffix keeps literal text instead, so a
|
|
846
|
-
// one-column label never collapses into a bare ellipsis.
|
|
847
|
-
function
|
|
626
|
+
// A budget too small for the suffix keeps literal styled text instead, so a
|
|
627
|
+
// one-column label never collapses into a bare ellipsis or loses its status.
|
|
628
|
+
function truncateStyled(text: string, width: number, suffix = "…"): string {
|
|
848
629
|
const max = Math.max(0, width);
|
|
849
|
-
|
|
850
|
-
if (visibleWidth(plain) <= max) return plain;
|
|
630
|
+
if (visibleWidth(text) <= max) return text;
|
|
851
631
|
|
|
852
632
|
const suffixWidth = visibleWidth(suffix);
|
|
853
|
-
if (suffixWidth >= max) return sliceByColumn(
|
|
854
|
-
return
|
|
633
|
+
if (suffixWidth >= max) return sliceByColumn(text, 0, max, true);
|
|
634
|
+
return truncateToWidth(text, max, suffix, false);
|
|
855
635
|
}
|
|
856
636
|
|
|
857
|
-
function fitSummary(summary: string, width: number): string {
|
|
637
|
+
function fitSummary(summary: string, width: number, suffix = "…"): string {
|
|
858
638
|
const max = Math.max(1, width);
|
|
859
|
-
return visibleWidth(summary) <= max
|
|
639
|
+
return visibleWidth(summary) <= max
|
|
640
|
+
? summary
|
|
641
|
+
: truncateStyled(summary, max, suffix);
|
|
860
642
|
}
|
|
861
643
|
|
|
862
644
|
// Fits "head tail" into width while always reserving at least one literal
|
|
863
645
|
// head column plus the joining space; the tail takes what is left and
|
|
864
646
|
// disappears entirely when no columns remain.
|
|
865
|
-
function fitSummaryTail(
|
|
647
|
+
function fitSummaryTail(
|
|
648
|
+
head: string,
|
|
649
|
+
tail: string,
|
|
650
|
+
width: number,
|
|
651
|
+
suffix = "…",
|
|
652
|
+
): string {
|
|
866
653
|
const max = Math.max(1, width);
|
|
867
654
|
const headWidth = visibleWidth(head);
|
|
868
655
|
const tailWidth = visibleWidth(tail);
|
|
869
656
|
if (headWidth + 1 + tailWidth <= max) return `${head.trimEnd()} ${tail}`;
|
|
870
|
-
if (tailWidth === 0) return
|
|
871
|
-
if (headWidth === 0) return
|
|
657
|
+
if (tailWidth === 0) return truncateStyled(head, max, suffix);
|
|
658
|
+
if (headWidth === 0) return truncateStyled(tail, max, suffix);
|
|
872
659
|
|
|
873
660
|
const labelBudget = Math.min(headWidth, Math.max(1, max - tailWidth - 1));
|
|
874
661
|
const tailBudget = Math.min(tailWidth, Math.max(0, max - labelBudget - 1));
|
|
875
|
-
if (tailBudget === 0) return
|
|
662
|
+
if (tailBudget === 0) return truncateStyled(head, max, suffix);
|
|
876
663
|
const fittedTail =
|
|
877
|
-
tailWidth <= tailBudget ? tail :
|
|
878
|
-
return `${
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function decorateSuccessfulCall(
|
|
882
|
-
row: ToolExecutionRow,
|
|
883
|
-
theme?: ThemeLike,
|
|
884
|
-
): void {
|
|
885
|
-
if (!theme || !Array.isArray(row.contentBox?.children)) return;
|
|
886
|
-
const original = row.contentBox.children[0] as ComponentLike | undefined;
|
|
887
|
-
const outcome = renderedOutcome(row, theme);
|
|
888
|
-
if (!original || typeof original.render !== "function" || !outcome) return;
|
|
889
|
-
|
|
890
|
-
row.contentBox.children[0] = {
|
|
891
|
-
render(width: number): string[] {
|
|
892
|
-
const lines = original.render(width);
|
|
893
|
-
const visibleLines = lines.filter(hasVisibleContent);
|
|
894
|
-
const line = visibleLines[0];
|
|
895
|
-
if (line === undefined) return lines;
|
|
896
|
-
|
|
897
|
-
const rendered = visibleLines.slice(0, 3).map(trimRenderedLine);
|
|
898
|
-
if (visibleLines.length > rendered.length)
|
|
899
|
-
rendered.push(theme.fg("muted", "…"));
|
|
900
|
-
const summary = rendered.join(theme.fg("muted", " · "));
|
|
901
|
-
return [
|
|
902
|
-
fitSummaryTail(summary, outcome, Math.max(1, width - BADGE_WIDTH)),
|
|
903
|
-
];
|
|
904
|
-
},
|
|
905
|
-
invalidate() {
|
|
906
|
-
original.invalidate();
|
|
907
|
-
},
|
|
908
|
-
};
|
|
664
|
+
tailWidth <= tailBudget ? tail : truncateStyled(tail, tailBudget, suffix);
|
|
665
|
+
return `${truncateStyled(head, labelBudget, suffix)} ${fittedTail}`;
|
|
909
666
|
}
|
|
910
667
|
|
|
911
668
|
function removeTrailingExpandHint(text: string): string {
|
|
@@ -920,12 +677,12 @@ function removeTrailingExpandHint(text: string): string {
|
|
|
920
677
|
}
|
|
921
678
|
|
|
922
679
|
function trimRenderedLine(text: string): string {
|
|
923
|
-
const plain = stripAnsi(text);
|
|
680
|
+
const plain = sanitizeInline(stripAnsi(text));
|
|
924
681
|
const leading = plain.match(/^\s*/)?.[0] ?? "";
|
|
925
682
|
const trimmed = plain.trim();
|
|
926
683
|
if (!trimmed) return "";
|
|
927
684
|
return sliceByColumn(
|
|
928
|
-
|
|
685
|
+
plain,
|
|
929
686
|
visibleWidth(leading),
|
|
930
687
|
visibleWidth(trimmed),
|
|
931
688
|
).trimEnd();
|
|
@@ -987,25 +744,348 @@ function renderedCallSummary(
|
|
|
987
744
|
|
|
988
745
|
const fallback = compactArgs(row.args);
|
|
989
746
|
return fallback
|
|
990
|
-
? theme.fg("
|
|
747
|
+
? theme.fg("muted", fallback)
|
|
991
748
|
: theme.fg("muted", "(no arguments)");
|
|
992
749
|
}
|
|
993
750
|
|
|
751
|
+
function styledCallLabel(
|
|
752
|
+
label: string,
|
|
753
|
+
theme: ThemeLike,
|
|
754
|
+
color = "muted",
|
|
755
|
+
): string {
|
|
756
|
+
const plain = sanitizeInline(stripAnsi(label)).trim();
|
|
757
|
+
const match = /^(\S+)(.*)$/s.exec(plain);
|
|
758
|
+
if (!match) return theme.fg(color, plain);
|
|
759
|
+
return (
|
|
760
|
+
theme.fg(color, theme.bold(match[1] ?? "")) +
|
|
761
|
+
theme.fg(color, match[2] ?? "")
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function collapsedCallLabel(
|
|
766
|
+
row: ToolExecutionRow,
|
|
767
|
+
width: number,
|
|
768
|
+
theme: ThemeLike,
|
|
769
|
+
color = "muted",
|
|
770
|
+
): string {
|
|
771
|
+
if (row.getRenderShell?.() === "self") {
|
|
772
|
+
return styledCallLabel(selfRenderedCallLabel(row), theme, color);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const token = row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
|
|
776
|
+
const summary = renderedCallSummary(row, Math.max(1, width), theme);
|
|
777
|
+
const plainSummary = stripAnsi(summary).trim();
|
|
778
|
+
const label = plainSummary ? `${token} ${plainSummary}` : token;
|
|
779
|
+
return styledCallLabel(label, theme, color);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function collapsedOutcome(
|
|
783
|
+
row: ToolExecutionRow,
|
|
784
|
+
width: number,
|
|
785
|
+
theme: ThemeLike,
|
|
786
|
+
): string | undefined {
|
|
787
|
+
if (rowHasFailed(row)) {
|
|
788
|
+
return capFailureTail(
|
|
789
|
+
renderedErrorOutcome(row, theme),
|
|
790
|
+
Math.max(3, Math.floor(width / 2)),
|
|
791
|
+
theme,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (isLiveRow(row)) {
|
|
795
|
+
const elapsed = liveElapsedText(row);
|
|
796
|
+
return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
|
|
797
|
+
}
|
|
798
|
+
if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
|
|
799
|
+
return renderedOutcome(row, theme);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function collapsedHeadline(
|
|
803
|
+
row: ToolExecutionRow,
|
|
804
|
+
width: number,
|
|
805
|
+
theme: ThemeLike,
|
|
806
|
+
): string {
|
|
807
|
+
// Failed rows render entirely in error so the row reads as the one that
|
|
808
|
+
// failed, not just its outcome tail.
|
|
809
|
+
const color = rowHasFailed(row) ? "error" : "muted";
|
|
810
|
+
const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
|
|
811
|
+
const budget = Math.max(1, width - visibleWidth(marker));
|
|
812
|
+
const label = collapsedCallLabel(row, budget, theme, color);
|
|
813
|
+
const outcome = collapsedOutcome(row, budget, theme);
|
|
814
|
+
// The truncation suffix inherits the row tone; pi-tui's truncation resets
|
|
815
|
+
// around a plain suffix, which would render it in the terminal default
|
|
816
|
+
// foreground instead of the row color.
|
|
817
|
+
const suffix = theme.fg(color, "…");
|
|
818
|
+
return (
|
|
819
|
+
marker +
|
|
820
|
+
(outcome
|
|
821
|
+
? fitSummaryTail(label, outcome, budget, suffix)
|
|
822
|
+
: fitSummary(label, budget, suffix))
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
827
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
type SubagentStep = {
|
|
831
|
+
agent: string;
|
|
832
|
+
profile: string | undefined;
|
|
833
|
+
task: string;
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
type SubagentPlan = {
|
|
837
|
+
kind: "single" | "parallel" | "chain";
|
|
838
|
+
scope: string;
|
|
839
|
+
steps: SubagentStep[];
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
function parseSubagentArgs(args: unknown): SubagentPlan | undefined {
|
|
843
|
+
if (!isRecord(args)) return undefined;
|
|
844
|
+
const scope =
|
|
845
|
+
args.agentScope === "project" || args.agentScope === "both"
|
|
846
|
+
? args.agentScope
|
|
847
|
+
: "user";
|
|
848
|
+
const hasParallel = Array.isArray(args.tasks) && args.tasks.length > 0;
|
|
849
|
+
const hasChain = Array.isArray(args.chain) && args.chain.length > 0;
|
|
850
|
+
const hasSingle =
|
|
851
|
+
typeof args.task === "string" && args.task.trim().length > 0;
|
|
852
|
+
if (Number(hasParallel) + Number(hasChain) + Number(hasSingle) !== 1) {
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const parseStep = (item: unknown): SubagentStep | undefined => {
|
|
857
|
+
if (!isRecord(item) || typeof item.task !== "string") return undefined;
|
|
858
|
+
return {
|
|
859
|
+
agent:
|
|
860
|
+
typeof item.agent === "string" && item.agent.trim()
|
|
861
|
+
? item.agent.trim()
|
|
862
|
+
: "...",
|
|
863
|
+
// The subagent extension suppresses the profile badge when a model
|
|
864
|
+
// override is present; mirror that here.
|
|
865
|
+
profile:
|
|
866
|
+
typeof item.profile === "string" &&
|
|
867
|
+
item.profile.trim() &&
|
|
868
|
+
item.model === undefined
|
|
869
|
+
? item.profile.trim()
|
|
870
|
+
: undefined,
|
|
871
|
+
task: item.task,
|
|
872
|
+
};
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
if (hasParallel) {
|
|
876
|
+
const steps = (args.tasks as unknown[]).map(parseStep);
|
|
877
|
+
if (steps.some((step) => step === undefined)) return undefined;
|
|
878
|
+
return { kind: "parallel", scope, steps: steps as SubagentStep[] };
|
|
879
|
+
}
|
|
880
|
+
if (hasChain) {
|
|
881
|
+
const steps = (args.chain as unknown[]).map(parseStep);
|
|
882
|
+
if (steps.some((step) => step === undefined)) return undefined;
|
|
883
|
+
return { kind: "chain", scope, steps: steps as SubagentStep[] };
|
|
884
|
+
}
|
|
885
|
+
const single = parseStep(args);
|
|
886
|
+
if (!single) return undefined;
|
|
887
|
+
return { kind: "single", scope, steps: [single] };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Splits a scraped plan line's content into the agent display name (which may
|
|
891
|
+
// carry a leading emoji) and the remaining badge/preview text.
|
|
892
|
+
function parseStepContent(content: string): {
|
|
893
|
+
displayName: string;
|
|
894
|
+
bareName: string;
|
|
895
|
+
rest: string;
|
|
896
|
+
} {
|
|
897
|
+
const tokens = content.trim().split(/\s+/).filter(Boolean);
|
|
898
|
+
if (tokens.length === 0) return { displayName: "", bareName: "", rest: "" };
|
|
899
|
+
const first = tokens[0]!;
|
|
900
|
+
const hasEmoji = [...first].some((char) => (char.codePointAt(0) ?? 0) > 0x7f);
|
|
901
|
+
const nameTokens = hasEmoji && tokens.length > 1 ? 2 : 1;
|
|
902
|
+
const displayName = tokens.slice(0, nameTokens).join(" ");
|
|
903
|
+
return {
|
|
904
|
+
displayName,
|
|
905
|
+
bareName: tokens[nameTokens - 1] ?? displayName,
|
|
906
|
+
rest: tokens.slice(nameTokens).join(" "),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Agent display names (emoji + name) come from the subagent extension's own
|
|
911
|
+
// call component when available; args only carry the bare agent name.
|
|
912
|
+
function scrapeSubagentDisplayNames(
|
|
913
|
+
row: ToolExecutionRow,
|
|
914
|
+
): Map<string, string> {
|
|
915
|
+
const names = new Map<string, string>();
|
|
916
|
+
let component = row.callRendererComponent;
|
|
917
|
+
if (!component && Array.isArray(row.contentBox?.children)) {
|
|
918
|
+
component = row.contentBox.children[0] as ComponentLike | undefined;
|
|
919
|
+
}
|
|
920
|
+
if (!component || typeof component.render !== "function") return names;
|
|
921
|
+
try {
|
|
922
|
+
for (const raw of component.render(120)) {
|
|
923
|
+
const line = sanitizeInline(stripAnsi(raw)).trim();
|
|
924
|
+
if (!line || /^subagent\b/.test(line)) continue;
|
|
925
|
+
const { displayName, bareName } = parseStepContent(
|
|
926
|
+
line.replace(/^\d+\.\s*/, ""),
|
|
927
|
+
);
|
|
928
|
+
if (displayName && bareName && displayName !== bareName) {
|
|
929
|
+
names.set(bareName, displayName);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch {
|
|
933
|
+
// Display-name scraping is cosmetic; args still carry the bare name.
|
|
934
|
+
}
|
|
935
|
+
return names;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function subagentStepPreview(task: string): string {
|
|
939
|
+
const clean = sanitizeInline(task)
|
|
940
|
+
.replace(/\{previous\}/gi, " ")
|
|
941
|
+
.replace(/\s+/g, " ")
|
|
942
|
+
.trim();
|
|
943
|
+
return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Subagents render as an ordinary unboxed tool block: a `%` heading with the
|
|
947
|
+
// plan kind/count/scope, then the numbered chain steps or parallel tasks with
|
|
948
|
+
// agent names in accent. Everything else stays muted (or error on failure),
|
|
949
|
+
// matching the shared tool-row aesthetic.
|
|
950
|
+
function renderSubagentPlan(
|
|
951
|
+
row: ToolExecutionRow,
|
|
952
|
+
width: number,
|
|
953
|
+
theme: ThemeLike,
|
|
954
|
+
): string[] | undefined {
|
|
955
|
+
const plan = parseSubagentArgs(row.args);
|
|
956
|
+
if (!plan || width < 12) return undefined;
|
|
957
|
+
|
|
958
|
+
const failed = rowHasFailed(row);
|
|
959
|
+
const color = failed ? "error" : "muted";
|
|
960
|
+
const nameColor = failed ? "error" : "accent";
|
|
961
|
+
const suffix = theme.fg(color, "…");
|
|
962
|
+
const displayNames = scrapeSubagentDisplayNames(row);
|
|
963
|
+
// Agent/profile/task values are model-supplied; sanitize them like every
|
|
964
|
+
// other collapsed-row text so control bytes cannot reach the terminal.
|
|
965
|
+
const displayOf = (agent: string) =>
|
|
966
|
+
theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
|
|
967
|
+
const detailOf = (step: SubagentStep) =>
|
|
968
|
+
theme.fg(
|
|
969
|
+
color,
|
|
970
|
+
`${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
|
|
974
|
+
const budget = Math.max(1, width - visibleWidth(marker));
|
|
975
|
+
const outcome = failed
|
|
976
|
+
? collapsedOutcome(row, budget, theme)
|
|
977
|
+
: isLiveRow(row)
|
|
978
|
+
? collapsedOutcome(row, budget, theme)
|
|
979
|
+
: row.result
|
|
980
|
+
? renderedGenericOutcome(theme)
|
|
981
|
+
: theme.fg("warning", "…");
|
|
982
|
+
const headline = (label: string) =>
|
|
983
|
+
marker +
|
|
984
|
+
(outcome
|
|
985
|
+
? fitSummaryTail(label, outcome, budget, suffix)
|
|
986
|
+
: fitSummary(label, budget, suffix));
|
|
987
|
+
|
|
988
|
+
if (plan.kind === "single") {
|
|
989
|
+
const step = plan.steps[0]!;
|
|
990
|
+
return [
|
|
991
|
+
headline(
|
|
992
|
+
theme.fg(color, theme.bold("subagent")) +
|
|
993
|
+
" " +
|
|
994
|
+
displayOf(step.agent) +
|
|
995
|
+
detailOf(step),
|
|
996
|
+
),
|
|
997
|
+
];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const kindLabel =
|
|
1001
|
+
plan.kind === "chain"
|
|
1002
|
+
? `chain (${plan.steps.length} ${plan.steps.length === 1 ? "step" : "steps"})`
|
|
1003
|
+
: `parallel (${plan.steps.length} tasks)`;
|
|
1004
|
+
const lines = [
|
|
1005
|
+
headline(
|
|
1006
|
+
theme.fg(color, theme.bold("subagent")) +
|
|
1007
|
+
" " +
|
|
1008
|
+
theme.fg(color, kindLabel) +
|
|
1009
|
+
theme.fg(color, ` [${plan.scope}]`),
|
|
1010
|
+
),
|
|
1011
|
+
];
|
|
1012
|
+
const shown = plan.steps.slice(0, 3);
|
|
1013
|
+
for (let index = 0; index < shown.length; index++) {
|
|
1014
|
+
const step = shown[index]!;
|
|
1015
|
+
const number =
|
|
1016
|
+
plan.kind === "chain" ? `${theme.fg(color, `${index + 1}.`)} ` : "";
|
|
1017
|
+
lines.push(` ${number}${displayOf(step.agent)}${detailOf(step)}`);
|
|
1018
|
+
}
|
|
1019
|
+
if (plan.steps.length > shown.length) {
|
|
1020
|
+
lines.push(
|
|
1021
|
+
` ${theme.fg(color, `... +${plan.steps.length - shown.length} more`)}`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return lines;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function imageResultLines(
|
|
1028
|
+
row: ToolExecutionRow,
|
|
1029
|
+
width: number,
|
|
1030
|
+
theme: ThemeLike,
|
|
1031
|
+
): string[] {
|
|
1032
|
+
if (!hasImageResult(row)) return [];
|
|
1033
|
+
const lines: string[] = [];
|
|
1034
|
+
const output = row.getTextOutput?.() || resultText(row);
|
|
1035
|
+
if (output) {
|
|
1036
|
+
const sanitized = output
|
|
1037
|
+
.split("\n")
|
|
1038
|
+
.map((line) => sanitizeInline(line))
|
|
1039
|
+
.join("\n");
|
|
1040
|
+
lines.push(
|
|
1041
|
+
...wrapTextWithAnsi(
|
|
1042
|
+
theme.fg("toolOutput", sanitized),
|
|
1043
|
+
Math.max(1, width),
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
for (let index = 0; index < (row.imageComponents?.length ?? 0); index++) {
|
|
1049
|
+
const spacer = row.imageSpacers?.[index];
|
|
1050
|
+
if (spacer) lines.push(...renderComponent(spacer, width));
|
|
1051
|
+
const image = row.imageComponents?.[index];
|
|
1052
|
+
if (image) lines.push(...renderComponent(image, width));
|
|
1053
|
+
}
|
|
1054
|
+
return lines.map((line) => truncateToWidth(line, width, "", false));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function renderCollapsedToolRow(
|
|
1058
|
+
row: ToolExecutionRow,
|
|
1059
|
+
width: number,
|
|
1060
|
+
theme: ThemeLike,
|
|
1061
|
+
): string[] {
|
|
1062
|
+
const layout = insetLayout(width);
|
|
1063
|
+
const plan =
|
|
1064
|
+
row.toolName === "subagent"
|
|
1065
|
+
? renderSubagentPlan(row, layout.contentWidth, theme)
|
|
1066
|
+
: undefined;
|
|
1067
|
+
const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
|
|
1068
|
+
body.push(...imageResultLines(row, layout.contentWidth, theme));
|
|
1069
|
+
return ["", ...insetLines(body, width)];
|
|
1070
|
+
}
|
|
1071
|
+
|
|
994
1072
|
function compactBulletLine(
|
|
995
1073
|
summary: string,
|
|
996
1074
|
outcome: string | undefined,
|
|
997
1075
|
width: number,
|
|
998
1076
|
theme: ThemeLike,
|
|
1077
|
+
color = "muted",
|
|
1078
|
+
indentSpaces = 2,
|
|
999
1079
|
): string {
|
|
1000
|
-
const prefix =
|
|
1080
|
+
const prefix = `${" ".repeat(indentSpaces)}${theme.fg(color, "•")} `;
|
|
1001
1081
|
const indent = visibleWidth(prefix);
|
|
1002
1082
|
if (width <= indent) return truncateToWidth(prefix, width, "", false);
|
|
1003
1083
|
const available = width - indent;
|
|
1004
1084
|
return (
|
|
1005
1085
|
prefix +
|
|
1006
1086
|
(outcome
|
|
1007
|
-
? fitSummaryTail(summary, outcome, available)
|
|
1008
|
-
: fitSummary(summary, available))
|
|
1087
|
+
? fitSummaryTail(summary, outcome, available, theme.fg(color, "…"))
|
|
1088
|
+
: fitSummary(summary, available, theme.fg(color, "…")))
|
|
1009
1089
|
);
|
|
1010
1090
|
}
|
|
1011
1091
|
|
|
@@ -1015,23 +1095,45 @@ function groupedCallComponent(
|
|
|
1015
1095
|
): ComponentLike {
|
|
1016
1096
|
return {
|
|
1017
1097
|
render(width: number): string[] {
|
|
1018
|
-
|
|
1098
|
+
// One marker for the whole consecutive block; each tool run gets a
|
|
1099
|
+
// sub-heading, and calls nest one level deeper as sub-bullets. No blank
|
|
1100
|
+
// lines inside the block — the hierarchy carries the separation.
|
|
1101
|
+
const lines: string[] = [
|
|
1102
|
+
truncateToWidth(
|
|
1103
|
+
theme.fg("muted", theme.bold(GROUP_MARKER)),
|
|
1104
|
+
width,
|
|
1105
|
+
"",
|
|
1106
|
+
false,
|
|
1107
|
+
),
|
|
1108
|
+
];
|
|
1019
1109
|
let previousToolName: string | undefined;
|
|
1020
1110
|
for (const row of rows) {
|
|
1021
|
-
if (
|
|
1022
|
-
if (lines.length > 0) lines.push("");
|
|
1111
|
+
if (row.toolName !== previousToolName) {
|
|
1023
1112
|
const token =
|
|
1024
1113
|
row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1114
|
+
lines.push(
|
|
1115
|
+
truncateToWidth(
|
|
1116
|
+
` ${theme.fg("muted", theme.bold(token))}`,
|
|
1117
|
+
width,
|
|
1118
|
+
"",
|
|
1119
|
+
false,
|
|
1120
|
+
),
|
|
1028
1121
|
);
|
|
1029
|
-
lines.push(truncateToWidth(heading, width, "", false));
|
|
1030
1122
|
previousToolName = row.toolName;
|
|
1031
1123
|
}
|
|
1032
|
-
const
|
|
1124
|
+
const color = rowHasFailed(row) ? "error" : "muted";
|
|
1125
|
+
const call = renderedCallSummary(row, Math.max(1, width - 6), theme);
|
|
1033
1126
|
const outcome = renderedGroupedOutcome(row, theme);
|
|
1034
|
-
lines.push(
|
|
1127
|
+
lines.push(
|
|
1128
|
+
compactBulletLine(
|
|
1129
|
+
theme.fg(color, stripAnsi(call)),
|
|
1130
|
+
outcome,
|
|
1131
|
+
width,
|
|
1132
|
+
theme,
|
|
1133
|
+
color,
|
|
1134
|
+
4,
|
|
1135
|
+
),
|
|
1136
|
+
);
|
|
1035
1137
|
}
|
|
1036
1138
|
return lines;
|
|
1037
1139
|
},
|
|
@@ -1039,21 +1141,6 @@ function groupedCallComponent(
|
|
|
1039
1141
|
};
|
|
1040
1142
|
}
|
|
1041
1143
|
|
|
1042
|
-
function renderWithTemporaryChild(
|
|
1043
|
-
container: ComponentContainer,
|
|
1044
|
-
child: ComponentLike,
|
|
1045
|
-
render: () => string[],
|
|
1046
|
-
): string[] {
|
|
1047
|
-
const children = container.children;
|
|
1048
|
-
if (!Array.isArray(children)) return render();
|
|
1049
|
-
container.children = [child];
|
|
1050
|
-
try {
|
|
1051
|
-
return render();
|
|
1052
|
-
} finally {
|
|
1053
|
-
container.children = children;
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
1144
|
function sameMembers(
|
|
1058
1145
|
left: ToolExecutionRow[],
|
|
1059
1146
|
right: ToolExecutionRow[],
|
|
@@ -1084,18 +1171,13 @@ function renderGroupedToolRows(
|
|
|
1084
1171
|
state: PresentationPatchState,
|
|
1085
1172
|
): string[] {
|
|
1086
1173
|
const theme = state.theme;
|
|
1087
|
-
if (!theme)
|
|
1088
|
-
return decorateHeader(
|
|
1089
|
-
row,
|
|
1090
|
-
state.originalRender.call(row, width),
|
|
1091
|
-
width,
|
|
1092
|
-
theme,
|
|
1093
|
-
);
|
|
1174
|
+
if (!theme) return state.originalRender.call(row, width);
|
|
1094
1175
|
const themeSample =
|
|
1095
1176
|
theme.fg("toolTitle", "x") +
|
|
1096
1177
|
theme.fg("muted", "x") +
|
|
1097
|
-
theme.
|
|
1098
|
-
theme.
|
|
1178
|
+
theme.fg("dim", "x") +
|
|
1179
|
+
theme.fg("warning", "x") +
|
|
1180
|
+
theme.fg("error", "x");
|
|
1099
1181
|
const cached = state.groupCache.get(row);
|
|
1100
1182
|
const hasLiveMembers = rows.some(isLiveRow);
|
|
1101
1183
|
if (
|
|
@@ -1109,68 +1191,21 @@ function renderGroupedToolRows(
|
|
|
1109
1191
|
return cached.lines;
|
|
1110
1192
|
}
|
|
1111
1193
|
|
|
1194
|
+
const layout = insetLayout(width);
|
|
1112
1195
|
const summary = groupedCallComponent(rows, theme);
|
|
1113
|
-
|
|
1114
|
-
if (!row.hasRendererDefinition?.()) {
|
|
1115
|
-
const text = row.contentText;
|
|
1116
|
-
const previous = text?.text;
|
|
1117
|
-
if (typeof previous !== "string" || typeof text?.setText !== "function") {
|
|
1118
|
-
return decorateHeader(
|
|
1119
|
-
row,
|
|
1120
|
-
state.originalRender.call(row, width),
|
|
1121
|
-
width,
|
|
1122
|
-
theme,
|
|
1123
|
-
);
|
|
1124
|
-
}
|
|
1125
|
-
text.setText(summary.render(Math.max(1, width - 2)).join("\n"));
|
|
1126
|
-
try {
|
|
1127
|
-
lines = state.originalRender.call(row, width);
|
|
1128
|
-
} finally {
|
|
1129
|
-
text.setText(previous);
|
|
1130
|
-
}
|
|
1131
|
-
} else if (row.getRenderShell?.() === "self") {
|
|
1132
|
-
const container = row.selfRenderContainer;
|
|
1133
|
-
if (!container)
|
|
1134
|
-
return decorateHeader(
|
|
1135
|
-
row,
|
|
1136
|
-
state.originalRender.call(row, width),
|
|
1137
|
-
width,
|
|
1138
|
-
theme,
|
|
1139
|
-
);
|
|
1140
|
-
const box = new Box(1, 1, (text) =>
|
|
1141
|
-
theme.bg(hasLiveMembers ? "toolPendingBg" : "toolSuccessBg", text),
|
|
1142
|
-
);
|
|
1143
|
-
box.addChild(summary);
|
|
1144
|
-
lines = renderWithTemporaryChild(container, box, () =>
|
|
1145
|
-
state.originalRender.call(row, width),
|
|
1146
|
-
);
|
|
1147
|
-
} else {
|
|
1148
|
-
const container = row.contentBox;
|
|
1149
|
-
if (!container)
|
|
1150
|
-
return decorateHeader(
|
|
1151
|
-
row,
|
|
1152
|
-
state.originalRender.call(row, width),
|
|
1153
|
-
width,
|
|
1154
|
-
theme,
|
|
1155
|
-
);
|
|
1156
|
-
lines = renderWithTemporaryChild(container, summary, () =>
|
|
1157
|
-
state.originalRender.call(row, width),
|
|
1158
|
-
);
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const decorated = decorateHeader(row, lines, width, theme);
|
|
1196
|
+
const lines = ["", ...insetLines(summary.render(layout.contentWidth), width)];
|
|
1162
1197
|
if (hasLiveMembers) {
|
|
1163
1198
|
state.groupCache.delete(row);
|
|
1164
1199
|
} else {
|
|
1165
1200
|
state.groupCache.set(row, {
|
|
1166
|
-
lines
|
|
1201
|
+
lines,
|
|
1167
1202
|
members: [...rows],
|
|
1168
1203
|
memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
|
|
1169
1204
|
themeSample,
|
|
1170
1205
|
width,
|
|
1171
1206
|
});
|
|
1172
1207
|
}
|
|
1173
|
-
return
|
|
1208
|
+
return lines;
|
|
1174
1209
|
}
|
|
1175
1210
|
|
|
1176
1211
|
function renderContainerWithToolGroups(
|
|
@@ -1234,9 +1269,8 @@ function renderContainerWithToolGroups(
|
|
|
1234
1269
|
}
|
|
1235
1270
|
|
|
1236
1271
|
for (const member of group) presentation.rowGroups.set(member, group);
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
// the grouped row keeps the same number of lines.
|
|
1272
|
+
// Use a live member while any call is pending, then the first member once
|
|
1273
|
+
// settled. Either way, the grouped row keeps the same number of lines.
|
|
1240
1274
|
const shellRow = group.find(isLiveRow) ?? child;
|
|
1241
1275
|
lines.push(...renderGroupedToolRows(shellRow, group, width, presentation));
|
|
1242
1276
|
index = lastMemberIndex;
|
|
@@ -1259,6 +1293,7 @@ function installGroupingPatch(
|
|
|
1259
1293
|
const existing = proto[GROUPING_PATCHED];
|
|
1260
1294
|
if (existing) {
|
|
1261
1295
|
existing.presentation = presentation;
|
|
1296
|
+
existing.disabled = false;
|
|
1262
1297
|
return existing;
|
|
1263
1298
|
}
|
|
1264
1299
|
|
|
@@ -1271,10 +1306,19 @@ function installGroupingPatch(
|
|
|
1271
1306
|
width: number,
|
|
1272
1307
|
): string[] {
|
|
1273
1308
|
const children = this.children;
|
|
1274
|
-
if (
|
|
1309
|
+
if (
|
|
1310
|
+
state.disabled ||
|
|
1311
|
+
!Array.isArray(children) ||
|
|
1312
|
+
!children.some(isToolExecutionRow)
|
|
1313
|
+
) {
|
|
1275
1314
|
return state.originalRender.call(this, width);
|
|
1276
1315
|
}
|
|
1316
|
+
let restoreHooks: () => void = () => {};
|
|
1277
1317
|
try {
|
|
1318
|
+
// Other container-level concerns (pi-content-layout's system-text
|
|
1319
|
+
// inset) decorate children through the shared hooks before grouping
|
|
1320
|
+
// rewrites the rows; delegation alone cannot reach them from here.
|
|
1321
|
+
restoreHooks = runChatContainerHooks(this, children, width);
|
|
1278
1322
|
return renderContainerWithToolGroups(
|
|
1279
1323
|
children,
|
|
1280
1324
|
width,
|
|
@@ -1282,6 +1326,8 @@ function installGroupingPatch(
|
|
|
1282
1326
|
);
|
|
1283
1327
|
} catch {
|
|
1284
1328
|
return state.originalRender.call(this, width);
|
|
1329
|
+
} finally {
|
|
1330
|
+
restoreHooks();
|
|
1285
1331
|
}
|
|
1286
1332
|
};
|
|
1287
1333
|
|
|
@@ -1300,6 +1346,10 @@ function installGroupingPatch(
|
|
|
1300
1346
|
|
|
1301
1347
|
function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
|
|
1302
1348
|
if (!state) return;
|
|
1349
|
+
// Even when another extension's wrapper sits on top of ours, grouping must
|
|
1350
|
+
// not outlive its owner: the wrapper delegates once disabled, and a later
|
|
1351
|
+
// reinstall re-enables it.
|
|
1352
|
+
state.disabled = true;
|
|
1303
1353
|
const proto = Container?.prototype as unknown as ComponentContainer & {
|
|
1304
1354
|
[GROUPING_PATCHED]?: GroupingPatchState;
|
|
1305
1355
|
render?: (width: number) => string[];
|
|
@@ -1359,20 +1409,23 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1359
1409
|
state.groupCache.delete(this);
|
|
1360
1410
|
}
|
|
1361
1411
|
state.originalUpdateDisplay.call(this);
|
|
1362
|
-
try {
|
|
1363
|
-
if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
|
|
1364
|
-
else collapseSuccessfulResult(this, state.theme);
|
|
1365
|
-
} catch {
|
|
1366
|
-
// Presentation is cosmetic; preserve Pi's renderer if its internals change.
|
|
1367
|
-
}
|
|
1368
1412
|
};
|
|
1369
1413
|
const patchedRender = function renderWithToolPresentation(
|
|
1370
1414
|
this: ToolExecutionRow,
|
|
1371
1415
|
width: number,
|
|
1372
1416
|
): string[] {
|
|
1373
1417
|
const lines = state.originalRender.call(this, width);
|
|
1418
|
+
if (
|
|
1419
|
+
typeof this.expanded !== "boolean" ||
|
|
1420
|
+
typeof this.isPartial !== "boolean" ||
|
|
1421
|
+
this.expanded ||
|
|
1422
|
+
!state.theme ||
|
|
1423
|
+
(lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
|
|
1424
|
+
) {
|
|
1425
|
+
return lines;
|
|
1426
|
+
}
|
|
1374
1427
|
try {
|
|
1375
|
-
return
|
|
1428
|
+
return renderCollapsedToolRow(this, width, state.theme);
|
|
1376
1429
|
} catch {
|
|
1377
1430
|
return lines;
|
|
1378
1431
|
}
|