@esso0428/pi-subagents 0.15.15 → 0.15.17

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 CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.17] - 2026-09-14
11
+
12
+ ### Fixed
13
+ - **FleetView preserves the selected row while navigating** by maintaining a bounded viewport over the full roster; `j`/`k`, arrow keys, and normalized wheel input share one directional transition and consume handled wheel events without stealing input from other focused components.
14
+ - **Structural Fleet/widget changes refresh the text-input viewport after a short defer** instead of requiring a manual redraw; refreshes coalesce, respect a maximum wait, cancel on session switch, and dispose before shutdown cleanup.
15
+
16
+ ## [0.15.16] - 2026-09-13
17
+
18
+ ### Added
19
+ - **Live panel rendering now coalesces refreshes and avoids rebuilding unchanged rows**, keeping FleetView, the conversation timeline, and tool hover interactions responsive during frequent agent updates.
20
+
21
+ ### Fixed
22
+ - Same-name tool results remain paired with their originating tool calls when a conversation is refreshed.
23
+
10
24
  ## [0.15.15] - 2026-09-13
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
15
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
16
16
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
17
17
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
18
- - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
18
+ - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓`/wheel to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. The selected row stays visible while the bounded list window moves through the full roster. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
19
19
  - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
20
20
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` or `.agents/agents/<name>.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
21
21
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.15.15",
3
+ "version": "0.15.17",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  type UICtx,
51
51
  } from "./ui/agent-widget.js";
52
52
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
53
+ import { createDeferredUiRefresh } from "./ui/deferred-ui-refresh.js";
53
54
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
54
55
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
55
56
 
@@ -374,6 +375,7 @@ export default function (pi: ExtensionAPI) {
374
375
  fleet.onAgentFinished(record.id);
375
376
  scheduleNudge(record.id, () => emitIndividualNudge(record));
376
377
  widget.update();
378
+ scheduleUiRefresh();
377
379
  }
378
380
 
379
381
  // ---- Group join manager ----
@@ -385,7 +387,11 @@ export default function (pi: ExtensionAPI) {
385
387
  scheduleNudge(groupKey, () => {
386
388
  // Re-check at send time
387
389
  const unconsumed = records.filter(r => !r.resultConsumed);
388
- if (unconsumed.length === 0) { widget.update(); return; }
390
+ if (unconsumed.length === 0) {
391
+ widget.update();
392
+ scheduleUiRefresh();
393
+ return;
394
+ }
389
395
 
390
396
  const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
391
397
  const label = partial
@@ -406,6 +412,7 @@ export default function (pi: ExtensionAPI) {
406
412
  }, { deliverAs: "followUp", triggerTurn: true });
407
413
  });
408
414
  widget.update();
415
+ scheduleUiRefresh();
409
416
  },
410
417
  30_000,
411
418
  );
@@ -463,6 +470,7 @@ export default function (pi: ExtensionAPI) {
463
470
  widget.markFinished(record.id);
464
471
  fleet.onAgentFinished(record.id);
465
472
  widget.update();
473
+ scheduleUiRefresh();
466
474
  return;
467
475
  }
468
476
 
@@ -470,6 +478,7 @@ export default function (pi: ExtensionAPI) {
470
478
  // don't send an individual nudge — finalizeBatch will pick it up retroactively.
471
479
  if (currentBatchAgents.some(a => a.id === record.id)) {
472
480
  widget.update();
481
+ scheduleUiRefresh();
473
482
  return;
474
483
  }
475
484
 
@@ -480,6 +489,7 @@ export default function (pi: ExtensionAPI) {
480
489
  // 'held' → do nothing, group will fire later
481
490
  // 'delivered' → group callback already fired
482
491
  widget.update();
492
+ scheduleUiRefresh();
483
493
  }, undefined, (record) => {
484
494
  // Emit started event when agent transitions to running (including from queue)
485
495
  pi.events.emit("subagents:started", {
@@ -487,6 +497,11 @@ export default function (pi: ExtensionAPI) {
487
497
  type: record.type,
488
498
  description: record.description,
489
499
  });
500
+ widget.ensureTimer();
501
+ widget.update();
502
+ fleet.ensureTimer();
503
+ fleet.update();
504
+ scheduleUiRefresh();
490
505
  }, (record, info) => {
491
506
  // Emit compacted event when agent's session compacts (preserves count on record).
492
507
  pi.events.emit("subagents:compacted", {
@@ -581,6 +596,7 @@ export default function (pi: ExtensionAPI) {
581
596
  });
582
597
 
583
598
  pi.on("session_before_switch", () => {
599
+ deferredUiRefresh.cancel();
584
600
  manager.clearCompleted(true);
585
601
  scheduler.stop();
586
602
  });
@@ -602,6 +618,7 @@ export default function (pi: ExtensionAPI) {
602
618
  manager.abortAll();
603
619
  for (const timer of pendingNudges.values()) clearTimeout(timer);
604
620
  pendingNudges.clear();
621
+ deferredUiRefresh.dispose();
605
622
  fleet.dispose();
606
623
  manager.dispose();
607
624
  });
@@ -614,13 +631,34 @@ export default function (pi: ExtensionAPI) {
614
631
  let widgetMode: WidgetMode = "background";
615
632
  function getWidgetMode(): WidgetMode { return widgetMode; }
616
633
  const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
617
- function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
634
+ function setWidgetMode(m: WidgetMode): void {
635
+ const changed = widgetMode !== m;
636
+ widgetMode = m;
637
+ widget.update();
638
+ if (changed) scheduleUiRefresh();
639
+ }
618
640
 
619
641
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
620
642
  const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd, pi, () => currentCtx);
643
+
644
+ // One render-only scheduler is shared by both widgets. The callback resolves
645
+ // the live TUI target at fire time because session switches invalidate the
646
+ // previous widget context; both widgets use the same TUI, so one successful
647
+ // request is sufficient.
648
+ const deferredUiRefresh = createDeferredUiRefresh(() => {
649
+ if (widget.requestUiRefresh(true)) return;
650
+ fleet.requestUiRefresh(true);
651
+ });
652
+ const scheduleUiRefresh = (): void => deferredUiRefresh.schedule();
653
+
621
654
  let fleetViewEnabled = true;
622
655
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
623
- function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
656
+ function setFleetViewEnabled(b: boolean): void {
657
+ const changed = fleetViewEnabled !== b;
658
+ fleetViewEnabled = b;
659
+ fleet.setEnabled(b);
660
+ if (changed) scheduleUiRefresh();
661
+ }
624
662
 
625
663
  // Project/global default for writing the subagent .output transcript. A custom
626
664
  // agent's `output_transcript` frontmatter overrides this per spawn; when the
@@ -720,8 +758,9 @@ export default function (pi: ExtensionAPI) {
720
758
 
721
759
  // Grab UI context from first tool execution + clear lingering widget on new turn
722
760
  pi.on("tool_execution_start", async (_event, ctx) => {
723
- widget.setUICtx(ctx.ui as UICtx);
724
- fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
761
+ const widgetContextChanged = widget.setUICtx(ctx.ui as UICtx);
762
+ const fleetContextChanged = fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
763
+ if (widgetContextChanged || fleetContextChanged) scheduleUiRefresh();
725
764
  widget.onTurnStart();
726
765
  });
727
766
 
@@ -1319,6 +1358,7 @@ Terse command-style prompts produce shallow, generic work.
1319
1358
  widget.update();
1320
1359
  fleet.ensureTimer();
1321
1360
  fleet.update();
1361
+ scheduleUiRefresh();
1322
1362
 
1323
1363
  // Emit created event
1324
1364
  pi.events.emit("subagents:created", {
@@ -1379,8 +1419,10 @@ Terse command-style prompts produce shallow, generic work.
1379
1419
  fgId = a.id;
1380
1420
  agentActivity.set(a.id, fgState);
1381
1421
  widget.ensureTimer();
1422
+ widget.update();
1382
1423
  fleet.ensureTimer();
1383
1424
  fleet.update();
1425
+ scheduleUiRefresh();
1384
1426
  break;
1385
1427
  }
1386
1428
  }
@@ -240,6 +240,8 @@ export class AgentWidget {
240
240
  private tui: any | undefined;
241
241
  /** Last status bar text, used to avoid redundant setStatus calls. */
242
242
  private lastStatusText: string | undefined;
243
+ /** Snapshot of the state used for the last widget registration/render request. */
244
+ private lastRenderKey: string | undefined;
243
245
 
244
246
  constructor(
245
247
  private manager: AgentManager,
@@ -273,15 +275,24 @@ export class AgentWidget {
273
275
  }
274
276
 
275
277
  /** Set the UI context (grabbed from first tool execution). */
276
- setUICtx(ctx: UICtx) {
277
- if (ctx !== this.uiCtx) {
278
- // UICtx changed — the widget registered on the old context is gone.
279
- // Force re-registration on next update().
280
- this.uiCtx = ctx;
281
- this.widgetRegistered = false;
282
- this.tui = undefined;
283
- this.lastStatusText = undefined;
284
- }
278
+ setUICtx(ctx: UICtx): boolean {
279
+ if (ctx === this.uiCtx) return false;
280
+
281
+ // UICtx changed — the widget registered on the old context is gone.
282
+ // Force re-registration on next update().
283
+ this.uiCtx = ctx;
284
+ this.widgetRegistered = false;
285
+ this.tui = undefined;
286
+ this.lastStatusText = undefined;
287
+ this.lastRenderKey = undefined;
288
+ return true;
289
+ }
290
+
291
+ /** Request a render on the currently registered TUI without touching input. */
292
+ requestUiRefresh(force = true): boolean {
293
+ if (!this.tui) return false;
294
+ this.tui.requestRender(force);
295
+ return true;
285
296
  }
286
297
 
287
298
  /**
@@ -297,10 +308,20 @@ export class AgentWidget {
297
308
  this.update();
298
309
  }
299
310
 
300
- /** Ensure the widget update timer is running. */
311
+ /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
301
312
  ensureTimer() {
313
+ if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
302
314
  if (!this.widgetInterval) {
303
- this.widgetInterval = setInterval(() => this.update(), 80);
315
+ this.widgetInterval = setInterval(() => this.update(true), 80);
316
+ }
317
+ }
318
+
319
+ private syncTimer(shouldRun: boolean): void {
320
+ if (shouldRun) {
321
+ this.ensureTimer();
322
+ } else if (this.widgetInterval) {
323
+ clearInterval(this.widgetInterval);
324
+ this.widgetInterval = undefined;
304
325
  }
305
326
  }
306
327
 
@@ -505,8 +526,43 @@ export class AgentWidget {
505
526
  return lines;
506
527
  }
507
528
 
508
- /** Force an immediate widget update. */
509
- update() {
529
+ /** Build a render-relevant snapshot without capturing mutable records. */
530
+ private renderKey(allAgents: ReturnType<AgentManager["listAgents"]>): string {
531
+ const activities = allAgents.map(a => {
532
+ const activity = this.agentActivity.get(a.id);
533
+ return activity ? {
534
+ id: a.id,
535
+ activeTools: [...activity.activeTools.entries()],
536
+ toolUses: activity.toolUses,
537
+ responseText: activity.responseText,
538
+ turnCount: activity.turnCount,
539
+ maxTurns: activity.maxTurns,
540
+ lifetimeUsage: activity.lifetimeUsage,
541
+ contextPercent: getSessionContextPercent(activity.session),
542
+ } : undefined;
543
+ });
544
+ return JSON.stringify({
545
+ frame: this.widgetFrame,
546
+ agents: allAgents.map(a => ({
547
+ id: a.id,
548
+ type: a.type,
549
+ description: a.description,
550
+ status: a.status,
551
+ toolUses: a.toolUses,
552
+ startedAt: a.startedAt,
553
+ completedAt: a.completedAt,
554
+ error: a.error,
555
+ lifetimeUsage: a.lifetimeUsage,
556
+ compactionCount: a.compactionCount,
557
+ isBackground: a.isBackground,
558
+ })),
559
+ activities,
560
+ finishedTurnAge: [...this.finishedTurnAge.entries()],
561
+ });
562
+ }
563
+
564
+ /** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
565
+ update(advanceSpinner = false) {
510
566
  if (!this.uiCtx) return;
511
567
  const allAgents = this.widgetAgents();
512
568
 
@@ -532,7 +588,8 @@ export class AgentWidget {
532
588
  this.uiCtx.setStatus("subagents", undefined);
533
589
  this.lastStatusText = undefined;
534
590
  }
535
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
591
+ this.lastRenderKey = undefined;
592
+ this.syncTimer(false);
536
593
  // Clean up stale entries
537
594
  for (const [id] of this.finishedTurnAge) {
538
595
  if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
@@ -554,7 +611,12 @@ export class AgentWidget {
554
611
  this.lastStatusText = newStatusText;
555
612
  }
556
613
 
557
- this.widgetFrame++;
614
+ // Spinner animation is driven only by the timer while a visible running
615
+ // agent exists. Event-driven updates refresh changed stats without making
616
+ // an otherwise idle widget advance.
617
+ if (advanceSpinner && runningCount > 0) this.widgetFrame++;
618
+ this.syncTimer(runningCount > 0);
619
+ const renderKey = this.renderKey(allAgents);
558
620
 
559
621
  // Register widget callback once; subsequent updates use requestRender()
560
622
  // which re-invokes render() without replacing the component (avoids layout thrashing).
@@ -567,14 +629,17 @@ export class AgentWidget {
567
629
  // Theme changed — force re-registration so factory captures fresh theme.
568
630
  this.widgetRegistered = false;
569
631
  this.tui = undefined;
632
+ this.lastRenderKey = undefined;
570
633
  },
571
634
  };
572
635
  }, { placement: "aboveEditor" });
573
636
  this.widgetRegistered = true;
574
- } else {
575
- // Widget already registered — just request a re-render of existing components.
637
+ } else if (renderKey !== this.lastRenderKey) {
638
+ // Widget already registered — request a re-render only when visible state
639
+ // changed (or the spinner timer advanced).
576
640
  this.tui?.requestRender();
577
641
  }
642
+ this.lastRenderKey = renderKey;
578
643
  }
579
644
 
580
645
  dispose() {
@@ -589,5 +654,8 @@ export class AgentWidget {
589
654
  this.widgetRegistered = false;
590
655
  this.tui = undefined;
591
656
  this.lastStatusText = undefined;
657
+ this.lastRenderKey = undefined;
658
+ this.uiCtx = undefined;
659
+ this.finishedTurnAge.clear();
592
660
  }
593
661
  }
@@ -300,23 +300,25 @@ export function formatConversationMessages(
300
300
  const blocks: ConversationBlock[] = [];
301
301
  const usedBlockIds = new Set<string>();
302
302
  const callsById = new Map<string, ConversationBlock>();
303
- const callsByName = new Map<string, ConversationBlock[]>();
303
+ const callsByName = new Map<string, { blocks: ConversationBlock[]; next: number }>();
304
304
 
305
305
  const registerCall = (name: string, block: ConversationBlock) => {
306
306
  callsById.set(block.toolCallId!, block);
307
307
  const nameKey = name.toLowerCase();
308
- const candidates = callsByName.get(nameKey) ?? [];
309
- candidates.push(block);
310
- callsByName.set(nameKey, candidates);
308
+ const queue = callsByName.get(nameKey) ?? { blocks: [], next: 0 };
309
+ queue.blocks.push(block);
310
+ callsByName.set(nameKey, queue);
311
311
  };
312
312
  const unregisterCall = (block: ConversationBlock) => {
313
+ // Calls remain in their name queue as tombstones. Advancing its cursor is
314
+ // amortized O(1), unlike removing from the middle of a same-name array.
313
315
  if (block.toolCallId) callsById.delete(block.toolCallId);
314
- const nameKey = (block.toolName ?? "tool").toLowerCase();
315
- const candidates = callsByName.get(nameKey);
316
- if (!candidates) return;
317
- const index = candidates.indexOf(block);
318
- if (index >= 0) candidates.splice(index, 1);
319
- if (candidates.length === 0) callsByName.delete(nameKey);
316
+ };
317
+ const nextCallByName = (name: string): ConversationBlock | undefined => {
318
+ const queue = callsByName.get(name.toLowerCase());
319
+ if (!queue) return undefined;
320
+ while (queue.next < queue.blocks.length && queue.blocks[queue.next]?.toolStatus !== "pending") queue.next++;
321
+ return queue.blocks[queue.next];
320
322
  };
321
323
 
322
324
  for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
@@ -368,7 +370,7 @@ export function formatConversationMessages(
368
370
  const direct = result.toolCallId ? callsById.get(result.toolCallId) : undefined;
369
371
  let match = direct;
370
372
  if (!match && result.toolName) {
371
- match = callsByName.get(result.toolName.toLowerCase())?.[0];
373
+ match = nextCallByName(result.toolName);
372
374
  }
373
375
  if (match) {
374
376
  applyToolResult(match, result, maxToolResultChars);