@hheei/pi-ext-ui 0.1.0 → 0.2.1

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/dist/renderer.js CHANGED
@@ -1,230 +1,50 @@
1
+ import { getLanguageFromPath, highlightCode, } from "@earendil-works/pi-coding-agent";
1
2
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
- import { applyPatchGlyph } from "./apply-patch-glyph.js";
3
- import { previewV4aOperations } from "./apply-patch-preview.js";
4
- import { applyPatchFacts, formatApplyPatchOperationRow, } from "./apply-patch-view.js";
5
- import { DiffView, diffStats } from "./diff-view.js";
6
- import { ToolView, } from "./tool-view.js";
7
- /** Tools with a pi-ext-ui-owned renderer. Other tools stay on Pi's renderer. */
8
- export const SPECIALIZED_TOOL_RENDERERS = [
9
- "edit",
10
- "write",
11
- "apply_patch",
12
- "python_eval",
13
- "codemode",
14
- ];
3
+ import { agentResultText, getRegisteredToolPresentationProvider, getToolPresentationProvider, hasOptionalToolRenderers, } from "@hheei/pi-ext-core";
4
+ import { DiffView } from "./diff-view.js";
5
+ import { NestedCallsView } from "./nested-calls-view.js";
6
+ import { formatToolHeaderText, ToolView, } from "./tool-view.js";
7
+ import { getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
8
+ /**
9
+ * Upstream tools whose call and result views pi-ext-ui still frames itself. Concrete tool packages
10
+ * own their own presentation through ext-core's optional renderer point, so their names never
11
+ * appear here.
12
+ */
13
+ export const SPECIALIZED_TOOL_RENDERERS = ["codemode"];
15
14
  /** Web-access tools use the shared folding shell while retaining their native body renderer. */
16
15
  export const WEB_ACCESS_TOOLS = ["web_search", "web_fetch", "web_open"];
17
- const APPLY_PATCH_TOOL_NAME = "apply_patch";
18
- const APPLY_PATCH_PREVIEW_STATE_KEY = "__piExtUiApplyPatchPreview";
19
- const PYTHON_EVAL_TOOL_NAME = "python_eval";
20
- const PYTHON_EVAL_TOOL_TITLE = "python_eval";
21
- const PYTHON_EVAL_DETAILS_FORMAT = "pi-ext-tools-python-eval";
22
- const EDIT_TOOL_NAME = "edit";
23
- const WRITE_TOOL_NAME = "write";
24
- const EDIT_VIEW_KEY = "__piExtToolsEditView";
25
- const WRITE_VIEW_KEY = "__piExtToolsWriteView";
16
+ import { callStatsParts, estimateStreamingTokens, extractHeavyInputCommand, extractNestedCalls, extractResultText, extractSummaryArg, formatCostUsd, formatDurationMs, inlinePresentation, isRecord, resolveContainerStatus, toolPath, } from "./payload-parser.js";
17
+ export { estimateStreamingTokens, inlinePresentation, } from "./payload-parser.js";
26
18
  const SPECIALIZED_TOOL_SET = new Set(SPECIALIZED_TOOL_RENDERERS);
27
19
  const WEB_ACCESS_TOOL_SET = new Set(WEB_ACCESS_TOOLS);
28
20
  const CALL_VIEW_STATE_KEY = "__piExtUiCallView";
29
- const CODEMODE_OPTIONS_PREFIX = "// @options:";
30
- const CODEMODE_SCRIPT_HEADER = /^Script (?:completed|failed)\r?\nWall time ([\d.]+) seconds\r?\nOutput:\r?\n/;
31
- function isRecord(value) {
32
- return typeof value === "object" && value !== null && !Array.isArray(value);
33
- }
34
- export function estimateStreamingTokens(args) {
35
- if (args === undefined || args === null)
36
- return 0;
37
- try {
38
- const serialized = typeof args === "string" ? args : JSON.stringify(args);
39
- if (!serialized || serialized === "{}")
40
- return 0;
41
- return Math.max(1, Math.ceil(serialized.length / 4));
42
- }
43
- catch {
44
- return 0;
45
- }
46
- }
47
- function formatDurationMs(ms) {
48
- if (ms === undefined || !Number.isFinite(ms))
49
- return "";
50
- return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
51
- }
52
- function formatCostUsd(cost) {
53
- if (cost === undefined || !Number.isFinite(cost) || cost <= 0)
54
- return "";
55
- return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
56
- }
57
- export function extractCodemodeHeaderFacts(args) {
58
- if (!isRecord(args))
59
- return "";
60
- const options = {};
61
- for (const [key, value] of Object.entries(args)) {
62
- if (key !== "code" && value !== undefined && value !== null) {
63
- options[key] = value;
64
- }
65
- }
66
- if (typeof args.code === "string") {
67
- const firstLine = args.code.split(/\r?\n/, 1)[0]?.trim() ?? "";
68
- if (firstLine.startsWith(CODEMODE_OPTIONS_PREFIX)) {
69
- try {
70
- const parsed = JSON.parse(firstLine.slice(CODEMODE_OPTIONS_PREFIX.length).trim());
71
- if (isRecord(parsed)) {
72
- Object.assign(options, parsed);
73
- }
74
- }
75
- catch { }
76
- }
77
- }
78
- const facts = [];
79
- const timeoutMs = typeof options.timeout_ms === "number"
80
- ? options.timeout_ms
81
- : typeof options.timeout === "number"
82
- ? options.timeout * 1000
83
- : undefined;
84
- if (timeoutMs !== undefined) {
85
- const label = timeoutMs >= 1000 ? `${String(Math.round(timeoutMs / 1000))}s` : `${String(timeoutMs)}ms`;
86
- facts.push(`(timeout ${label})`);
87
- }
88
- if (typeof options.max_output_tokens === "number") {
89
- facts.push(`(tokens ${String(options.max_output_tokens)})`);
90
- }
91
- if (typeof options.target === "string" && options.target !== "local") {
92
- facts.push(`(${options.target})`);
93
- }
94
- return facts.join(" ");
95
- }
96
- function extractSummaryArg(toolName, args) {
97
- if (toolName === "codemode") {
98
- return extractCodemodeHeaderFacts(args);
99
- }
100
- if (!isRecord(args))
101
- return "";
102
- for (const key of [
103
- "query",
104
- "queries",
105
- "url",
106
- "urls",
107
- "source",
108
- "claim",
109
- "id",
110
- "search_id",
111
- "topic",
112
- "path",
113
- "pattern",
114
- ]) {
115
- const val = args[key];
116
- if (typeof val === "string" && val.trim() !== "") {
117
- return stripTerminalSequences(val.trim());
118
- }
119
- if (Array.isArray(val) && val.length > 0 && typeof val[0] === "string") {
120
- const first = stripTerminalSequences(val[0].trim());
121
- return val.length > 1 ? `${first} (+${String(val.length - 1)})` : first;
122
- }
123
- }
124
- return "";
125
- }
126
- function extractHeavyInputCommand(toolName, args) {
127
- if (!isRecord(args))
128
- return undefined;
129
- if (toolName === "bash" && typeof args.command === "string") {
130
- return args.command.trim();
131
- }
132
- if (toolName === "python_eval" && typeof args.code === "string") {
133
- return args.code.trim();
134
- }
135
- if (toolName === "codemode" && typeof args.code === "string") {
136
- return args.code.trim();
137
- }
21
+ function detectCodeLanguage(toolName) {
22
+ if (toolName === "bash")
23
+ return "bash";
24
+ if (toolName === "codemode")
25
+ return "javascript";
138
26
  return undefined;
139
27
  }
140
- function extractNestedCalls(result) {
141
- if (result === undefined || !isRecord(result.details))
142
- return [];
143
- const rawList = Array.isArray(result.details.calls)
144
- ? result.details.calls
145
- : Array.isArray(result.details.nestedCalls)
146
- ? result.details.nestedCalls
147
- : undefined;
148
- if (rawList === undefined)
149
- return [];
150
- const items = [];
151
- for (const entry of rawList) {
152
- if (!isRecord(entry))
153
- continue;
154
- const name = typeof entry.name === "string"
155
- ? entry.name
156
- : typeof entry.toolName === "string"
157
- ? entry.toolName
158
- : undefined;
159
- if (name === undefined)
160
- continue;
161
- const rawStatus = entry.status;
162
- const status = rawStatus === "running" ||
163
- rawStatus === "ok" ||
164
- rawStatus === "error" ||
165
- rawStatus === "cancelled"
166
- ? rawStatus
167
- : undefined;
168
- const summary = typeof entry.args === "string" && entry.args.trim() !== ""
169
- ? entry.args
170
- : typeof entry.summary === "string"
171
- ? entry.summary
172
- : undefined;
173
- items.push({
174
- toolName: name,
175
- summary,
176
- status,
177
- isError: entry.isError === true || status === "error",
178
- isWarning: entry.isWarning === true || status === "cancelled",
179
- durationMs: typeof entry.durationMs === "number" ? entry.durationMs : undefined,
180
- cost: typeof entry.cost === "number" ? entry.cost : undefined,
181
- error: typeof entry.error === "string" ? entry.error : undefined,
182
- });
183
- }
184
- return items;
185
- }
186
- function extractResultText(toolName, result) {
187
- const parts = [];
188
- for (const block of result.content) {
189
- if (block.type === "text" && block.text.trim() !== "") {
190
- parts.push(stripTerminalSequences(block.text));
191
- }
192
- }
193
- const joined = parts.join("\n");
194
- if (toolName === "codemode") {
195
- const match = CODEMODE_SCRIPT_HEADER.exec(joined);
196
- const wallTimeSeconds = match?.[1] !== undefined ? Number.parseFloat(match[1]) : undefined;
197
- const cleaned = joined.replace(CODEMODE_SCRIPT_HEADER, "").trim();
198
- return { outputText: cleaned, wallTimeSeconds };
199
- }
200
- return { outputText: joined, wallTimeSeconds: undefined };
201
- }
202
- function callStatsParts(total, failCount) {
203
- if (failCount > 0) {
204
- return [`${String(total - failCount)} ok`, `${String(failCount)} failed`];
205
- }
206
- return [`${String(total)} ${total === 1 ? "call" : "calls"}`];
207
- }
208
- function resolveContainerStatus(params) {
209
- if (params.selfFailed)
210
- return "error";
211
- if (params.hasChildren)
212
- return "none";
213
- if (params.isPartial)
214
- return "running";
215
- return params.isError ? "error" : "ok";
216
- }
217
- function createInputCodeSection(toolName, args, theme) {
28
+ function createInputCodeSection(toolName, args, theme, railToken = "muted") {
218
29
  const raw = extractHeavyInputCommand(toolName, args);
219
30
  if (raw === undefined || raw === "")
220
31
  return undefined;
221
32
  const isShell = toolName === "bash";
222
- const lines = raw
223
- .replace(/\r/g, "")
224
- .replace(/\t/g, " ")
225
- .split("\n")
226
- .map((line, i) => theme.fg("mdCode", isShell ? (i === 0 ? `$ ${line}` : ` ${line}`) : line));
227
- return { railToken: "muted", content: lines };
33
+ const clean = raw.replace(/\r/g, "").replace(/\t/g, " ");
34
+ const lang = detectCodeLanguage(toolName);
35
+ let lines;
36
+ if (isShell) {
37
+ lines = clean.split("\n").map((line, i) => (i === 0 ? `$ ${line}` : ` ${line}`));
38
+ }
39
+ else {
40
+ try {
41
+ lines = highlightCode(clean, lang);
42
+ }
43
+ catch {
44
+ lines = clean.split("\n").map((line) => theme.fg("mdCode", line));
45
+ }
46
+ }
47
+ return { railToken, content: lines };
228
48
  }
229
49
  function formatArgsPreview(args) {
230
50
  if (args === undefined || args === null)
@@ -238,343 +58,32 @@ function formatArgsPreview(args) {
238
58
  return "";
239
59
  }
240
60
  }
241
- function nestedStatusGlyph(status, theme) {
242
- switch (status) {
243
- case "streaming":
244
- return theme.fg("muted", "⋯");
245
- case "running":
246
- return theme.fg("accent", "◐");
247
- case "ok":
248
- return theme.fg("success", "✓");
249
- case "warn":
250
- return theme.fg("warning", "!");
251
- case "error":
252
- return theme.fg("error", "✗");
253
- case "none":
254
- return "";
255
- }
256
- }
257
61
  /**
258
62
  * The host stacks a tool execution's call and result views. Following the
259
63
  * official edit renderer, the call view is stored in the shared renderer
260
64
  * state so the result renderer can hide it in place within the same
261
65
  * updateDisplay pass — no draw-time flags needed.
66
+ *
67
+ * The call row is left visible until a result view exists: hiding it here would
68
+ * blank the whole execution for as long as the tool produces no result yet.
262
69
  */
263
- function rememberCallView(context, view, toolName) {
70
+ function rememberCallView(context, view) {
264
71
  if (isRecord(context.state)) {
265
72
  context.state[CALL_VIEW_STATE_KEY] = view;
266
73
  }
267
- // A container tool's own row yields as soon as execution starts: nested rows and output
268
- // carry the progress, so a spinner row on top would only duplicate them.
269
- if ((toolName === "codemode" ||
270
- toolName === PYTHON_EVAL_TOOL_NAME ||
271
- toolName === APPLY_PATCH_TOOL_NAME) &&
272
- context.executionStarted) {
273
- view.hide();
274
- }
275
74
  }
276
- function hideCallView(context, toolName, isPartial) {
277
- if (toolName !== "codemode" &&
278
- toolName !== PYTHON_EVAL_TOOL_NAME &&
279
- toolName !== APPLY_PATCH_TOOL_NAME &&
280
- isPartial)
75
+ /**
76
+ * A result view takes over from the call view. A container tool yields on any result, because its
77
+ * own row would duplicate the nested rows and output it now carries; every other tool keeps its
78
+ * call row until the final result. A provider states that for its own tool, so no name is involved.
79
+ */
80
+ function hideCallView(context, replaceCallOnPartial, isPartial) {
81
+ if (!replaceCallOnPartial && isPartial)
281
82
  return;
282
83
  const view = isRecord(context.state) ? context.state[CALL_VIEW_STATE_KEY] : undefined;
283
84
  if (view instanceof ToolView)
284
85
  view.hide();
285
86
  }
286
- function toolPath(args) {
287
- let value = args;
288
- if (typeof value === "string") {
289
- try {
290
- value = JSON.parse(value);
291
- }
292
- catch {
293
- return "";
294
- }
295
- }
296
- if (!isRecord(value))
297
- return "";
298
- return typeof value.path === "string" ? value.path.trim() : "";
299
- }
300
- function editPath(args) {
301
- return toolPath(args);
302
- }
303
- function numericField(value, key) {
304
- return isRecord(value) && typeof value[key] === "number" ? value[key] : undefined;
305
- }
306
- function writeStoredDiff(lines) {
307
- const hunks = [];
308
- let hunk = [];
309
- const finish = () => {
310
- if (hunk.length > 0)
311
- hunks.push(hunk);
312
- hunk = [];
313
- };
314
- for (const entry of lines) {
315
- if (!isRecord(entry))
316
- return undefined;
317
- if (entry.type === "sep") {
318
- finish();
319
- continue;
320
- }
321
- if (entry.type !== "add" && entry.type !== "del" && entry.type !== "ctx")
322
- return undefined;
323
- if (typeof entry.content !== "string")
324
- return undefined;
325
- const line = entry.type === "del" ? entry.oldNum : entry.newNum;
326
- if (typeof line !== "number")
327
- return undefined;
328
- hunk.push({ kind: entry.type, line, text: entry.content });
329
- }
330
- finish();
331
- return hunks.length === 0
332
- ? undefined
333
- : { kind: "hunks", hunks: hunks.map((lines) => ({ lines })) };
334
- }
335
- /** Uses only the baseline and content already stored by the write tool. */
336
- function writePresentation(args, details) {
337
- const view = isRecord(details) ? details[WRITE_VIEW_KEY] : undefined;
338
- if (!isRecord(view))
339
- return undefined;
340
- if (view.kind === "noChange")
341
- return { kind: "noChange" };
342
- if (view.kind === "diff") {
343
- const stored = Array.isArray(view.lines) ? writeStoredDiff(view.lines) : undefined;
344
- if (stored === undefined)
345
- return undefined;
346
- const source = stored;
347
- const stats = diffStats(source);
348
- return stats === undefined
349
- ? undefined
350
- : {
351
- kind: "diff",
352
- source,
353
- added: numericField(view, "added") ?? stats.added,
354
- removed: numericField(view, "removed") ?? stats.removed,
355
- };
356
- }
357
- if (view.kind !== "new" && view.kind !== "replace")
358
- return undefined;
359
- const content = typeof view.content === "string"
360
- ? view.content
361
- : isRecord(args) && typeof args.content === "string"
362
- ? args.content
363
- : undefined;
364
- if (content === undefined)
365
- return undefined;
366
- const lines = numericField(view, "lines") ?? content.split("\n").length;
367
- return {
368
- kind: "content",
369
- label: view.kind === "new" ? `new file (${String(lines)} lines)` : `wrote (${String(lines)} lines)`,
370
- content,
371
- };
372
- }
373
- function fileToolPresentation(toolName, args, details, isError) {
374
- const path = toolPath(args);
375
- if (toolName === "read") {
376
- if (path === "")
377
- return undefined;
378
- return { summary: `read ${path}`, status: isError ? "error" : "ok" };
379
- }
380
- if (toolName === WRITE_TOOL_NAME) {
381
- const presentation = isError ? undefined : writePresentation(args, details);
382
- const delta = presentation?.kind === "diff"
383
- ? ` (+${String(presentation.added)} -${String(presentation.removed)})`
384
- : presentation?.kind === "content"
385
- ? ` (${presentation.label})`
386
- : presentation?.kind === "noChange"
387
- ? " (no changes)"
388
- : "";
389
- if (path === "" && delta === "")
390
- return undefined;
391
- return {
392
- summary: `${WRITE_TOOL_NAME} ${path}${delta}`.trim(),
393
- status: isError ? "error" : presentation?.kind === "noChange" ? "none" : "ok",
394
- };
395
- }
396
- return undefined;
397
- }
398
- /** Pi native edit persists a unified patch plus the same change as a numbered display diff. */
399
- function editDiffSource(details) {
400
- if (!isRecord(details))
401
- return undefined;
402
- if (typeof details.patch === "string" && details.patch.trim() !== "") {
403
- return { kind: "unifiedPatch", text: details.patch };
404
- }
405
- if (typeof details.diff === "string" && details.diff.trim() !== "") {
406
- return displayDiffSource(details.diff);
407
- }
408
- return editViewSource(details[EDIT_VIEW_KEY]);
409
- }
410
- function displayDiffSource(diff) {
411
- const hunks = [];
412
- let current = [];
413
- let pendingBreak = false;
414
- for (const raw of diff.split(/\r?\n/)) {
415
- if (raw === "")
416
- continue;
417
- const sign = raw.charAt(0);
418
- const match = /^ *(\d+)? ?(.*)$/.exec(raw.slice(1));
419
- const lineNumber = match?.[1];
420
- if (lineNumber === undefined) {
421
- pendingBreak = true;
422
- continue;
423
- }
424
- if (pendingBreak && current.length > 0) {
425
- hunks.push(current);
426
- current = [];
427
- }
428
- pendingBreak = false;
429
- const kind = sign === "+" ? "add" : sign === "-" ? "del" : "ctx";
430
- current.push({ kind, line: Number(lineNumber), text: match?.[2] ?? "" });
431
- }
432
- if (current.length === 0)
433
- return undefined;
434
- hunks.push(current);
435
- return { kind: "hunks", hunks: hunks.map((lines) => ({ lines })) };
436
- }
437
- function editViewSource(view) {
438
- const hunks = editViewHunks(view);
439
- return hunks.length === 0 ? undefined : { kind: "hunks", hunks };
440
- }
441
- function editViewHunks(view) {
442
- if (!isRecord(view))
443
- return [];
444
- const ops = view.kind === "single" && isRecord(view.op)
445
- ? [view.op]
446
- : view.kind === "multi" && Array.isArray(view.ops)
447
- ? view.ops.filter(isRecord)
448
- : [];
449
- return ops.flatMap((op) => hunksForEditOp(op));
450
- }
451
- function hunksForEditOp(op) {
452
- if (typeof op.oldContent !== "string" || typeof op.newContent !== "string")
453
- return [];
454
- const startLine = typeof op.startLine === "number" && op.startLine > 0 ? op.startLine : 1;
455
- return lineHunks(op.oldContent.replace(/\r\n/g, "\n"), op.newContent.replace(/\r\n/g, "\n"), startLine);
456
- }
457
- function splitFragment(text) {
458
- if (text === "")
459
- return [];
460
- const lines = text.split("\n");
461
- if (lines.at(-1) === "")
462
- lines.pop();
463
- return lines;
464
- }
465
- /** Line LCS of one stored before/after fragment. No file context is invented. */
466
- function lineHunks(before, after, startLine) {
467
- const oldLines = splitFragment(before);
468
- const newLines = splitFragment(after);
469
- const oldCount = oldLines.length;
470
- const newCount = newLines.length;
471
- const lengths = Array.from({ length: oldCount + 1 }, () => Array(newCount + 1).fill(0));
472
- for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex -= 1) {
473
- const row = lengths[oldIndex];
474
- const next = lengths[oldIndex + 1];
475
- if (row === undefined || next === undefined)
476
- continue;
477
- for (let newIndex = newCount - 1; newIndex >= 0; newIndex -= 1) {
478
- row[newIndex] =
479
- oldLines[oldIndex] === newLines[newIndex]
480
- ? (next[newIndex + 1] ?? 0) + 1
481
- : Math.max(next[newIndex] ?? 0, row[newIndex + 1] ?? 0);
482
- }
483
- }
484
- const lines = [];
485
- let oldIndex = 0;
486
- let newIndex = 0;
487
- let oldLine = startLine;
488
- let newLine = startLine;
489
- while (oldIndex < oldCount || newIndex < newCount) {
490
- const row = lengths[oldIndex];
491
- const next = lengths[oldIndex + 1];
492
- if (oldIndex < oldCount && newIndex < newCount && oldLines[oldIndex] === newLines[newIndex]) {
493
- lines.push({ kind: "ctx", line: newLine, text: oldLines[oldIndex] ?? "" });
494
- oldIndex += 1;
495
- newIndex += 1;
496
- oldLine += 1;
497
- newLine += 1;
498
- }
499
- else if (newIndex < newCount &&
500
- (oldIndex === oldCount || (row?.[newIndex + 1] ?? 0) > (next?.[newIndex] ?? 0))) {
501
- lines.push({ kind: "add", line: newLine, text: newLines[newIndex] ?? "" });
502
- newIndex += 1;
503
- newLine += 1;
504
- }
505
- else {
506
- lines.push({ kind: "del", line: oldLine, text: oldLines[oldIndex] ?? "" });
507
- oldIndex += 1;
508
- oldLine += 1;
509
- }
510
- }
511
- const changed = lines.some((line) => line.kind !== "ctx");
512
- return changed ? [{ lines }] : [];
513
- }
514
- function patchTextForCopy(details) {
515
- if (isRecord(details) && typeof details.patch === "string")
516
- return details.patch;
517
- const hunks = editViewHunks(isRecord(details) ? details[EDIT_VIEW_KEY] : undefined);
518
- if (hunks.length === 0)
519
- return undefined;
520
- return (hunks
521
- .map((hunk) => {
522
- let oldStart = 0;
523
- let newStart = 0;
524
- let oldCount = 0;
525
- let newCount = 0;
526
- const body = hunk.lines.map((line) => {
527
- if (line.kind !== "add") {
528
- oldCount += 1;
529
- if (oldStart === 0)
530
- oldStart = line.line;
531
- }
532
- if (line.kind !== "del") {
533
- newCount += 1;
534
- if (newStart === 0)
535
- newStart = line.line;
536
- }
537
- const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
538
- return sign + line.text;
539
- });
540
- if (body.length === 0)
541
- return "";
542
- const header = "@@ -" +
543
- String(oldStart || 1) +
544
- "," +
545
- String(oldCount) +
546
- " +" +
547
- String(newStart || 1) +
548
- "," +
549
- String(newCount) +
550
- " @@";
551
- return [header, ...body].join("\n");
552
- })
553
- .filter((hunk) => hunk !== "")
554
- .join("\n") || undefined);
555
- }
556
- /**
557
- * One compact nested summary for a file tool. Reports the delta only when the
558
- * caller actually has result details; a codemode nested record keeps arguments
559
- * alone, so it degrades to `<tool> <path>` instead of inventing counts.
560
- */
561
- export function inlinePresentation(toolName, args, details, isError) {
562
- const fileTool = fileToolPresentation(toolName, args, details, isError);
563
- if (fileTool !== undefined)
564
- return fileTool;
565
- if (toolName !== EDIT_TOOL_NAME)
566
- return undefined;
567
- const path = editPath(args);
568
- const source = isError ? undefined : editDiffSource(details);
569
- const stats = source === undefined ? undefined : diffStats(source);
570
- if (path === "" && stats === undefined)
571
- return undefined;
572
- const label = path === "" ? EDIT_TOOL_NAME : `${EDIT_TOOL_NAME} ${path}`;
573
- return {
574
- summary: stats === undefined ? label : `${label} (+${String(stats.added)} -${String(stats.removed)})`,
575
- status: isError ? "error" : "ok",
576
- };
577
- }
578
87
  function formatNestedCallRow(call, theme) {
579
88
  const inline = inlinePresentation(call.toolName, call.summary, undefined, call.isError === true);
580
89
  const status = call.status === "running"
@@ -582,399 +91,18 @@ function formatNestedCallRow(call, theme) {
582
91
  : call.isWarning === true
583
92
  ? "warn"
584
93
  : (inline?.status ?? (call.isError === true ? "error" : "ok"));
585
- const glyph = nestedStatusGlyph(status, theme);
586
- const name = theme.fg("toolTitle", inline?.summary ?? call.toolName);
587
94
  const cleanArgs = inline !== undefined || call.summary === undefined
588
95
  ? ""
589
96
  : stripTerminalSequences(call.summary.trim());
590
97
  const truncatedArgs = cleanArgs.length > 72 ? `${cleanArgs.slice(0, 69)}…` : cleanArgs;
591
- const summaryPart = truncatedArgs !== "" ? ` ${theme.fg("muted", truncatedArgs)}` : "";
592
- const durText = formatDurationMs(call.durationMs);
593
- const durationPart = durText !== "" ? ` ${theme.fg("dim", durText)}` : "";
594
98
  const costText = formatCostUsd(call.cost);
595
- const costPart = costText !== "" ? ` ${theme.fg("dim", costText)}` : "";
596
- return `${glyph} ${name}${summaryPart}${durationPart}${costPart}`;
597
- }
598
- function renderEditResult(params) {
599
- const { theme, context, result, renderOptions, defaultLevel, options } = params;
600
- const path = editPath(context.args);
601
- const isError = context.isError || result.isError;
602
- const source = isError ? undefined : editDiffSource(result.details);
603
- const stats = source === undefined ? undefined : diffStats(source);
604
- const summaryParts = [
605
- stats === undefined ? path : `${path} (+${String(stats.added)} -${String(stats.removed)})`,
606
- ];
607
- const durText = formatDurationMs(context.durationMs);
608
- if (durText !== "")
609
- summaryParts.push(durText);
610
- const summary = summaryParts.join(" · ");
611
- const output = stripTerminalSequences(extractResultText(EDIT_TOOL_NAME, result).outputText);
612
- const patch = patchTextForCopy(result.details);
613
- // A successful edit hides the top-level check mark; the diff body carries the change.
614
- const status = isError
615
- ? "error"
616
- : renderOptions.isPartial
617
- ? "running"
618
- : source !== undefined
619
- ? "none"
620
- : "ok";
621
- const sections = source === undefined
622
- ? output === ""
623
- ? []
624
- : [{ railToken: isError ? "error" : "dim", content: output }]
625
- : [{ railToken: "dim", content: new DiffView({ theme, source }) }];
626
- const copy = patch !== undefined ? { primary: patch } : output !== "" ? { primary: output } : undefined;
627
- return new ToolView({
628
- theme,
629
- header: { title: EDIT_TOOL_NAME, status, summary },
630
- sections,
631
- copy,
632
- outputPad: context.outputPad,
633
- stateHolder: context.state,
634
- defaultLevel,
635
- globalExpanded: renderOptions.expanded,
636
- invalidate: () => context.invalidate(),
637
- copyToClipboard: options?.copyToClipboard,
638
- });
639
- }
640
- function renderWriteResult(params) {
641
- const { theme, context, result, renderOptions, defaultLevel, options } = params;
642
- const path = toolPath(context.args);
643
- const isError = context.isError || result.isError;
644
- const presentation = isError ? undefined : writePresentation(context.args, result.details);
645
- const output = stripTerminalSequences(extractResultText(WRITE_TOOL_NAME, result).outputText);
646
- const baseSummary = presentation?.kind === "diff"
647
- ? `${path} (+${String(presentation.added)} -${String(presentation.removed)})`
648
- : presentation?.kind === "content"
649
- ? `${path} (${presentation.label})`
650
- : presentation?.kind === "noChange"
651
- ? `${path} (no changes)`
652
- : path;
653
- const durText = formatDurationMs(context.durationMs);
654
- const summaryParts = [baseSummary];
655
- if (durText !== "")
656
- summaryParts.push(durText);
657
- const summary = summaryParts.join(" · ");
658
- const sections = presentation?.kind === "diff"
659
- ? [{ railToken: "dim", content: new DiffView({ theme, source: presentation.source }) }]
660
- : presentation?.kind === "content"
661
- ? [{ railToken: "dim", content: presentation.content }]
662
- : output === ""
663
- ? []
664
- : [{ railToken: isError ? "error" : "dim", content: output }];
665
- const copyText = presentation?.kind === "content"
666
- ? presentation.content
667
- : presentation?.kind === "diff"
668
- ? undefined
669
- : output;
670
- return new ToolView({
671
- theme,
672
- header: {
673
- title: WRITE_TOOL_NAME,
674
- status: isError
675
- ? "error"
676
- : renderOptions.isPartial
677
- ? "running"
678
- : presentation?.kind === "diff"
679
- ? "none"
680
- : presentation?.kind === "noChange"
681
- ? "none"
682
- : "ok",
683
- summary,
684
- },
685
- sections,
686
- copy: copyText !== undefined && copyText !== "" ? { primary: copyText } : undefined,
687
- outputPad: context.outputPad,
688
- stateHolder: context.state,
689
- defaultLevel,
690
- globalExpanded: renderOptions.expanded,
691
- invalidate: () => context.invalidate(),
692
- copyToClipboard: options?.copyToClipboard,
693
- });
694
- }
695
- /** Reads only the persisted `pi-ext-tools-eval` details; other shapes degrade to plain output. */
696
- function pythonEvalFacts(details) {
697
- if (!isRecord(details) || details.format !== PYTHON_EVAL_DETAILS_FORMAT)
698
- return undefined;
699
- const rawRows = Array.isArray(details.rows) ? details.rows : [];
700
- const rows = [];
701
- for (const entry of rawRows) {
702
- if (!isRecord(entry))
703
- continue;
704
- if (entry.kind === "text" && typeof entry.text === "string") {
705
- rows.push({ kind: "text", text: entry.text });
706
- }
707
- else if (entry.kind === "display" && typeof entry.text === "string") {
708
- rows.push({ kind: "display", text: entry.text });
709
- }
710
- else if (entry.kind === "result" && typeof entry.text === "string") {
711
- rows.push({ kind: "result", text: entry.text });
712
- }
713
- else if (entry.kind === "tool" && isRecord(entry.trace)) {
714
- const trace = entry.trace;
715
- if (typeof trace.name !== "string")
716
- continue;
717
- rows.push({
718
- kind: "tool",
719
- name: trace.name,
720
- args: trace.args,
721
- durationMs: typeof trace.durationMs === "number" ? trace.durationMs : undefined,
722
- error: typeof trace.error === "string" ? trace.error : undefined,
723
- });
724
- }
725
- }
726
- return {
727
- rows,
728
- error: typeof details.error === "string" ? details.error : undefined,
729
- };
730
- }
731
- /** A nested file-tool row keeps its tool name; other tools degrade to name plus bounded args. */
732
- function formatEvalToolRow(row, theme) {
733
- const isError = row.error !== undefined;
734
- const inline = inlinePresentation(row.name, row.args, undefined, isError);
735
- const glyph = nestedStatusGlyph(isError ? "error" : "ok", theme);
736
- if (inline !== undefined) {
737
- const durText = formatDurationMs(row.durationMs);
738
- const durationPart = durText !== "" ? ` ${theme.fg("dim", durText)}` : "";
739
- return `${glyph} ${theme.fg("toolTitle", inline.summary)}${durationPart}`;
740
- }
741
- const summary = typeof row.args === "string" && row.args.trim() !== ""
742
- ? stripTerminalSequences(row.args.trim())
743
- : "";
744
- const truncated = summary.length > 72 ? `${summary.slice(0, 69)}…` : summary;
745
- const summaryPart = truncated !== "" ? ` ${theme.fg("muted", truncated)}` : "";
746
- return `${glyph} ${theme.fg("toolTitle", row.name)}${summaryPart}`;
747
- }
748
- function renderPythonEvalResult(params) {
749
- const { theme, context, result, renderOptions, defaultLevel, options } = params;
750
- const facts = pythonEvalFacts(result.details);
751
- const isError = (context.isError ?? false) || (result.isError ?? false);
752
- const toolRows = facts?.rows.filter((row) => row.kind === "tool") ??
753
- [];
754
- const anyToolError = toolRows.some((row) => row.error !== undefined);
755
- const failed = isError || facts?.error !== undefined || anyToolError;
756
- const status = resolveContainerStatus({
757
- isError,
758
- isPartial: renderOptions.isPartial === true,
759
- hasChildren: toolRows.length > 0,
760
- selfFailed: failed && !anyToolError,
761
- });
762
- const parts = [];
763
- if (isRecord(context.args)) {
764
- if (context.args.reset === true)
765
- parts.push("(reset)");
766
- if (typeof context.args.timeout === "number")
767
- parts.push(`(timeout ${String(context.args.timeout)}s)`);
768
- }
769
- if (facts !== undefined) {
770
- if (toolRows.length > 0) {
771
- const failCount = toolRows.filter((row) => row.error !== undefined).length;
772
- parts.push(...callStatsParts(toolRows.length, failCount));
773
- }
774
- else if (facts.rows.length > 0) {
775
- parts.push(`${String(facts.rows.length)} ${facts.rows.length === 1 ? "row" : "rows"}`);
776
- }
777
- }
778
- const durText = formatDurationMs(context.durationMs);
779
- if (durText !== "")
780
- parts.push(durText);
781
- const summary = parts.join(" · ");
782
- const collapsedRows = toolRows.length > 0 ? toolRows.map((row) => formatEvalToolRow(row, theme)) : undefined;
783
- const code = extractHeavyInputCommand(PYTHON_EVAL_TOOL_NAME, context.args);
784
- const inputSection = createInputCodeSection(PYTHON_EVAL_TOOL_NAME, context.args, theme);
785
- const sections = inputSection !== undefined ? [inputSection] : [];
786
- if (facts !== undefined) {
787
- const outputRows = [];
788
- for (const row of facts.rows) {
789
- if (row.kind === "tool") {
790
- outputRows.push(formatEvalToolRow(row, theme));
791
- }
792
- else if (row.kind === "display" || row.kind === "result") {
793
- const prefix = theme.fg("dim", `${row.kind}:`);
794
- const lines = row.text.replace(/\r/g, "").split("\n");
795
- for (let i = 0; i < lines.length; i++) {
796
- outputRows.push(i === 0 ? `${prefix} ${lines[i]}` : ` ${lines[i]}`);
797
- }
798
- }
799
- else if (row.text !== "") {
800
- outputRows.push(...row.text.replace(/\r/g, "").split("\n"));
801
- }
802
- }
803
- if (outputRows.length > 0) {
804
- sections.push({
805
- railToken: isError ? "error" : "dim",
806
- content: outputRows,
807
- });
808
- }
809
- }
810
- else {
811
- const output = stripTerminalSequences(extractResultText(PYTHON_EVAL_TOOL_NAME, result).outputText);
812
- if (output !== "") {
813
- sections.push({ railToken: isError ? "error" : "dim", content: output });
814
- }
815
- }
816
- const output = stripTerminalSequences(extractResultText(PYTHON_EVAL_TOOL_NAME, result).outputText);
817
- const copy = code !== undefined && code !== ""
818
- ? { primary: code, full: output !== "" ? `${code}\n---\n${output}` : code }
819
- : output !== ""
820
- ? { primary: output }
821
- : undefined;
822
- return new ToolView({
823
- theme,
824
- header: {
825
- title: PYTHON_EVAL_TOOL_TITLE,
826
- status,
827
- summary,
828
- },
829
- collapsedRows,
830
- sections,
831
- copy,
832
- outputPad: context.outputPad,
833
- stateHolder: context.state,
834
- defaultLevel,
835
- globalExpanded: renderOptions.expanded,
836
- invalidate: () => context.invalidate(),
837
- copyToClipboard: options?.copyToClipboard,
838
- });
839
- }
840
- /**
841
- * Streaming call view: operation headers recognized from the patch prefix
842
- * preview as pending rows. `󰄰` only marks a recognized header — it promises
843
- * neither validation nor application.
844
- */
845
- function renderApplyPatchCallView(params) {
846
- const { theme, context, defaultLevel, options } = params;
847
- const patch = isRecord(context.args) && typeof context.args.patch === "string" ? context.args.patch : "";
848
- const host = isRecord(context.args) &&
849
- typeof context.args.target === "string" &&
850
- context.args.target !== "local"
851
- ? context.args.target
852
- : undefined;
853
- // The preview cursor lives in the shared render state so each streamed update
854
- // only processes new lines; an absent state degrades to a one-shot preview.
855
- const state = isRecord(context.state) ? context.state : undefined;
856
- const cursor = state?.[APPLY_PATCH_PREVIEW_STATE_KEY];
857
- const stored = typeof cursor === "object" && cursor !== null ? cursor : undefined;
858
- const operations = previewV4aOperations(patch, context.argsComplete === true, stored);
859
- if (state !== undefined && stored !== undefined) {
860
- state[APPLY_PATCH_PREVIEW_STATE_KEY] = stored;
861
- }
862
- const files = new Set(operations.map((operation) => operation.path)).size;
863
- const summary = files > 0 ? `${String(files)} ${files === 1 ? "file" : "files"}` : "";
864
- const rows = operations.map((operation) => formatApplyPatchOperationRow({
865
- operationIndex: 0,
866
- kind: operation.kind,
867
- path: operation.path,
868
- addedLines: operation.addedLines,
869
- removedLines: operation.removedLines,
870
- status: "pending",
871
- appliedHunks: undefined,
872
- totalHunks: undefined,
873
- partialReason: undefined,
874
- }, theme, host));
875
- return new ToolView({
876
- theme,
877
- header: {
878
- title: APPLY_PATCH_TOOL_NAME,
879
- status: context.isError ? "error" : "none",
880
- summary,
881
- },
882
- collapsedRows: rows,
883
- sections: [{ railToken: "dim", content: rows }],
884
- copy: patch !== "" ? { primary: patch } : undefined,
885
- outputPad: context.outputPad,
886
- stateHolder: context.state,
887
- defaultLevel,
888
- globalExpanded: context.expanded,
889
- invalidate: () => context.invalidate(),
890
- copyToClipboard: options?.copyToClipboard,
891
- });
892
- }
893
- function renderApplyPatchResultView(params) {
894
- const { theme, context, result, renderOptions, defaultLevel, options } = params;
895
- const facts = applyPatchFacts(result.details);
896
- const isError = context.isError || result.isError;
897
- const host = facts?.target;
898
- // The aggregated status is the executor's fact; success hides the top glyph.
899
- const status = facts === undefined
900
- ? isError
901
- ? "error"
902
- : renderOptions.isPartial
903
- ? "running"
904
- : "ok"
905
- : facts.status === "failed"
906
- ? "error"
907
- : facts.status === "partial"
908
- ? "warn"
909
- : "none";
910
- const output = stripTerminalSequences(extractResultText(APPLY_PATCH_TOOL_NAME, result).outputText);
911
- const parts = [];
912
- if (facts !== undefined) {
913
- const files = facts.operations.length;
914
- if (files > 0)
915
- parts.push(`${String(files)} ${files === 1 ? "file" : "files"}`);
916
- if (facts.addedLines > 0 || facts.removedLines > 0)
917
- parts.push(`+${String(facts.addedLines)} -${String(facts.removedLines)}`);
918
- }
919
- const durText = formatDurationMs(context.durationMs);
920
- if (durText !== "")
921
- parts.push(durText);
922
- const summary = parts.join(" · ");
923
- const rows = facts === undefined
924
- ? []
925
- : facts.operations.map((operation) => formatApplyPatchOperationRow(operation, theme, host));
926
- const sections = rows.length > 0 ? [{ railToken: "dim", content: rows }] : [];
927
- if (facts === undefined && output !== "") {
928
- sections.push({ railToken: isError ? "error" : "dim", content: output });
929
- }
930
- else if (facts !== undefined) {
931
- for (const snapshot of facts.snapshots) {
932
- const hunks = snapshotHunks(snapshot);
933
- if (hunks !== undefined && hunks.length > 0) {
934
- sections.push({
935
- railToken: "dim",
936
- content: new DiffView({ theme, source: { kind: "hunks", hunks } }),
937
- });
938
- }
939
- }
940
- const diagnosis = (entries, glyphStatus) => entries.flatMap((entry) => [
941
- `${applyPatchGlyph(glyphStatus, theme)} ${entry.paths[0] ?? "<unknown>"} · ${entry.error}`,
942
- ...entry.diagnostics.map((diagnostic) => `${applyPatchGlyph(glyphStatus, theme)} ${entry.paths[0] ?? "<unknown>"} · ${diagnostic.text}`),
943
- ]);
944
- const failureRows = [
945
- ...diagnosis(facts.rejected, "rejected"),
946
- ...diagnosis(facts.unconfirmed, "unconfirmed"),
947
- ...diagnosis(facts.notApplied, "not_applied"),
948
- ];
949
- if (failureRows.length > 0) {
950
- sections.push({ railToken: "error", content: failureRows });
951
- }
952
- }
953
- const patch = isRecord(context.args) && typeof context.args.patch === "string" ? context.args.patch : "";
954
- const copy = patch !== ""
955
- ? { primary: patch, full: output !== "" ? `${patch}\n---\n${output}` : patch }
956
- : output !== ""
957
- ? { primary: output }
958
- : undefined;
959
- return new ToolView({
960
- theme,
961
- header: { title: APPLY_PATCH_TOOL_NAME, status, summary },
962
- collapsedRows: rows.length > 0 ? rows : undefined,
963
- sections,
964
- copy,
965
- outputPad: context.outputPad,
966
- stateHolder: context.state,
967
- defaultLevel,
968
- globalExpanded: renderOptions.expanded,
969
- invalidate: () => context.invalidate(),
970
- copyToClipboard: options?.copyToClipboard,
971
- });
972
- }
973
- /** Hunks of one stored before/after snapshot; no file context is invented. */
974
- function snapshotHunks(snapshot) {
975
- if (snapshot.before.length === 0 && snapshot.after.length === 0)
976
- return undefined;
977
- return lineHunks(snapshot.before.join("\n"), snapshot.after.join("\n"), snapshot.startLine);
99
+ return formatToolHeaderText({
100
+ status,
101
+ title: call.toolName,
102
+ summary: [inline?.summary ?? truncatedArgs, formatDurationMs(call.durationMs), costText]
103
+ .filter(Boolean)
104
+ .join(" · "),
105
+ }, theme);
978
106
  }
979
107
  function shouldInterceptTool(toolName, options) {
980
108
  if (options?.catchAll === true) {
@@ -989,35 +117,32 @@ function shouldInterceptTool(toolName, options) {
989
117
  }
990
118
  return SPECIALIZED_TOOL_SET.has(toolName) || WEB_ACCESS_TOOL_SET.has(toolName);
991
119
  }
992
- function renderSpecializedResult(params) {
993
- const { toolName } = params;
994
- if (toolName === EDIT_TOOL_NAME)
995
- return renderEditResult(params);
996
- if (toolName === WRITE_TOOL_NAME)
997
- return renderWriteResult(params);
998
- if (toolName === PYTHON_EVAL_TOOL_NAME)
999
- return renderPythonEvalResult(params);
1000
- if (toolName === APPLY_PATCH_TOOL_NAME)
1001
- return renderApplyPatchResultView(params);
1002
- return undefined;
1003
- }
1004
120
  function renderGenericResult(params) {
1005
- const { toolName, theme, context, result, renderOptions, defaultLevel, originalRenderCall, originalRenderResult, options, } = params;
121
+ const { toolName, theme, context, result, renderOptions, defaultLevel, originalRenderCall, originalRenderResult, resolvePresentation, options, } = params;
1006
122
  const heavyCmd = extractHeavyInputCommand(toolName, context.args);
1007
123
  const nestedCalls = extractNestedCalls(result);
1008
- const isMetaTool = toolName === "codemode" || toolName === "subagent" || nestedCalls.length > 0;
124
+ const captured = toolName === "codemode"
125
+ ? (getCapturedNestedCalls(context.toolCallId) ??
126
+ (isRecord(result.details) &&
127
+ Array.isArray(result.details.__piExtUiCapturedCalls)
128
+ ? result.details
129
+ .__piExtUiCapturedCalls
130
+ : undefined))
131
+ : undefined;
132
+ const childCalls = captured ?? nestedCalls;
133
+ const isMetaTool = toolName === "codemode" || toolName === "subagent" || childCalls.length > 0;
1009
134
  const { outputText: rawOutput, wallTimeSeconds } = extractResultText(toolName, result);
1010
135
  const isError = (context.isError ?? false) || (result.isError ?? false);
1011
136
  const status = toolName === "codemode"
1012
- ? nestedCalls.length === 0 && isError
137
+ ? childCalls.length === 0 && isError
1013
138
  ? "error"
1014
139
  : "none"
1015
140
  : isMetaTool
1016
141
  ? resolveContainerStatus({
1017
142
  isError,
1018
143
  isPartial: renderOptions.isPartial === true,
1019
- hasChildren: nestedCalls.length > 0,
1020
- selfFailed: nestedCalls.length === 0 && isError,
144
+ hasChildren: childCalls.length > 0,
145
+ selfFailed: childCalls.length === 0 && isError,
1021
146
  })
1022
147
  : isError
1023
148
  ? "error"
@@ -1038,9 +163,9 @@ function renderGenericResult(params) {
1038
163
  if (stripped !== "")
1039
164
  summaryParts.push(stripped);
1040
165
  }
1041
- if (isMetaTool && nestedCalls.length > 0) {
1042
- const failCount = nestedCalls.filter((c) => c.isError || c.isWarning).length;
1043
- summaryParts.push(...callStatsParts(nestedCalls.length, failCount));
166
+ if (isMetaTool && childCalls.length > 0) {
167
+ const failCount = childCalls.filter((c) => Boolean(c.isError || ("status" in c && c.status === "error"))).length;
168
+ summaryParts.push(...callStatsParts(childCalls.length, failCount));
1044
169
  }
1045
170
  if (context.durationMs !== undefined) {
1046
171
  summaryParts.push(formatDurationMs(context.durationMs));
@@ -1048,28 +173,75 @@ function renderGenericResult(params) {
1048
173
  else if (wallTimeSeconds !== undefined && Number.isFinite(wallTimeSeconds)) {
1049
174
  summaryParts.push(formatDurationMs(wallTimeSeconds * 1000));
1050
175
  }
1051
- const totalCost = nestedCalls.reduce((sum, c) => sum + (c.cost ?? 0), 0);
176
+ const totalCost = childCalls.reduce((sum, c) => sum + (c.cost ?? 0), 0);
1052
177
  if (totalCost > 0) {
1053
178
  summaryParts.push(formatCostUsd(totalCost));
1054
179
  }
1055
180
  const summary = summaryParts.join(" · ");
1056
- const collapsedRows = isMetaTool && nestedCalls.length > 0
1057
- ? nestedCalls.map((call) => formatNestedCallRow(call, theme))
181
+ const codemodeNestedView = toolName === "codemode" && childCalls.length > 0
182
+ ? new NestedCallsView({
183
+ calls: childCalls,
184
+ theme,
185
+ context,
186
+ resolveChildRenderer: (childName) => {
187
+ return {
188
+ renderResult: (childResult, childOptions, childTheme, childCtx) => {
189
+ const provider = resolvePresentation?.(childName);
190
+ if (provider !== undefined) {
191
+ return renderOwnedPresentation({
192
+ toolName: childName,
193
+ presentation: provider(childCtx.args, childResult, childTheme, childCtx),
194
+ result: childResult,
195
+ theme: childTheme,
196
+ context: childCtx,
197
+ defaultLevel: childOptions.expanded ? 2 : 1,
198
+ options,
199
+ });
200
+ }
201
+ return renderGenericResult({
202
+ toolName: childName,
203
+ theme: childTheme,
204
+ context: childCtx,
205
+ result: childResult,
206
+ renderOptions: childOptions,
207
+ defaultLevel: childOptions.expanded ? 2 : 1,
208
+ originalRenderCall: undefined,
209
+ originalRenderResult: undefined,
210
+ resolvePresentation,
211
+ options,
212
+ });
213
+ },
214
+ };
215
+ },
216
+ copyToClipboard: options?.copyToClipboard,
217
+ stateHolder: context.state,
218
+ resolvePresentation,
219
+ invalidate: () => context.invalidate(),
220
+ })
1058
221
  : undefined;
1059
- const inputSection = createInputCodeSection(toolName, context.args, theme);
222
+ const collapsedRows = codemodeNestedView !== undefined
223
+ ? codemodeNestedView
224
+ : isMetaTool && nestedCalls.length > 0
225
+ ? nestedCalls.map((call) => formatNestedCallRow(call, theme))
226
+ : undefined;
227
+ const toolRailToken = isError
228
+ ? "error"
229
+ : renderOptions.isPartial
230
+ ? "accent"
231
+ : "success";
232
+ // Upper room (input code) uses neutral/muted rail; lower room (output) uses toolRailToken (success/error)
233
+ const inputSection = createInputCodeSection(toolName, context.args, theme, "muted");
1060
234
  const sections = inputSection !== undefined ? [inputSection] : [];
1061
- const outputRailToken = isError ? "error" : renderOptions.isPartial ? "accent" : "dim";
1062
235
  if (toolName === "codemode") {
236
+ // Nested tool calls belong only to collapsedRows (Level 0), hidden when expanded
1063
237
  if (rawOutput !== "") {
1064
- const outputColor = isError ? "error" : "toolOutput";
1065
- const coloredLines = rawOutput
1066
- .replace(/\r/g, "")
1067
- .replace(/\t/g, " ")
1068
- .split("\n")
1069
- .map((line) => theme.fg(outputColor, line));
238
+ const rawLines = rawOutput.replace(/\r/g, "").replace(/\t/g, " ").split("\n");
239
+ const outputLines = isError
240
+ ? rawLines.map((line) => theme.fg("error", line))
241
+ : rawLines.map((line) => theme.fg("dim", line));
1070
242
  sections.push({
1071
- railToken: outputRailToken,
1072
- content: coloredLines,
243
+ railToken: toolRailToken,
244
+ content: outputLines,
1073
245
  });
1074
246
  }
1075
247
  }
@@ -1093,31 +265,60 @@ function renderGenericResult(params) {
1093
265
  invalidate() { },
1094
266
  };
1095
267
  sections.push({
1096
- railToken: outputRailToken,
268
+ railToken: toolRailToken,
1097
269
  content: lazyUpstreamComponent,
1098
270
  maxLines: effectiveExpanded ? Number.POSITIVE_INFINITY : 9,
1099
271
  });
1100
272
  }
1101
273
  else if (rawOutput !== "") {
1102
- const outputColor = isError ? "error" : "toolOutput";
1103
- const coloredLines = rawOutput.split("\n").map((line) => theme.fg(outputColor, line));
274
+ const rawLines = rawOutput.split("\n");
275
+ let outputLines;
276
+ if (isError) {
277
+ outputLines = rawLines.map((line) => theme.fg("error", line));
278
+ }
279
+ else if (toolName === "read" || toolPath(context.args) !== "") {
280
+ const filePath = toolPath(context.args);
281
+ const lang = filePath !== "" ? getLanguageFromPath(filePath) : undefined;
282
+ if (lang !== undefined) {
283
+ try {
284
+ outputLines = highlightCode(rawOutput, lang);
285
+ }
286
+ catch {
287
+ outputLines = rawLines;
288
+ }
289
+ }
290
+ else {
291
+ outputLines = rawLines;
292
+ }
293
+ }
294
+ else if (toolName === "bash") {
295
+ outputLines = rawLines.map((line) => theme.fg("dim", line));
296
+ }
297
+ else {
298
+ outputLines = rawLines;
299
+ }
1104
300
  sections.push({
1105
- railToken: outputRailToken,
1106
- content: coloredLines,
301
+ railToken: toolRailToken,
302
+ content: outputLines,
1107
303
  });
1108
304
  }
1109
305
  let copy;
1110
- if (heavyCmd !== undefined && heavyCmd !== "") {
306
+ if (toolName === "codemode") {
307
+ copy = undefined;
308
+ }
309
+ else if (heavyCmd !== undefined && heavyCmd !== "") {
310
+ const cleanOutput = stripTerminalSequences(rawOutput);
1111
311
  copy = {
1112
312
  primary: heavyCmd,
1113
- full: rawOutput !== "" ? `${heavyCmd}\n---\n${rawOutput}` : heavyCmd,
313
+ full: rawOutput !== "" ? `${heavyCmd}\n---\n${cleanOutput}` : heavyCmd,
1114
314
  };
1115
315
  }
1116
316
  else if (rawOutput !== "") {
317
+ const cleanOutput = stripTerminalSequences(rawOutput);
1117
318
  const argsFormatted = formatArgsPreview(context.args);
1118
319
  copy = {
1119
- primary: rawOutput,
1120
- full: argsFormatted !== "" ? `${argsFormatted}\n\n${rawOutput}` : rawOutput,
320
+ primary: cleanOutput,
321
+ full: argsFormatted !== "" ? `${argsFormatted}\n\n${cleanOutput}` : cleanOutput,
1121
322
  };
1122
323
  }
1123
324
  return new ToolView({
@@ -1127,6 +328,7 @@ function renderGenericResult(params) {
1127
328
  status,
1128
329
  summary,
1129
330
  },
331
+ railToken: toolRailToken,
1130
332
  collapsedRows,
1131
333
  sections,
1132
334
  copy,
@@ -1138,11 +340,104 @@ function renderGenericResult(params) {
1138
340
  copyToClipboard: options?.copyToClipboard,
1139
341
  });
1140
342
  }
343
+ /**
344
+ * The generic frame around one owner's presentation. The caller already resolved it, so a frame
345
+ * pass never asks the tool to rebuild its rows.
346
+ */
347
+ function renderOwnedPresentation(params) {
348
+ const { toolName, presentation, result, theme, context, defaultLevel, options } = params;
349
+ const isError = context.isError || result?.isError === true;
350
+ const pending = result === undefined || context.isPartial;
351
+ const isDiff = !isError && !pending && presentation.kind === "diff";
352
+ // The tool owns its status: a container says "none" for a child's failure and a partial patch
353
+ // says "warn" even though the host marks the result failed. Only a provider without an opinion
354
+ // falls back to the frame's own reading of the execution.
355
+ const status = presentation.status ??
356
+ (isError
357
+ ? "error"
358
+ : pending
359
+ ? context.argsComplete
360
+ ? "running"
361
+ : "streaming"
362
+ : isDiff
363
+ ? "none"
364
+ : "ok");
365
+ const railToken = isError ? "error" : pending ? "accent" : "success";
366
+ const output = isError && result !== undefined
367
+ ? agentResultText(result)
368
+ : (presentation.output ?? (result === undefined ? "" : agentResultText(result)));
369
+ const sections = [...(presentation.sections ?? [])];
370
+ if (presentation.sections === undefined && presentation.request) {
371
+ sections.push({ railToken: "muted", content: stripTerminalSequences(presentation.request) });
372
+ }
373
+ if (presentation.sections !== undefined) {
374
+ // Tool owns domain rows; this factory owns the frame and interaction only.
375
+ }
376
+ else if (isDiff) {
377
+ if (presentation.diff.trim() !== "") {
378
+ sections.push({
379
+ railToken: "dim",
380
+ content: new DiffView({
381
+ theme,
382
+ source: { kind: "unifiedPatch", text: presentation.diff },
383
+ }),
384
+ });
385
+ }
386
+ }
387
+ else if (output !== "") {
388
+ const lines = stripTerminalSequences(output).split("\n");
389
+ sections.push({
390
+ railToken,
391
+ content: isError ? lines.map((line) => theme.fg("error", line)) : lines,
392
+ });
393
+ }
394
+ const duration = result === undefined ? "" : formatDurationMs(context.durationMs);
395
+ const summary = [stripTerminalSequences(presentation.summary), duration]
396
+ .filter(Boolean)
397
+ .join(" · ");
398
+ const copy = presentation.copy === undefined
399
+ ? isDiff && presentation.diff.trim() !== ""
400
+ ? { primary: presentation.diff }
401
+ : undefined
402
+ : {
403
+ primary: stripTerminalSequences(presentation.copy.primary),
404
+ ...(presentation.copy.full === undefined
405
+ ? {}
406
+ : { full: stripTerminalSequences(presentation.copy.full) }),
407
+ };
408
+ return new ToolView({
409
+ theme,
410
+ header: { title: toolName, status, summary },
411
+ railToken,
412
+ // The tool owns the folded rows and their tones; the frame only sanitizes them for the host.
413
+ collapsedRows: presentation.collapsedRows ??
414
+ (isError && output !== ""
415
+ ? output
416
+ .split("\n")
417
+ .slice(0, 1)
418
+ .map((line) => theme.fg("error", stripTerminalSequences(line)))
419
+ : undefined),
420
+ sections,
421
+ copy,
422
+ outputPad: context.outputPad,
423
+ stateHolder: context.state,
424
+ defaultLevel,
425
+ globalExpanded: context.expanded,
426
+ invalidate: () => context.invalidate(),
427
+ copyToClipboard: options?.copyToClipboard,
428
+ });
429
+ }
1141
430
  export function createCollapsibleToolRendererResolver(options) {
431
+ return createRendererResolver(options);
432
+ }
433
+ function createRendererResolver(options, resolvePresentation) {
1142
434
  const defaultLevel = options?.defaultCollapsed === false ? 1 : 0;
1143
435
  return (toolName, next) => {
1144
436
  const upstream = next();
1145
- if (!shouldInterceptTool(toolName, options)) {
437
+ if (hasOptionalToolRenderers(upstream))
438
+ return upstream;
439
+ const provider = getToolPresentationProvider(upstream) ?? resolvePresentation?.(toolName);
440
+ if (provider === undefined && !shouldInterceptTool(toolName, options)) {
1146
441
  return upstream;
1147
442
  }
1148
443
  const originalRenderCall = upstream?.renderCall;
@@ -1150,33 +445,17 @@ export function createCollapsibleToolRendererResolver(options) {
1150
445
  return {
1151
446
  renderShell: "self",
1152
447
  renderCall(args, theme, context) {
1153
- if (toolName === APPLY_PATCH_TOOL_NAME) {
1154
- const view = renderApplyPatchCallView({
448
+ if (provider !== undefined) {
449
+ const callContext = { ...context, args };
450
+ const view = renderOwnedPresentation({
451
+ toolName,
452
+ presentation: provider(args, undefined, theme, callContext),
1155
453
  theme,
1156
- context: { ...context, args },
454
+ context: callContext,
1157
455
  defaultLevel,
1158
456
  options,
1159
457
  });
1160
- rememberCallView(context, view, toolName);
1161
- return view;
1162
- }
1163
- if (toolName === EDIT_TOOL_NAME) {
1164
- const path = editPath(args);
1165
- const view = new ToolView({
1166
- theme,
1167
- header: {
1168
- title: EDIT_TOOL_NAME,
1169
- status: context.isError ? "error" : "running",
1170
- summary: path,
1171
- },
1172
- outputPad: context.outputPad,
1173
- stateHolder: context.state,
1174
- defaultLevel,
1175
- globalExpanded: context.expanded,
1176
- invalidate: () => context.invalidate(),
1177
- copyToClipboard: options?.copyToClipboard,
1178
- });
1179
- rememberCallView(context, view, toolName);
458
+ rememberCallView(context, view);
1180
459
  return view;
1181
460
  }
1182
461
  const isStreamingArgs = context.isPartial && !context.argsComplete;
@@ -1238,22 +517,34 @@ export function createCollapsibleToolRendererResolver(options) {
1238
517
  invalidate: () => context.invalidate(),
1239
518
  copyToClipboard: options?.copyToClipboard,
1240
519
  });
1241
- rememberCallView(context, view, toolName);
520
+ rememberCallView(context, view);
1242
521
  return view;
1243
522
  },
1244
523
  renderResult(result, renderOptions, theme, context) {
1245
- hideCallView(context, toolName, renderOptions.isPartial);
1246
- const specialized = renderSpecializedResult({
1247
- toolName,
1248
- theme,
1249
- context,
1250
- result,
1251
- renderOptions,
1252
- defaultLevel,
1253
- options,
1254
- });
1255
- if (specialized !== undefined)
1256
- return specialized;
524
+ const resultContext = {
525
+ ...context,
526
+ expanded: renderOptions.expanded,
527
+ isPartial: renderOptions.isPartial,
528
+ };
529
+ const presentation = provider === undefined
530
+ ? undefined
531
+ : provider(resultContext.args, result, theme, resultContext);
532
+ // Without an owner, a container frame is the only name-based decision left, and it
533
+ // belongs to upstream codemode.
534
+ const replacesCallOnPartial = presentation?.replaceCallOnPartial === true ||
535
+ (presentation === undefined && SPECIALIZED_TOOL_SET.has(toolName));
536
+ hideCallView(context, replacesCallOnPartial, renderOptions.isPartial);
537
+ if (presentation !== undefined) {
538
+ return renderOwnedPresentation({
539
+ toolName,
540
+ presentation,
541
+ result,
542
+ theme,
543
+ context: resultContext,
544
+ defaultLevel,
545
+ options,
546
+ });
547
+ }
1257
548
  return renderGenericResult({
1258
549
  toolName,
1259
550
  theme,
@@ -1263,12 +554,38 @@ export function createCollapsibleToolRendererResolver(options) {
1263
554
  defaultLevel,
1264
555
  originalRenderCall,
1265
556
  originalRenderResult,
557
+ resolvePresentation,
1266
558
  options,
1267
559
  });
1268
560
  },
1269
561
  };
1270
562
  };
1271
563
  }
564
+ /** UI factory consumed by package-owned direct renderers, independently of host resolver precedence. */
565
+ export function createOptionalToolRendererFactory(pi, options) {
566
+ return (name, native) => createRendererResolver({ ...options, tools: [name] }, (toolName) => getRegisteredToolPresentationProvider(pi, toolName))(name, () => native) ?? native;
567
+ }
1272
568
  export function registerCollapsibleToolRenderer(pi, options) {
1273
- pi.registerToolRenderer(createCollapsibleToolRendererResolver(options));
569
+ if (typeof pi.on === "function") {
570
+ pi.on("tool_execution_start", (event) => {
571
+ recordToolExecutionStart(event);
572
+ });
573
+ pi.on("tool_execution_end", (event) => {
574
+ recordToolExecutionEnd(event);
575
+ });
576
+ pi.on("tool_result", (event) => {
577
+ const captured = getCapturedNestedCalls(event.toolCallId);
578
+ if (captured && captured.length > 0) {
579
+ const currentDetails = isRecord(event.details) ? event.details : {};
580
+ return {
581
+ details: {
582
+ ...currentDetails,
583
+ __piExtUiCapturedCalls: captured,
584
+ },
585
+ };
586
+ }
587
+ return undefined;
588
+ });
589
+ }
590
+ pi.registerToolRenderer(createRendererResolver(options, (name) => getRegisteredToolPresentationProvider(pi, name)));
1274
591
  }