@esso0428/pi-subagents 0.15.14 → 0.15.16

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -32
  2. package/dist/ui/agent-widget.d.ts +8 -0
  3. package/dist/ui/agent-widget.d.ts.map +1 -1
  4. package/dist/ui/agent-widget.js +40 -12
  5. package/dist/ui/agent-widget.js.map +1 -1
  6. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  7. package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
  8. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  10. package/dist/ui/ccstyle/tool-renderer.js +4 -10
  11. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.d.ts +9 -16
  13. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  14. package/dist/ui/ccstyle/tool-result.js +71 -121
  15. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  16. package/dist/ui/conversation-timeline.d.ts +14 -9
  17. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  18. package/dist/ui/conversation-timeline.js +60 -72
  19. package/dist/ui/conversation-timeline.js.map +1 -1
  20. package/dist/ui/conversation-viewer.d.ts +4 -1
  21. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  22. package/dist/ui/conversation-viewer.js +95 -94
  23. package/dist/ui/conversation-viewer.js.map +1 -1
  24. package/dist/write-execution.d.ts +0 -2
  25. package/dist/write-execution.d.ts.map +1 -1
  26. package/dist/write-execution.js +1 -5
  27. package/dist/write-execution.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/ui/agent-widget.ts +106 -20
  30. package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
  31. package/src/ui/ccstyle/tool-renderer.ts +9 -22
  32. package/src/ui/ccstyle/tool-result.ts +82 -186
  33. package/src/ui/conversation-blocks.ts +13 -11
  34. package/src/ui/conversation-timeline.ts +211 -122
  35. package/src/ui/conversation-viewer.ts +149 -125
  36. package/src/ui/fleet-list.ts +34 -1
  37. package/src/write-execution.ts +1 -7
  38. package/test/ui/agent-widget.test.ts +161 -0
  39. package/test/ui/conversation-diff.test.ts +1 -47
  40. package/test/ui/conversation-viewer.test.ts +181 -112
  41. package/test/ui/fleet-list.test.ts +5 -1
  42. package/test/write-execution.test.ts +0 -2
  43. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
  44. package/.pi/workflows/index.json +0 -5
@@ -14,7 +14,19 @@ import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type Se
14
14
  // ---- Constants ----
15
15
 
16
16
  /** Maximum number of rendered lines before overflow collapse kicks in. */
17
- const MAX_WIDGET_LINES = 12;
17
+ export const MAX_WIDGET_LINES = 12;
18
+ /** Keep a small editor/input area visible below the above-editor widget. */
19
+ const MIN_EDITOR_LINES = 4;
20
+
21
+ /**
22
+ * Derive the widget ceiling from the terminal height while retaining the
23
+ * historical MAX_WIDGET_LINES ceiling. Very short terminals may have no room
24
+ * for the widget; that is preferable to consuming the editor/input area.
25
+ */
26
+ export function getWidgetLineBudget(rows: number): number {
27
+ if (!Number.isFinite(rows)) return MAX_WIDGET_LINES;
28
+ return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(rows) - MIN_EDITOR_LINES));
29
+ }
18
30
 
19
31
  /** Braille spinner frames for animated running indicator. */
20
32
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -176,9 +188,9 @@ export function buildInvocationTags(
176
188
  return { modelName: invocation.modelName, tags };
177
189
  }
178
190
 
179
- /** Truncate text to a single line, max `len` chars. */
191
+ /** Normalize and truncate text so it can never add physical widget rows. */
180
192
  function truncateLine(text: string, len = 60): string {
181
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
193
+ const line = text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
182
194
  if (line.length <= len) return line;
183
195
  return line.slice(0, len) + "…";
184
196
  }
@@ -228,6 +240,8 @@ export class AgentWidget {
228
240
  private tui: any | undefined;
229
241
  /** Last status bar text, used to avoid redundant setStatus calls. */
230
242
  private lastStatusText: string | undefined;
243
+ /** Snapshot of the state used for the last widget registration/render request. */
244
+ private lastRenderKey: string | undefined;
231
245
 
232
246
  constructor(
233
247
  private manager: AgentManager,
@@ -269,6 +283,7 @@ export class AgentWidget {
269
283
  this.widgetRegistered = false;
270
284
  this.tui = undefined;
271
285
  this.lastStatusText = undefined;
286
+ this.lastRenderKey = undefined;
272
287
  }
273
288
  }
274
289
 
@@ -285,10 +300,20 @@ export class AgentWidget {
285
300
  this.update();
286
301
  }
287
302
 
288
- /** Ensure the widget update timer is running. */
303
+ /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
289
304
  ensureTimer() {
305
+ if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
290
306
  if (!this.widgetInterval) {
291
- this.widgetInterval = setInterval(() => this.update(), 80);
307
+ this.widgetInterval = setInterval(() => this.update(true), 80);
308
+ }
309
+ }
310
+
311
+ private syncTimer(shouldRun: boolean): void {
312
+ if (shouldRun) {
313
+ this.ensureTimer();
314
+ } else if (this.widgetInterval) {
315
+ clearInterval(this.widgetInterval);
316
+ this.widgetInterval = undefined;
292
317
  }
293
318
  }
294
319
 
@@ -325,7 +350,7 @@ export class AgentWidget {
325
350
  statusText = theme.fg("dim", " stopped");
326
351
  } else if (a.status === "error") {
327
352
  icon = theme.fg("error", "✗");
328
- const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
353
+ const errMsg = a.error ? `: ${truncateLine(a.error)}` : "";
329
354
  statusText = theme.fg("error", ` error${errMsg}`);
330
355
  } else {
331
356
  // aborted
@@ -340,7 +365,7 @@ export class AgentWidget {
340
365
  parts.push(duration);
341
366
 
342
367
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
343
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
368
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", truncateLine(a.description))} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
344
369
  }
345
370
 
346
371
  /**
@@ -363,6 +388,8 @@ export class AgentWidget {
363
388
  if (!hasActive && !hasFinished) return [];
364
389
 
365
390
  const w = tui.terminal.columns;
391
+ const maxLines = getWidgetLineBudget(tui.terminal.rows);
392
+ if (maxLines === 0) return [];
366
393
  const truncate = (line: string) => truncateToWidth(line, w);
367
394
  const headingColor = hasActive ? "accent" : "dim";
368
395
  const headingIcon = hasActive ? "●" : "○";
@@ -399,7 +426,7 @@ export class AgentWidget {
399
426
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
400
427
 
401
428
  runningLines.push([
402
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
429
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
403
430
  truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
404
431
  ]);
405
432
  }
@@ -408,12 +435,18 @@ export class AgentWidget {
408
435
  ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
409
436
  : undefined;
410
437
 
411
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
412
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
438
+ // Assemble with a responsive cap (heading + overflow indicator = 2
439
+ // reserved lines when content exceeds the available body budget).
440
+ const maxBody = maxLines - 1; // heading takes 1 line
413
441
  const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
414
442
 
415
443
  const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
416
444
 
445
+ if (maxLines === 1) {
446
+ // There is room only for the heading; do not consume the editor/input row.
447
+ return lines;
448
+ }
449
+
417
450
  if (totalBody <= maxBody) {
418
451
  // Everything fits — add all lines and fix up connectors for the last item.
419
452
  lines.push(...finishedLines);
@@ -439,6 +472,7 @@ export class AgentWidget {
439
472
  // Reserve 1 line for overflow indicator.
440
473
  let budget = maxBody - 1;
441
474
  let hiddenRunning = 0;
475
+ let hiddenQueued = 0;
442
476
  let hiddenFinished = 0;
443
477
 
444
478
  // 1. Running agents (2 lines each)
@@ -452,9 +486,13 @@ export class AgentWidget {
452
486
  }
453
487
 
454
488
  // 2. Queued line
455
- if (queuedLine && budget >= 1) {
456
- lines.push(queuedLine);
457
- budget--;
489
+ if (queuedLine) {
490
+ if (budget >= 1) {
491
+ lines.push(queuedLine);
492
+ budget--;
493
+ } else {
494
+ hiddenQueued = 1;
495
+ }
458
496
  }
459
497
 
460
498
  // 3. Finished agents
@@ -470,17 +508,53 @@ export class AgentWidget {
470
508
  // Overflow summary
471
509
  const overflowParts: string[] = [];
472
510
  if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
511
+ if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
473
512
  if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
474
513
  const overflowText = overflowParts.join(", ");
475
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
514
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenQueued + hiddenFinished} more (${overflowText})`)}`)
476
515
  );
477
516
  }
478
517
 
479
518
  return lines;
480
519
  }
481
520
 
482
- /** Force an immediate widget update. */
483
- update() {
521
+ /** Build a render-relevant snapshot without capturing mutable records. */
522
+ private renderKey(allAgents: ReturnType<AgentManager["listAgents"]>): string {
523
+ const activities = allAgents.map(a => {
524
+ const activity = this.agentActivity.get(a.id);
525
+ return activity ? {
526
+ id: a.id,
527
+ activeTools: [...activity.activeTools.entries()],
528
+ toolUses: activity.toolUses,
529
+ responseText: activity.responseText,
530
+ turnCount: activity.turnCount,
531
+ maxTurns: activity.maxTurns,
532
+ lifetimeUsage: activity.lifetimeUsage,
533
+ contextPercent: getSessionContextPercent(activity.session),
534
+ } : undefined;
535
+ });
536
+ return JSON.stringify({
537
+ frame: this.widgetFrame,
538
+ agents: allAgents.map(a => ({
539
+ id: a.id,
540
+ type: a.type,
541
+ description: a.description,
542
+ status: a.status,
543
+ toolUses: a.toolUses,
544
+ startedAt: a.startedAt,
545
+ completedAt: a.completedAt,
546
+ error: a.error,
547
+ lifetimeUsage: a.lifetimeUsage,
548
+ compactionCount: a.compactionCount,
549
+ isBackground: a.isBackground,
550
+ })),
551
+ activities,
552
+ finishedTurnAge: [...this.finishedTurnAge.entries()],
553
+ });
554
+ }
555
+
556
+ /** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
557
+ update(advanceSpinner = false) {
484
558
  if (!this.uiCtx) return;
485
559
  const allAgents = this.widgetAgents();
486
560
 
@@ -506,7 +580,8 @@ export class AgentWidget {
506
580
  this.uiCtx.setStatus("subagents", undefined);
507
581
  this.lastStatusText = undefined;
508
582
  }
509
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
583
+ this.lastRenderKey = undefined;
584
+ this.syncTimer(false);
510
585
  // Clean up stale entries
511
586
  for (const [id] of this.finishedTurnAge) {
512
587
  if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
@@ -528,7 +603,12 @@ export class AgentWidget {
528
603
  this.lastStatusText = newStatusText;
529
604
  }
530
605
 
531
- this.widgetFrame++;
606
+ // Spinner animation is driven only by the timer while a visible running
607
+ // agent exists. Event-driven updates refresh changed stats without making
608
+ // an otherwise idle widget advance.
609
+ if (advanceSpinner && runningCount > 0) this.widgetFrame++;
610
+ this.syncTimer(runningCount > 0);
611
+ const renderKey = this.renderKey(allAgents);
532
612
 
533
613
  // Register widget callback once; subsequent updates use requestRender()
534
614
  // which re-invokes render() without replacing the component (avoids layout thrashing).
@@ -541,14 +621,17 @@ export class AgentWidget {
541
621
  // Theme changed — force re-registration so factory captures fresh theme.
542
622
  this.widgetRegistered = false;
543
623
  this.tui = undefined;
624
+ this.lastRenderKey = undefined;
544
625
  },
545
626
  };
546
627
  }, { placement: "aboveEditor" });
547
628
  this.widgetRegistered = true;
548
- } else {
549
- // Widget already registered — just request a re-render of existing components.
629
+ } else if (renderKey !== this.lastRenderKey) {
630
+ // Widget already registered — request a re-render only when visible state
631
+ // changed (or the spinner timer advanced).
550
632
  this.tui?.requestRender();
551
633
  }
634
+ this.lastRenderKey = renderKey;
552
635
  }
553
636
 
554
637
  dispose() {
@@ -563,5 +646,8 @@ export class AgentWidget {
563
646
  this.widgetRegistered = false;
564
647
  this.tui = undefined;
565
648
  this.lastStatusText = undefined;
649
+ this.lastRenderKey = undefined;
650
+ this.uiCtx = undefined;
651
+ this.finishedTurnAge.clear();
566
652
  }
567
653
  }
@@ -2951,11 +2951,6 @@ export function renderWriteDiffResult(
2951
2951
  return new Text(theme.fg("warning", "↳ diff unavailable: durable diff has no displayable lines"), 0, 0);
2952
2952
  }
2953
2953
  }
2954
- const persistedWasOverwrite = persistedParsed?.entries.some(
2955
- (entry) => entry.kind === "line" && entry.lineKind === "remove",
2956
- ) ?? false;
2957
- const wasOverwrite = options.fileExistedBeforeWrite === true || persistedWasOverwrite;
2958
-
2959
2954
  if (typeof content !== "string" && !persistedParsed) {
2960
2955
  if (!safeFallbackText.trim()) {
2961
2956
  return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
@@ -3036,7 +3031,7 @@ export function renderWriteDiffResult(
3036
3031
  hovered,
3037
3032
  clampDiffLinesToWidth(
3038
3033
  renderWriteCollapsedHintLine(
3039
- wasOverwrite,
3034
+ options.fileExistedBeforeWrite === true,
3040
3035
  safeWidth,
3041
3036
  theme,
3042
3037
  hovered,
@@ -3048,7 +3043,7 @@ export function renderWriteDiffResult(
3048
3043
  }
3049
3044
 
3050
3045
  const header = renderWriteHeader(
3051
- wasOverwrite,
3046
+ options.fileExistedBeforeWrite === true,
3052
3047
  safeWidth,
3053
3048
  theme,
3054
3049
  options.headerLabel,
@@ -31,11 +31,8 @@ function summaryForCall(toolName: string, args: any): { main: string; detail: st
31
31
  detail: details.length ? ` (${details.join(", ")})` : "",
32
32
  };
33
33
  }
34
- const value =
35
- args.path ?? args.file_path ?? args.command ?? args.query ?? args.pattern ?? args.url ?? args.name;
36
- return value === undefined
37
- ? { main: title, detail: "" }
38
- : { main: `${title} ${oneLine(value)}`, detail: "" };
34
+ const value = args.path ?? args.file_path ?? args.command ?? args.query ?? args.pattern ?? args.url ?? args.name;
35
+ return value === undefined ? { main: title, detail: "" } : { main: `${title} ${oneLine(value)}`, detail: "" };
39
36
  }
40
37
 
41
38
  function renderCallComponent(toolName: string, args: any, theme: any, context: any): Component {
@@ -45,11 +42,9 @@ function renderCallComponent(toolName: string, args: any, theme: any, context: a
45
42
  return {
46
43
  render(width: number): string[] {
47
44
  const safeWidth = Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
48
- const summary = summaryForCall(toolName, args);
49
45
  const prefix = ` ${theme.fg(color, icon)} `;
50
- const title = theme.fg("toolTitle", summary.main);
51
- const detail = summary.detail ? theme.fg("dim", summary.detail) : "";
52
- return [truncateToWidth(prefix + title + detail, safeWidth, "")];
46
+ const summary = summaryForCall(toolName, args);
47
+ return [truncateToWidth(prefix + theme.fg("toolTitle", summary.main) + theme.fg("dim", summary.detail), safeWidth, "")];
53
48
  },
54
49
  invalidate() {},
55
50
  };
@@ -78,19 +73,11 @@ export function createViewerCcstyleTool(toolName: string, isHovered: () => boole
78
73
  },
79
74
  renderResult(result, options, theme, context) {
80
75
  settled = true;
81
- return createViewerCcstyleResult(
82
- name,
83
- result,
84
- { ...options, isHovered },
85
- theme,
86
- {
87
- ...context,
88
- isHovered,
89
- diffConfig: {
90
- ...DEFAULT_TOOL_DISPLAY_CONFIG,
91
- },
92
- },
93
- );
76
+ return createViewerCcstyleResult(name, result, { ...options, isHovered }, theme, {
77
+ ...context,
78
+ isHovered,
79
+ diffConfig: { ...DEFAULT_TOOL_DISPLAY_CONFIG },
80
+ });
94
81
  },
95
82
  };
96
83
  }