@hheei/pi-ext-ui 0.5.1 → 0.6.0

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.
@@ -67,6 +67,7 @@ export class NestedCallsView {
67
67
  #props;
68
68
  #inMemoryLevels = new Map();
69
69
  #copiedIndex = undefined;
70
+ #failedIndex = undefined;
70
71
  #copiedTimer = undefined;
71
72
  #lineMapping = [];
72
73
  #renderCache;
@@ -96,14 +97,11 @@ export class NestedCallsView {
96
97
  typeof copiedState.until === "number") {
97
98
  const remaining = copiedState.until - Date.now();
98
99
  if (remaining > 0) {
99
- this.#copiedIndex = copiedState.index;
100
+ const failed = copiedState.failed === true;
101
+ this.#copiedIndex = failed ? undefined : copiedState.index;
102
+ this.#failedIndex = failed ? copiedState.index : undefined;
100
103
  this.#copiedTimer = setTimeout(() => {
101
- this.#copiedIndex = undefined;
102
- this.#renderCache = undefined;
103
- const s = this.#props.stateHolder;
104
- if (s !== undefined) {
105
- delete s[NESTED_CALLS_COPIED_KEY];
106
- }
104
+ this.#clearCopyFeedback();
107
105
  this.#props.invalidate?.();
108
106
  }, remaining);
109
107
  this.#copiedTimer.unref?.();
@@ -162,9 +160,9 @@ export class NestedCallsView {
162
160
  */
163
161
  #callContext(call) {
164
162
  const { context } = this.#props;
165
- const toolCallId = "toolCallId" in call && typeof call.toolCallId === "string"
166
- ? call.toolCallId
167
- : context.toolCallId;
163
+ // A captured call carries its own id. A persisted record has none, so it falls back to the
164
+ // parent's id — the only identity the host offers, and never a child's guess.
165
+ const toolCallId = "id" in call ? call.id : context.toolCallId;
168
166
  return {
169
167
  ...context,
170
168
  lastComponent: undefined,
@@ -187,39 +185,54 @@ export class NestedCallsView {
187
185
  if (!text)
188
186
  return;
189
187
  const writeClip = this.#props.copyToClipboard ?? copyToSystemClipboard;
190
- void writeClip(text).catch(() => { });
191
188
  const durationMs = 1500;
189
+ // Feedback starts when the write settles: a rejected clipboard write is not a copy.
190
+ void writeClip(text).then(() => this.#showCopyFeedback(callIndex, durationMs, false), () => this.#showCopyFeedback(callIndex, durationMs, true));
191
+ }
192
+ /** One feedback window on one row: ✓ when the text reached the clipboard, ✗ when it did not. */
193
+ #showCopyFeedback(callIndex, durationMs, failed) {
192
194
  const until = Date.now() + durationMs;
193
- this.#copiedIndex = callIndex;
195
+ this.#copiedIndex = failed ? undefined : callIndex;
196
+ this.#failedIndex = failed ? callIndex : undefined;
194
197
  this.#renderCache = undefined;
195
198
  const state = this.#props.stateHolder;
196
199
  if (state !== undefined) {
197
200
  state[NESTED_CALLS_COPIED_KEY] = {
198
201
  index: callIndex,
199
202
  until,
203
+ ...(failed ? { failed: true } : {}),
200
204
  };
201
205
  }
202
206
  if (this.#copiedTimer !== undefined) {
203
207
  clearTimeout(this.#copiedTimer);
204
208
  }
205
209
  this.#copiedTimer = setTimeout(() => {
206
- this.#copiedIndex = undefined;
207
- this.#renderCache = undefined;
208
- const s = this.#props.stateHolder;
209
- if (s !== undefined) {
210
- delete s[NESTED_CALLS_COPIED_KEY];
211
- }
210
+ this.#clearCopyFeedback();
212
211
  this.#props.invalidate?.();
213
212
  }, durationMs);
214
213
  this.#copiedTimer.unref?.();
215
214
  this.#props.invalidate?.();
216
215
  }
216
+ /** Drops the copy marker, its timer, and the shared record it left behind. */
217
+ #clearCopyFeedback() {
218
+ if (this.#copiedTimer !== undefined) {
219
+ clearTimeout(this.#copiedTimer);
220
+ this.#copiedTimer = undefined;
221
+ }
222
+ this.#copiedIndex = undefined;
223
+ this.#failedIndex = undefined;
224
+ this.#renderCache = undefined;
225
+ const state = this.#props.stateHolder;
226
+ if (state !== undefined)
227
+ delete state[NESTED_CALLS_COPIED_KEY];
228
+ }
217
229
  render(width) {
218
230
  const safeWidth = Math.max(1, width);
219
231
  const levelsKey = this.#getLevelsKey();
220
232
  if (this.#renderCache !== undefined &&
221
233
  this.#renderCache.width === safeWidth &&
222
234
  this.#renderCache.copiedIndex === this.#copiedIndex &&
235
+ this.#renderCache.failedIndex === this.#failedIndex &&
223
236
  this.#renderCache.levelsKey === levelsKey) {
224
237
  this.#lineMapping = this.#renderCache.lineMapping;
225
238
  return this.#renderCache.lines;
@@ -254,7 +267,11 @@ export class NestedCallsView {
254
267
  summary: [childSummary, duration].filter(Boolean).join(" · "),
255
268
  }, theme);
256
269
  const isCopied = this.#copiedIndex === i;
257
- const copyIcon = isCopied ? theme.fg("success", "✓") : theme.fg("dim", "󰆏");
270
+ const copyIcon = this.#failedIndex === i
271
+ ? theme.fg("error", "✗")
272
+ : isCopied
273
+ ? theme.fg("success", "✓")
274
+ : theme.fg("dim", "󰆏");
258
275
  const copySlotWidth = 2 + rightMargin;
259
276
  const maxLeft = Math.max(1, width - copySlotWidth);
260
277
  const truncatedLeft = truncate(leftContent, maxLeft);
@@ -290,6 +307,7 @@ export class NestedCallsView {
290
307
  this.#renderCache = {
291
308
  width: safeWidth,
292
309
  copiedIndex: this.#copiedIndex,
310
+ failedIndex: this.#failedIndex,
293
311
  levelsKey,
294
312
  lines,
295
313
  lineMapping: this.#lineMapping,
@@ -390,17 +408,13 @@ export class NestedCallsView {
390
408
  return { handled: true };
391
409
  }
392
410
  dispose() {
393
- if (this.#copiedTimer !== undefined) {
394
- clearTimeout(this.#copiedTimer);
395
- this.#copiedTimer = undefined;
396
- }
411
+ this.#clearCopyFeedback();
397
412
  this.#renderCache = undefined;
398
413
  }
399
414
  collapseAll() {
400
- if (this.#copiedTimer !== undefined) {
401
- clearTimeout(this.#copiedTimer);
402
- this.#copiedTimer = undefined;
403
- }
415
+ // The marker points at a row that is no longer on screen; leaving it behind would bring a
416
+ // permanent checkmark back when the same view expands again.
417
+ this.#clearCopyFeedback();
404
418
  this.#inMemoryLevels.clear();
405
419
  const state = this.#props.stateHolder;
406
420
  if (state !== undefined) {
@@ -166,16 +166,16 @@ export function extractSummaryArg(toolName, args) {
166
166
  return firstLine(parsed.code);
167
167
  case "bg_bash_get":
168
168
  case "bg_bash_stop":
169
- case "agent_send":
170
- case "agent_get":
171
- case "agent_stop":
169
+ case "agents_get":
170
+ case "agents_stop":
172
171
  return typeof parsed.id === "string" ? parsed.id.trim() : "";
173
- case "agent_spawn": {
172
+ case "agents_delegate": {
174
173
  const agent = typeof parsed.agent === "string" ? parsed.agent.trim() : "";
174
+ const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
175
175
  const task = firstLine(parsed.task);
176
176
  if (agent && task)
177
177
  return `${agent}: ${task}`;
178
- return agent || task;
178
+ return agent || id || task;
179
179
  }
180
180
  case "tool_search":
181
181
  return typeof parsed.query === "string" ? parsed.query.trim() : "";
package/dist/renderer.js CHANGED
@@ -79,6 +79,47 @@ function resolveToolCallSummary(params) {
79
79
  // Tier 2: Contractual Fallback for built-in tools
80
80
  return extractSummaryArg(toolName, args);
81
81
  }
82
+ function isCapturedCall(call) {
83
+ return "args" in call || "result" in call;
84
+ }
85
+ /** Nested facts for a consumer that lists children outside the container's own body. */
86
+ function nestedChildren(calls) {
87
+ return calls.map((call) => {
88
+ if (isCapturedCall(call)) {
89
+ const isError = call.isError === true;
90
+ const inline = inlinePresentation(call.toolName, call.args, call.result?.details, isError);
91
+ return {
92
+ title: call.toolName,
93
+ ...(inline === undefined ? {} : { summary: inline.summary }),
94
+ // The tracker records a call when it starts, so a missing result means it is still
95
+ // running, not that it succeeded.
96
+ status: isError
97
+ ? "error"
98
+ : call.result === undefined
99
+ ? "running"
100
+ : "ok",
101
+ ...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }),
102
+ ...(call.result === undefined
103
+ ? {}
104
+ : { detail: extractResultText(call.toolName, call.result).outputText }),
105
+ };
106
+ }
107
+ return {
108
+ title: call.toolName,
109
+ ...(call.summary === undefined ? {} : { summary: call.summary }),
110
+ // Same precedence as formatNestedCallRow: a cancelled call is a warning, not a success.
111
+ status: call.status === "running"
112
+ ? "running"
113
+ : call.isWarning === true
114
+ ? "warn"
115
+ : call.isError === true || call.status === "error"
116
+ ? "error"
117
+ : "ok",
118
+ ...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }),
119
+ ...(call.error === undefined ? {} : { detail: call.error }),
120
+ };
121
+ });
122
+ }
82
123
  function formatArgsPreview(args) {
83
124
  if (args === undefined || args === null)
84
125
  return "";
@@ -256,6 +297,7 @@ function renderGenericResult(params) {
256
297
  invalidate: () => context.invalidate(),
257
298
  })
258
299
  : undefined;
300
+ const children = nestedChildren(childCalls);
259
301
  const collapsedRows = codemodeNestedView !== undefined
260
302
  ? codemodeNestedView
261
303
  : isMetaTool && nestedCalls.length > 0
@@ -366,6 +408,7 @@ function renderGenericResult(params) {
366
408
  },
367
409
  railToken: toolRailToken,
368
410
  collapsedRows,
411
+ ...(children.length === 0 ? {} : { children }),
369
412
  sections,
370
413
  copy,
371
414
  outputPad: context.outputPad,
@@ -534,6 +577,8 @@ function renderOwnedPresentation(params) {
534
577
  .map((line) => theme.fg("error", stripTerminalSequences(line)))
535
578
  : undefined;
536
579
  })(),
580
+ ...(presentation.children === undefined ? {} : { children: presentation.children }),
581
+ ...(presentation.childCount === undefined ? {} : { childCount: presentation.childCount }),
537
582
  sections,
538
583
  copy,
539
584
  outputPad: context.outputPad,
@@ -0,0 +1,45 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Container } from "@earendil-works/pi-tui";
3
+ import { type ToolStatusKind, type ToolViewChild } from "./tool-view.js";
4
+ export interface Usage {
5
+ readonly input: number;
6
+ readonly output: number;
7
+ readonly cacheRead: number;
8
+ readonly cacheWrite: number;
9
+ }
10
+ export type MemberKind = "assistant" | "tool" | "notice";
11
+ export type Category = "tools" | "thinking" | "notices";
12
+ /** One transcript entry a block owns, reduced to the facts the collapsed frame draws. */
13
+ export interface Member {
14
+ readonly kind: MemberKind;
15
+ readonly component: Component;
16
+ readonly title?: string;
17
+ readonly summary?: string;
18
+ readonly status?: ToolStatusKind;
19
+ readonly children?: readonly ToolViewChild[];
20
+ /** Nested calls the member made, when `children` is only what fits. */
21
+ readonly childCount?: number;
22
+ readonly thinking?: string;
23
+ readonly thinkingChars?: number;
24
+ readonly usage?: Usage;
25
+ readonly detail?: (width: number) => string[];
26
+ }
27
+ /** One agent loop. Blocks created during the run share its origin; ending it freezes them. */
28
+ export interface ToolGroupRun {
29
+ readonly startedAt: number;
30
+ endedAt?: number;
31
+ }
32
+ /**
33
+ * The collapsed frame of one activity block.
34
+ *
35
+ * The frame owns only layout and hit testing: tool rows keep their own component so their glyph,
36
+ * colors, copy action, and fold level stay the ones the transcript already draws. Anything this
37
+ * view synthesized itself would drift from those rows.
38
+ */
39
+ export declare class ToolGroupView extends Container {
40
+ #private;
41
+ constructor(ui: ExtensionUIContext, redraw: () => void, run: ToolGroupRun | undefined);
42
+ update(members: Member[], settled: boolean): void;
43
+ invalidate(): void;
44
+ render(width: number): string[];
45
+ }