@pi-kaush/pi-tool-call-markers 0.2.3 → 0.2.5
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 +20 -14
- package/README.md +58 -31
- package/package.json +1 -1
- package/src/container-hooks.ts +57 -0
- package/src/index.ts +590 -441
- 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
|
-
}
|
|
181
|
-
|
|
182
|
-
function collapseGenericResult(row: ToolExecutionRow): boolean {
|
|
183
|
-
const text = row.contentText?.text;
|
|
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;
|
|
196
|
-
}
|
|
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;
|
|
197
110
|
|
|
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 = [];
|
|
111
|
+
function sanitizeInline(text: string): string {
|
|
112
|
+
return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
|
|
204
113
|
}
|
|
205
114
|
|
|
206
|
-
function
|
|
207
|
-
|
|
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
|
|
@@ -283,6 +162,18 @@ const MCP_FAILURE_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
|
283
162
|
"not_connected",
|
|
284
163
|
"timeout",
|
|
285
164
|
"script_error",
|
|
165
|
+
// Terminal rejections: the operation never executed (validation failures,
|
|
166
|
+
// auth-flow failures, missing inputs), unlike continuing-guidance codes.
|
|
167
|
+
"missing_server",
|
|
168
|
+
"missing_input",
|
|
169
|
+
"oauth_not_supported",
|
|
170
|
+
"auth_start_failed",
|
|
171
|
+
"not_authenticated",
|
|
172
|
+
"auth_complete_failed",
|
|
173
|
+
"query_too_long",
|
|
174
|
+
"unsafe_pattern",
|
|
175
|
+
"invalid_pattern",
|
|
176
|
+
"empty_query",
|
|
286
177
|
]);
|
|
287
178
|
|
|
288
179
|
function rowHasFailed(row: ToolExecutionRow): boolean {
|
|
@@ -294,46 +185,30 @@ function rowHasFailed(row: ToolExecutionRow): boolean {
|
|
|
294
185
|
function renderedErrorOutcome(row: ToolExecutionRow, theme: ThemeLike): string {
|
|
295
186
|
const firstLine =
|
|
296
187
|
resultText(row)
|
|
297
|
-
.split(
|
|
298
|
-
.map((line) => line.trim())
|
|
188
|
+
.split(/\r\n|[\r\n]/)
|
|
189
|
+
.map((line) => sanitizeInline(line).trim())
|
|
299
190
|
.find((line) => line.length > 0) ?? "error";
|
|
300
|
-
return `${theme.fg("
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function selfRenderedSummaryComponent(
|
|
304
|
-
row: ToolExecutionRow,
|
|
305
|
-
theme: ThemeLike,
|
|
306
|
-
tail: string,
|
|
307
|
-
maxTailShare = 1,
|
|
308
|
-
): ComponentLike {
|
|
309
|
-
return {
|
|
310
|
-
render(width: number): string[] {
|
|
311
|
-
const budget = Math.max(1, width - BADGE_WIDTH - 2);
|
|
312
|
-
const summary = selfRenderedSummary(row, budget);
|
|
313
|
-
// Long error lines would otherwise evict the call summary entirely;
|
|
314
|
-
// cap the tail's share so the label always survives.
|
|
315
|
-
const cappedTail =
|
|
316
|
-
maxTailShare >= 1
|
|
317
|
-
? tail
|
|
318
|
-
: capFailureTail(tail, Math.floor(budget * maxTailShare), theme);
|
|
319
|
-
return [fitSummaryTail(summary, cappedTail, budget)];
|
|
320
|
-
},
|
|
321
|
-
invalidate() {},
|
|
322
|
-
};
|
|
191
|
+
return `${theme.fg("error", "→")} ${theme.fg("error", firstLine)}`;
|
|
323
192
|
}
|
|
324
193
|
|
|
325
194
|
// Trim a failure tail to its quota of the budget, counting the appended
|
|
326
195
|
// ellipsis inside the cap, so the call label keeps the leftover columns
|
|
327
|
-
// plus the joining space instead of being truncated to nothing.
|
|
196
|
+
// plus the joining space instead of being truncated to nothing. A cap too
|
|
197
|
+
// small for the ellipsis keeps that many literal tail characters instead;
|
|
198
|
+
// a zero-column cap lets the tail disappear rather than spill an ellipsis
|
|
199
|
+
// outside its quota.
|
|
328
200
|
function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
|
|
329
201
|
const tailWidth = visibleWidth(tail);
|
|
330
202
|
if (tailWidth <= cap) return tail;
|
|
331
|
-
const ellipsis = theme.fg("
|
|
332
|
-
|
|
203
|
+
const ellipsis = theme.fg("error", "…");
|
|
204
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
205
|
+
if (cap <= 0) return "";
|
|
206
|
+
if (ellipsisWidth >= cap) return sliceByColumn(tail, 0, cap, true);
|
|
207
|
+
return `${sliceByColumn(tail, 0, cap - ellipsisWidth, true)}${ellipsis}`;
|
|
333
208
|
}
|
|
334
209
|
|
|
335
210
|
function renderedGenericOutcome(theme: ThemeLike): string {
|
|
336
|
-
return
|
|
211
|
+
return theme.fg("muted", "→ done");
|
|
337
212
|
}
|
|
338
213
|
|
|
339
214
|
// The MCP adapter stashes its call's first line in renderer state before
|
|
@@ -369,15 +244,105 @@ function flattenNewlines(text: string): string {
|
|
|
369
244
|
return text.replace(/[\r\n]+/g, " ");
|
|
370
245
|
}
|
|
371
246
|
|
|
247
|
+
// Actions the mcp proxy dispatches before any mode selector (pi-mcp-adapter
|
|
248
|
+
// 2.26.0). Unknown action values fall through to other modes, so their shapes
|
|
249
|
+
// are shown raw instead of as a guessed operation.
|
|
250
|
+
const MCP_ACTIONS: ReadonlySet<string> = new Set([
|
|
251
|
+
"ui-messages",
|
|
252
|
+
"auth-start",
|
|
253
|
+
"auth-complete",
|
|
254
|
+
]);
|
|
255
|
+
const MCP_MODE_KEYS = [
|
|
256
|
+
"tool",
|
|
257
|
+
"connect",
|
|
258
|
+
"describe",
|
|
259
|
+
"instructions",
|
|
260
|
+
"action",
|
|
261
|
+
] as const;
|
|
262
|
+
|
|
263
|
+
// A concise one-line label for the adapter's "mcp" proxy from the call's
|
|
264
|
+
// shape alone. The adapter's dispatch order is an implementation detail, so
|
|
265
|
+
// only a single unambiguous selector gets a named operation; conflicting,
|
|
266
|
+
// empty, or unrecognized selectors return undefined so the caller shows the
|
|
267
|
+
// complete compact args instead of claiming which operation executed.
|
|
268
|
+
function mcpProxyCallLabel(
|
|
269
|
+
record: Record<string, unknown>,
|
|
270
|
+
): string | undefined {
|
|
271
|
+
const selectors: string[] = [];
|
|
272
|
+
// An empty search still runs a search; every other selector must be a
|
|
273
|
+
// non-empty string to name an operation at all.
|
|
274
|
+
if (record.search !== undefined) selectors.push("search");
|
|
275
|
+
for (const key of MCP_MODE_KEYS) {
|
|
276
|
+
const value = record[key];
|
|
277
|
+
if (value === undefined) continue;
|
|
278
|
+
// An empty selector cannot dispatch, and which fallback ran is adapter
|
|
279
|
+
// internals, so the raw args are kept instead.
|
|
280
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
281
|
+
selectors.push(key);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const server =
|
|
285
|
+
typeof record.server === "string" && record.server.length > 0
|
|
286
|
+
? record.server
|
|
287
|
+
: undefined;
|
|
288
|
+
const rest: Record<string, unknown> = { ...record };
|
|
289
|
+
if (server !== undefined) delete rest.server;
|
|
290
|
+
const serverSuffix = server === undefined ? "" : ` @ ${server}`;
|
|
291
|
+
|
|
292
|
+
if (selectors.length === 0) {
|
|
293
|
+
// No mode selector: the adapter lists a named server or reports status.
|
|
294
|
+
const label = server === undefined ? "mcp status" : `mcp list ${server}`;
|
|
295
|
+
const restJson = squashJsonish(rest);
|
|
296
|
+
return restJson ? `${label} ${restJson}` : label;
|
|
297
|
+
}
|
|
298
|
+
if (selectors.length !== 1) return undefined;
|
|
299
|
+
|
|
300
|
+
const key = selectors[0]!;
|
|
301
|
+
delete rest[key];
|
|
302
|
+
|
|
303
|
+
if (key === "tool") {
|
|
304
|
+
delete rest.args;
|
|
305
|
+
const inner = squashJsonish(record.args);
|
|
306
|
+
const target = `${String(record.tool)}${serverSuffix}`;
|
|
307
|
+
return inner ? `${target} ${inner}` : target;
|
|
308
|
+
}
|
|
309
|
+
if (key === "search") {
|
|
310
|
+
const label =
|
|
311
|
+
`mcp search ${String(record.search)}`.trimEnd() + serverSuffix;
|
|
312
|
+
const restJson = squashJsonish(rest);
|
|
313
|
+
return restJson ? `${label} ${restJson}` : label;
|
|
314
|
+
}
|
|
315
|
+
if (key === "action") {
|
|
316
|
+
const name = String(record.action);
|
|
317
|
+
if (!MCP_ACTIONS.has(name)) return undefined;
|
|
318
|
+
let label = `mcp ${name}${serverSuffix}`;
|
|
319
|
+
if (name === "auth-complete") {
|
|
320
|
+
delete rest.args;
|
|
321
|
+
const input = squashJsonish(record.args);
|
|
322
|
+
if (input) label += ` ${input}`;
|
|
323
|
+
}
|
|
324
|
+
const restJson = squashJsonish(rest);
|
|
325
|
+
return restJson ? `${label} ${restJson}` : label;
|
|
326
|
+
}
|
|
327
|
+
// connect / describe / instructions: the adapter dispatches these modes
|
|
328
|
+
// without params.server, so a server argument is shown raw rather than as
|
|
329
|
+
// a scope suffix that implies an operation that never ran.
|
|
330
|
+
const label = `mcp ${key} ${String(record[key])}`;
|
|
331
|
+
const restJson = squashJsonish(
|
|
332
|
+
server === undefined ? rest : { ...rest, server },
|
|
333
|
+
);
|
|
334
|
+
return restJson ? `${label} ${restJson}` : label;
|
|
335
|
+
}
|
|
336
|
+
|
|
372
337
|
// Builds a one-line "tool {args}" label from the call arguments. The
|
|
373
338
|
// adapter's own title drops the arguments and its pretty-printed JSON spans
|
|
374
339
|
// many lines, so squash the JSON ourselves to keep the row at one line.
|
|
375
340
|
function selfRenderedCallLabel(row: ToolExecutionRow): string {
|
|
376
341
|
const token = row.toolName ?? "tool";
|
|
377
342
|
const args = row.args;
|
|
378
|
-
// Only the adapter's "mcp" proxy tool uses these action shapes
|
|
379
|
-
// label from the args
|
|
380
|
-
// (limit/offset) and
|
|
343
|
+
// Only the adapter's "mcp" proxy tool uses these action shapes, so the
|
|
344
|
+
// label is derived from the args' shape alone; the adapter's own
|
|
345
|
+
// compactTitle drops parameters (limit/offset) and is not trusted here.
|
|
381
346
|
if (
|
|
382
347
|
token === "mcp" &&
|
|
383
348
|
args &&
|
|
@@ -385,33 +350,28 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
|
|
|
385
350
|
!Array.isArray(args)
|
|
386
351
|
) {
|
|
387
352
|
const record = args as Record<string, unknown>;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
const restJson = squashJsonish(rest);
|
|
407
|
-
return restJson ? `${label} ${restJson}` : label;
|
|
408
|
-
}
|
|
409
|
-
if (typeof record.server === "string") return `mcp list ${record.server}`;
|
|
353
|
+
const proxyLabel = mcpProxyCallLabel(record);
|
|
354
|
+
// Unambiguous shapes get a concise name; anything else keeps the complete
|
|
355
|
+
// compact args so the row never claims an operation that may not have run.
|
|
356
|
+
if (proxyLabel) return proxyLabel;
|
|
357
|
+
const json = squashJsonish(args);
|
|
358
|
+
return json ? `mcp ${json}` : "mcp status";
|
|
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}`;
|
|
410
370
|
}
|
|
411
371
|
const title = selfRenderedCallTitle(row);
|
|
412
372
|
const json = squashJsonish(args);
|
|
413
|
-
//
|
|
414
|
-
//
|
|
373
|
+
// Direct tools put only the bare name in their own title, so append the
|
|
374
|
+
// args ourselves.
|
|
415
375
|
if (title && title !== token) return title;
|
|
416
376
|
if (json) return `${token} ${json}`;
|
|
417
377
|
return title ?? token;
|
|
@@ -472,91 +432,6 @@ function isLiveRow(row: ToolExecutionRow): boolean {
|
|
|
472
432
|
);
|
|
473
433
|
}
|
|
474
434
|
|
|
475
|
-
function liveTailText(
|
|
476
|
-
row: ToolExecutionRow,
|
|
477
|
-
droppedVisible: boolean,
|
|
478
|
-
theme?: ThemeLike,
|
|
479
|
-
): string | undefined {
|
|
480
|
-
if (!theme) return undefined;
|
|
481
|
-
const elapsed = liveElapsedText(row);
|
|
482
|
-
if (!droppedVisible && !elapsed) return undefined;
|
|
483
|
-
const parts: string[] = [];
|
|
484
|
-
if (droppedVisible) parts.push("…");
|
|
485
|
-
if (elapsed) parts.push("·", elapsed);
|
|
486
|
-
return theme.fg("muted", parts.join(" "));
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function fitLiveHeader(
|
|
490
|
-
header: string,
|
|
491
|
-
tail: string,
|
|
492
|
-
width: number,
|
|
493
|
-
droppedVisible: boolean,
|
|
494
|
-
): string {
|
|
495
|
-
const max = Math.max(1, width);
|
|
496
|
-
const head = header.trimEnd();
|
|
497
|
-
if (visibleWidth(head) + visibleWidth(tail) + 1 <= max)
|
|
498
|
-
return `${head} ${tail}`;
|
|
499
|
-
const headWidth = Math.max(1, max - visibleWidth(tail) - 1);
|
|
500
|
-
const headSuffix = droppedVisible ? "" : "…";
|
|
501
|
-
return `${truncateToWidth(head, headWidth, headSuffix, false)} ${tail}`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// While a call streams or runs, pin it to a single header line with the
|
|
505
|
-
// elapsed time inline, so the block never grows and never collapses on
|
|
506
|
-
// completion; the header text simply settles into the outcome summary. The
|
|
507
|
-
// cap is composed before the Box applies background and padding, so the live
|
|
508
|
-
// line keeps the tool background edge to edge and the block's bottom padding.
|
|
509
|
-
function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
|
|
510
|
-
// Pin live self-rendered rows (e.g. MCP calls) to the same one-line summary
|
|
511
|
-
// they settle into, so the block never grows and hops on completion.
|
|
512
|
-
if (row.getRenderShell?.() === "self") {
|
|
513
|
-
const container = row.selfRenderContainer;
|
|
514
|
-
if (!theme || !container || !Array.isArray(container.children)) return;
|
|
515
|
-
const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
|
|
516
|
-
box.addChild(
|
|
517
|
-
selfRenderedSummaryComponent(row, theme, theme.fg("muted", "…")),
|
|
518
|
-
);
|
|
519
|
-
container.children = [box];
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
if (row.hasRendererDefinition?.()) {
|
|
523
|
-
const container = row.contentBox;
|
|
524
|
-
if (!container || !Array.isArray(container.children)) return;
|
|
525
|
-
const hadExtraChildren = container.children.length > 1;
|
|
526
|
-
removeResultComponent(container);
|
|
527
|
-
const original = container.children[0] as ComponentLike | undefined;
|
|
528
|
-
if (!original || typeof original.render !== "function") return;
|
|
529
|
-
container.children[0] = {
|
|
530
|
-
render(width: number): string[] {
|
|
531
|
-
const lines = original.render(width);
|
|
532
|
-
const headerIndex = lines.findIndex(hasVisibleContent);
|
|
533
|
-
if (headerIndex === -1) return lines;
|
|
534
|
-
const header = lines[headerIndex] ?? "";
|
|
535
|
-
const droppedVisible =
|
|
536
|
-
hadExtraChildren ||
|
|
537
|
-
lines.slice(headerIndex + 1).some(hasVisibleContent);
|
|
538
|
-
const tail = liveTailText(row, droppedVisible, theme);
|
|
539
|
-
return [
|
|
540
|
-
tail ? fitLiveHeader(header, tail, width, droppedVisible) : header,
|
|
541
|
-
];
|
|
542
|
-
},
|
|
543
|
-
invalidate() {
|
|
544
|
-
original.invalidate();
|
|
545
|
-
},
|
|
546
|
-
};
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const text = row.contentText;
|
|
551
|
-
if (typeof text?.text !== "string" || typeof text.setText !== "function")
|
|
552
|
-
return;
|
|
553
|
-
const lines = text.text.split("\n");
|
|
554
|
-
const title = lines[0] ?? "";
|
|
555
|
-
const droppedVisible = lines.slice(1).some((line) => line.trim().length > 0);
|
|
556
|
-
const tail = liveTailText(row, droppedVisible, theme);
|
|
557
|
-
text.setText(tail ? `${title} ${tail}` : title);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
435
|
function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
|
|
561
436
|
return (
|
|
562
437
|
row.expanded === false &&
|
|
@@ -594,6 +469,7 @@ function isGroupableToolRow(
|
|
|
594
469
|
renderAt: (index: number) => string[],
|
|
595
470
|
state: PresentationPatchState,
|
|
596
471
|
): boolean {
|
|
472
|
+
if (row.toolName === "subagent") return false;
|
|
597
473
|
if (!isLiveRow(row) && !isCollapsibleSuccess(row)) return false;
|
|
598
474
|
return (
|
|
599
475
|
state.collapseParallel ||
|
|
@@ -681,6 +557,10 @@ function diffCounts(diff: string): { added: number; removed: number } {
|
|
|
681
557
|
}
|
|
682
558
|
|
|
683
559
|
function outcomeSummary(row: ToolExecutionRow): string | undefined {
|
|
560
|
+
// Self-rendered tools own their result framing; the built-in heuristics
|
|
561
|
+
// (line counts, diff stats) describe default-shell rows only, so their
|
|
562
|
+
// settled rows always fall back to the generic outcome.
|
|
563
|
+
if (row.getRenderShell?.() === "self") return undefined;
|
|
684
564
|
if (!isCollapsibleSuccess(row)) return undefined;
|
|
685
565
|
|
|
686
566
|
const text = resultText(row);
|
|
@@ -724,7 +604,7 @@ function renderedOutcome(
|
|
|
724
604
|
): string | undefined {
|
|
725
605
|
const summary = outcomeSummary(row);
|
|
726
606
|
if (!summary) return undefined;
|
|
727
|
-
return
|
|
607
|
+
return theme.fg("muted", `→ ${summary}`);
|
|
728
608
|
}
|
|
729
609
|
|
|
730
610
|
function renderedGroupedOutcome(
|
|
@@ -735,7 +615,7 @@ function renderedGroupedOutcome(
|
|
|
735
615
|
if (outcome) return outcome;
|
|
736
616
|
if (isLiveRow(row)) {
|
|
737
617
|
const elapsed = liveElapsedText(row);
|
|
738
|
-
return theme.fg("
|
|
618
|
+
return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
|
|
739
619
|
}
|
|
740
620
|
// Self-rendered tools have no per-tool outcome summary; keep the group's
|
|
741
621
|
// settled tail consistent with collapsed singletons.
|
|
@@ -743,59 +623,46 @@ function renderedGroupedOutcome(
|
|
|
743
623
|
return undefined;
|
|
744
624
|
}
|
|
745
625
|
|
|
746
|
-
|
|
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 {
|
|
747
629
|
const max = Math.max(0, width);
|
|
748
|
-
|
|
749
|
-
if (visibleWidth(plain) <= max) return plain;
|
|
630
|
+
if (visibleWidth(text) <= max) return text;
|
|
750
631
|
|
|
751
632
|
const suffixWidth = visibleWidth(suffix);
|
|
752
|
-
if (suffixWidth >= max) return sliceByColumn(
|
|
753
|
-
return
|
|
633
|
+
if (suffixWidth >= max) return sliceByColumn(text, 0, max, true);
|
|
634
|
+
return truncateToWidth(text, max, suffix, false);
|
|
754
635
|
}
|
|
755
636
|
|
|
756
|
-
function fitSummary(summary: string, width: number): string {
|
|
637
|
+
function fitSummary(summary: string, width: number, suffix = "…"): string {
|
|
757
638
|
const max = Math.max(1, width);
|
|
758
|
-
return visibleWidth(summary) <= max
|
|
639
|
+
return visibleWidth(summary) <= max
|
|
640
|
+
? summary
|
|
641
|
+
: truncateStyled(summary, max, suffix);
|
|
759
642
|
}
|
|
760
643
|
|
|
761
|
-
|
|
644
|
+
// Fits "head tail" into width while always reserving at least one literal
|
|
645
|
+
// head column plus the joining space; the tail takes what is left and
|
|
646
|
+
// disappears entirely when no columns remain.
|
|
647
|
+
function fitSummaryTail(
|
|
648
|
+
head: string,
|
|
649
|
+
tail: string,
|
|
650
|
+
width: number,
|
|
651
|
+
suffix = "…",
|
|
652
|
+
): string {
|
|
762
653
|
const max = Math.max(1, width);
|
|
763
|
-
const
|
|
764
|
-
if (visibleWidth(summary) <= max) return summary;
|
|
765
|
-
|
|
654
|
+
const headWidth = visibleWidth(head);
|
|
766
655
|
const tailWidth = visibleWidth(tail);
|
|
767
|
-
if (tailWidth
|
|
768
|
-
return
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
function decorateSuccessfulCall(
|
|
772
|
-
row: ToolExecutionRow,
|
|
773
|
-
theme?: ThemeLike,
|
|
774
|
-
): void {
|
|
775
|
-
if (!theme || !Array.isArray(row.contentBox?.children)) return;
|
|
776
|
-
const original = row.contentBox.children[0] as ComponentLike | undefined;
|
|
777
|
-
const outcome = renderedOutcome(row, theme);
|
|
778
|
-
if (!original || typeof original.render !== "function" || !outcome) return;
|
|
656
|
+
if (headWidth + 1 + tailWidth <= max) return `${head.trimEnd()} ${tail}`;
|
|
657
|
+
if (tailWidth === 0) return truncateStyled(head, max, suffix);
|
|
658
|
+
if (headWidth === 0) return truncateStyled(tail, max, suffix);
|
|
779
659
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const rendered = visibleLines.slice(0, 3).map(trimRenderedLine);
|
|
788
|
-
if (visibleLines.length > rendered.length)
|
|
789
|
-
rendered.push(theme.fg("muted", "…"));
|
|
790
|
-
const summary = rendered.join(theme.fg("muted", " · "));
|
|
791
|
-
return [
|
|
792
|
-
fitSummaryTail(summary, outcome, Math.max(1, width - BADGE_WIDTH)),
|
|
793
|
-
];
|
|
794
|
-
},
|
|
795
|
-
invalidate() {
|
|
796
|
-
original.invalidate();
|
|
797
|
-
},
|
|
798
|
-
};
|
|
660
|
+
const labelBudget = Math.min(headWidth, Math.max(1, max - tailWidth - 1));
|
|
661
|
+
const tailBudget = Math.min(tailWidth, Math.max(0, max - labelBudget - 1));
|
|
662
|
+
if (tailBudget === 0) return truncateStyled(head, max, suffix);
|
|
663
|
+
const fittedTail =
|
|
664
|
+
tailWidth <= tailBudget ? tail : truncateStyled(tail, tailBudget, suffix);
|
|
665
|
+
return `${truncateStyled(head, labelBudget, suffix)} ${fittedTail}`;
|
|
799
666
|
}
|
|
800
667
|
|
|
801
668
|
function removeTrailingExpandHint(text: string): string {
|
|
@@ -810,12 +677,12 @@ function removeTrailingExpandHint(text: string): string {
|
|
|
810
677
|
}
|
|
811
678
|
|
|
812
679
|
function trimRenderedLine(text: string): string {
|
|
813
|
-
const plain = stripAnsi(text);
|
|
680
|
+
const plain = sanitizeInline(stripAnsi(text));
|
|
814
681
|
const leading = plain.match(/^\s*/)?.[0] ?? "";
|
|
815
682
|
const trimmed = plain.trim();
|
|
816
683
|
if (!trimmed) return "";
|
|
817
684
|
return sliceByColumn(
|
|
818
|
-
|
|
685
|
+
plain,
|
|
819
686
|
visibleWidth(leading),
|
|
820
687
|
visibleWidth(trimmed),
|
|
821
688
|
).trimEnd();
|
|
@@ -877,25 +744,347 @@ function renderedCallSummary(
|
|
|
877
744
|
|
|
878
745
|
const fallback = compactArgs(row.args);
|
|
879
746
|
return fallback
|
|
880
|
-
? theme.fg("
|
|
747
|
+
? theme.fg("muted", fallback)
|
|
881
748
|
: theme.fg("muted", "(no arguments)");
|
|
882
749
|
}
|
|
883
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
|
+
|
|
884
1072
|
function compactBulletLine(
|
|
885
1073
|
summary: string,
|
|
886
1074
|
outcome: string | undefined,
|
|
887
1075
|
width: number,
|
|
888
1076
|
theme: ThemeLike,
|
|
1077
|
+
color = "muted",
|
|
889
1078
|
): string {
|
|
890
|
-
const prefix = ` ${theme.fg(
|
|
1079
|
+
const prefix = ` ${theme.fg(color, "•")} `;
|
|
891
1080
|
const indent = visibleWidth(prefix);
|
|
892
1081
|
if (width <= indent) return truncateToWidth(prefix, width, "", false);
|
|
893
1082
|
const available = width - indent;
|
|
894
1083
|
return (
|
|
895
1084
|
prefix +
|
|
896
1085
|
(outcome
|
|
897
|
-
? fitSummaryTail(summary, outcome, available)
|
|
898
|
-
: fitSummary(summary, available))
|
|
1086
|
+
? fitSummaryTail(summary, outcome, available, theme.fg(color, "…"))
|
|
1087
|
+
: fitSummary(summary, available, theme.fg(color, "…")))
|
|
899
1088
|
);
|
|
900
1089
|
}
|
|
901
1090
|
|
|
@@ -912,16 +1101,25 @@ function groupedCallComponent(
|
|
|
912
1101
|
if (lines.length > 0) lines.push("");
|
|
913
1102
|
const token =
|
|
914
1103
|
row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
|
|
915
|
-
const heading = theme.fg(
|
|
916
|
-
"
|
|
917
|
-
|
|
918
|
-
)
|
|
1104
|
+
const heading = `${theme.fg(
|
|
1105
|
+
"muted",
|
|
1106
|
+
theme.bold(GROUP_MARKER),
|
|
1107
|
+
)} ${theme.fg("muted", theme.bold(token))}`;
|
|
919
1108
|
lines.push(truncateToWidth(heading, width, "", false));
|
|
920
1109
|
previousToolName = row.toolName;
|
|
921
1110
|
}
|
|
1111
|
+
const color = rowHasFailed(row) ? "error" : "muted";
|
|
922
1112
|
const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
|
|
923
1113
|
const outcome = renderedGroupedOutcome(row, theme);
|
|
924
|
-
lines.push(
|
|
1114
|
+
lines.push(
|
|
1115
|
+
compactBulletLine(
|
|
1116
|
+
theme.fg(color, stripAnsi(call)),
|
|
1117
|
+
outcome,
|
|
1118
|
+
width,
|
|
1119
|
+
theme,
|
|
1120
|
+
color,
|
|
1121
|
+
),
|
|
1122
|
+
);
|
|
925
1123
|
}
|
|
926
1124
|
return lines;
|
|
927
1125
|
},
|
|
@@ -929,21 +1127,6 @@ function groupedCallComponent(
|
|
|
929
1127
|
};
|
|
930
1128
|
}
|
|
931
1129
|
|
|
932
|
-
function renderWithTemporaryChild(
|
|
933
|
-
container: ComponentContainer,
|
|
934
|
-
child: ComponentLike,
|
|
935
|
-
render: () => string[],
|
|
936
|
-
): string[] {
|
|
937
|
-
const children = container.children;
|
|
938
|
-
if (!Array.isArray(children)) return render();
|
|
939
|
-
container.children = [child];
|
|
940
|
-
try {
|
|
941
|
-
return render();
|
|
942
|
-
} finally {
|
|
943
|
-
container.children = children;
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
1130
|
function sameMembers(
|
|
948
1131
|
left: ToolExecutionRow[],
|
|
949
1132
|
right: ToolExecutionRow[],
|
|
@@ -974,18 +1157,13 @@ function renderGroupedToolRows(
|
|
|
974
1157
|
state: PresentationPatchState,
|
|
975
1158
|
): string[] {
|
|
976
1159
|
const theme = state.theme;
|
|
977
|
-
if (!theme)
|
|
978
|
-
return decorateHeader(
|
|
979
|
-
row,
|
|
980
|
-
state.originalRender.call(row, width),
|
|
981
|
-
width,
|
|
982
|
-
theme,
|
|
983
|
-
);
|
|
1160
|
+
if (!theme) return state.originalRender.call(row, width);
|
|
984
1161
|
const themeSample =
|
|
985
1162
|
theme.fg("toolTitle", "x") +
|
|
986
1163
|
theme.fg("muted", "x") +
|
|
987
|
-
theme.
|
|
988
|
-
theme.
|
|
1164
|
+
theme.fg("dim", "x") +
|
|
1165
|
+
theme.fg("warning", "x") +
|
|
1166
|
+
theme.fg("error", "x");
|
|
989
1167
|
const cached = state.groupCache.get(row);
|
|
990
1168
|
const hasLiveMembers = rows.some(isLiveRow);
|
|
991
1169
|
if (
|
|
@@ -999,68 +1177,21 @@ function renderGroupedToolRows(
|
|
|
999
1177
|
return cached.lines;
|
|
1000
1178
|
}
|
|
1001
1179
|
|
|
1180
|
+
const layout = insetLayout(width);
|
|
1002
1181
|
const summary = groupedCallComponent(rows, theme);
|
|
1003
|
-
|
|
1004
|
-
if (!row.hasRendererDefinition?.()) {
|
|
1005
|
-
const text = row.contentText;
|
|
1006
|
-
const previous = text?.text;
|
|
1007
|
-
if (typeof previous !== "string" || typeof text?.setText !== "function") {
|
|
1008
|
-
return decorateHeader(
|
|
1009
|
-
row,
|
|
1010
|
-
state.originalRender.call(row, width),
|
|
1011
|
-
width,
|
|
1012
|
-
theme,
|
|
1013
|
-
);
|
|
1014
|
-
}
|
|
1015
|
-
text.setText(summary.render(Math.max(1, width - 2)).join("\n"));
|
|
1016
|
-
try {
|
|
1017
|
-
lines = state.originalRender.call(row, width);
|
|
1018
|
-
} finally {
|
|
1019
|
-
text.setText(previous);
|
|
1020
|
-
}
|
|
1021
|
-
} else if (row.getRenderShell?.() === "self") {
|
|
1022
|
-
const container = row.selfRenderContainer;
|
|
1023
|
-
if (!container)
|
|
1024
|
-
return decorateHeader(
|
|
1025
|
-
row,
|
|
1026
|
-
state.originalRender.call(row, width),
|
|
1027
|
-
width,
|
|
1028
|
-
theme,
|
|
1029
|
-
);
|
|
1030
|
-
const box = new Box(1, 1, (text) =>
|
|
1031
|
-
theme.bg(hasLiveMembers ? "toolPendingBg" : "toolSuccessBg", text),
|
|
1032
|
-
);
|
|
1033
|
-
box.addChild(summary);
|
|
1034
|
-
lines = renderWithTemporaryChild(container, box, () =>
|
|
1035
|
-
state.originalRender.call(row, width),
|
|
1036
|
-
);
|
|
1037
|
-
} else {
|
|
1038
|
-
const container = row.contentBox;
|
|
1039
|
-
if (!container)
|
|
1040
|
-
return decorateHeader(
|
|
1041
|
-
row,
|
|
1042
|
-
state.originalRender.call(row, width),
|
|
1043
|
-
width,
|
|
1044
|
-
theme,
|
|
1045
|
-
);
|
|
1046
|
-
lines = renderWithTemporaryChild(container, summary, () =>
|
|
1047
|
-
state.originalRender.call(row, width),
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
const decorated = decorateHeader(row, lines, width, theme);
|
|
1182
|
+
const lines = ["", ...insetLines(summary.render(layout.contentWidth), width)];
|
|
1052
1183
|
if (hasLiveMembers) {
|
|
1053
1184
|
state.groupCache.delete(row);
|
|
1054
1185
|
} else {
|
|
1055
1186
|
state.groupCache.set(row, {
|
|
1056
|
-
lines
|
|
1187
|
+
lines,
|
|
1057
1188
|
members: [...rows],
|
|
1058
1189
|
memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
|
|
1059
1190
|
themeSample,
|
|
1060
1191
|
width,
|
|
1061
1192
|
});
|
|
1062
1193
|
}
|
|
1063
|
-
return
|
|
1194
|
+
return lines;
|
|
1064
1195
|
}
|
|
1065
1196
|
|
|
1066
1197
|
function renderContainerWithToolGroups(
|
|
@@ -1124,9 +1255,8 @@ function renderContainerWithToolGroups(
|
|
|
1124
1255
|
}
|
|
1125
1256
|
|
|
1126
1257
|
for (const member of group) presentation.rowGroups.set(member, group);
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
// the grouped row keeps the same number of lines.
|
|
1258
|
+
// Use a live member while any call is pending, then the first member once
|
|
1259
|
+
// settled. Either way, the grouped row keeps the same number of lines.
|
|
1130
1260
|
const shellRow = group.find(isLiveRow) ?? child;
|
|
1131
1261
|
lines.push(...renderGroupedToolRows(shellRow, group, width, presentation));
|
|
1132
1262
|
index = lastMemberIndex;
|
|
@@ -1149,6 +1279,7 @@ function installGroupingPatch(
|
|
|
1149
1279
|
const existing = proto[GROUPING_PATCHED];
|
|
1150
1280
|
if (existing) {
|
|
1151
1281
|
existing.presentation = presentation;
|
|
1282
|
+
existing.disabled = false;
|
|
1152
1283
|
return existing;
|
|
1153
1284
|
}
|
|
1154
1285
|
|
|
@@ -1161,10 +1292,19 @@ function installGroupingPatch(
|
|
|
1161
1292
|
width: number,
|
|
1162
1293
|
): string[] {
|
|
1163
1294
|
const children = this.children;
|
|
1164
|
-
if (
|
|
1295
|
+
if (
|
|
1296
|
+
state.disabled ||
|
|
1297
|
+
!Array.isArray(children) ||
|
|
1298
|
+
!children.some(isToolExecutionRow)
|
|
1299
|
+
) {
|
|
1165
1300
|
return state.originalRender.call(this, width);
|
|
1166
1301
|
}
|
|
1302
|
+
let restoreHooks: () => void = () => {};
|
|
1167
1303
|
try {
|
|
1304
|
+
// Other container-level concerns (pi-content-layout's system-text
|
|
1305
|
+
// inset) decorate children through the shared hooks before grouping
|
|
1306
|
+
// rewrites the rows; delegation alone cannot reach them from here.
|
|
1307
|
+
restoreHooks = runChatContainerHooks(this, children, width);
|
|
1168
1308
|
return renderContainerWithToolGroups(
|
|
1169
1309
|
children,
|
|
1170
1310
|
width,
|
|
@@ -1172,6 +1312,8 @@ function installGroupingPatch(
|
|
|
1172
1312
|
);
|
|
1173
1313
|
} catch {
|
|
1174
1314
|
return state.originalRender.call(this, width);
|
|
1315
|
+
} finally {
|
|
1316
|
+
restoreHooks();
|
|
1175
1317
|
}
|
|
1176
1318
|
};
|
|
1177
1319
|
|
|
@@ -1190,6 +1332,10 @@ function installGroupingPatch(
|
|
|
1190
1332
|
|
|
1191
1333
|
function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
|
|
1192
1334
|
if (!state) return;
|
|
1335
|
+
// Even when another extension's wrapper sits on top of ours, grouping must
|
|
1336
|
+
// not outlive its owner: the wrapper delegates once disabled, and a later
|
|
1337
|
+
// reinstall re-enables it.
|
|
1338
|
+
state.disabled = true;
|
|
1193
1339
|
const proto = Container?.prototype as unknown as ComponentContainer & {
|
|
1194
1340
|
[GROUPING_PATCHED]?: GroupingPatchState;
|
|
1195
1341
|
render?: (width: number) => string[];
|
|
@@ -1249,20 +1395,23 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1249
1395
|
state.groupCache.delete(this);
|
|
1250
1396
|
}
|
|
1251
1397
|
state.originalUpdateDisplay.call(this);
|
|
1252
|
-
try {
|
|
1253
|
-
if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
|
|
1254
|
-
else collapseSuccessfulResult(this, state.theme);
|
|
1255
|
-
} catch {
|
|
1256
|
-
// Presentation is cosmetic; preserve Pi's renderer if its internals change.
|
|
1257
|
-
}
|
|
1258
1398
|
};
|
|
1259
1399
|
const patchedRender = function renderWithToolPresentation(
|
|
1260
1400
|
this: ToolExecutionRow,
|
|
1261
1401
|
width: number,
|
|
1262
1402
|
): string[] {
|
|
1263
1403
|
const lines = state.originalRender.call(this, width);
|
|
1404
|
+
if (
|
|
1405
|
+
typeof this.expanded !== "boolean" ||
|
|
1406
|
+
typeof this.isPartial !== "boolean" ||
|
|
1407
|
+
this.expanded ||
|
|
1408
|
+
!state.theme ||
|
|
1409
|
+
(lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
|
|
1410
|
+
) {
|
|
1411
|
+
return lines;
|
|
1412
|
+
}
|
|
1264
1413
|
try {
|
|
1265
|
-
return
|
|
1414
|
+
return renderCollapsedToolRow(this, width, state.theme);
|
|
1266
1415
|
} catch {
|
|
1267
1416
|
return lines;
|
|
1268
1417
|
}
|