@giladbarnea/pi-user-agents 0.0.7 → 0.0.9

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/widget.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as childProcess from "node:child_process";
1
2
  import {
2
3
  isKeyRelease,
3
4
  matchesKey,
@@ -7,6 +8,7 @@ import {
7
8
  import {
8
9
  AgentViewer,
9
10
  type AgentViewerAction,
11
+ type AgentViewerActions,
10
12
  describeActivity,
11
13
  isLiveAgent,
12
14
  isRunningAgent,
@@ -20,17 +22,21 @@ import type {
20
22
  AgentMessage,
21
23
  AgentResultMessage,
22
24
  CompletedAgent,
25
+ HerdrDelivery,
23
26
  RebaseDelivery,
24
27
  RunningAgent,
25
28
  Theme,
26
29
  UIContext,
27
30
  } from "./shared.js";
28
31
  import {
32
+ CONFIRMATION_WINDOW_MS,
29
33
  contextLabel,
34
+ errorMessage,
30
35
  formatMs,
31
36
  TimedConfirmation,
32
37
  formatToolUses,
33
38
  formatTurns,
39
+ herdrPaneLabel,
34
40
  logSteering,
35
41
  mainContextLabel,
36
42
  MAX_WIDGET_LINES,
@@ -46,12 +52,17 @@ type UserAgentWidgetEntry =
46
52
  type CachedActivityPreview = {
47
53
  source: AgentMessage | string | undefined;
48
54
  status: RunningAgent["status"] | boolean;
55
+ herdrPane: string | undefined;
49
56
  error: string | undefined;
50
57
  width: number;
51
58
  theme: Theme;
52
59
  text: string;
53
60
  };
54
61
 
62
+ type CopyTarget = "response" | "session-id";
63
+
64
+ const COPY_FEEDBACK_MS = 1500;
65
+
55
66
  export class UserAgentWidget {
56
67
  private ui: UIContext | undefined;
57
68
  private frame = 0;
@@ -62,9 +73,16 @@ export class UserAgentWidget {
62
73
  private active = false;
63
74
  private selectedIndex = 0;
64
75
  private viewerOpen = false;
76
+ private copied: { agentId: string; target: CopyTarget } | undefined;
77
+ private copiedTimer: ReturnType<typeof setTimeout> | undefined;
78
+ /** A transient per-row notice explaining why an action did nothing. */
79
+ private warning: { agentId: string; text: string } | undefined;
80
+ private warningTimer: ReturnType<typeof setTimeout> | undefined;
81
+ /** The agent whose herdr pane is being opened right now. */
82
+ private herdrPending: string | undefined;
65
83
  private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
66
- /** One confirmation slot for detaching the selected agent. */
67
- private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
84
+ /** One confirmation slot for both destructive selected-row actions. */
85
+ private readonly confirmation = new TimedConfirmation<string>(() => this.update());
68
86
  private readonly completedAgents: CompletedAgent[] = [];
69
87
 
70
88
  constructor(
@@ -72,6 +90,7 @@ export class UserAgentWidget {
72
90
  private readonly sendSquashedResult: (message: AgentResultMessage) => void,
73
91
  private readonly announceDetached: (sessionId: string) => void,
74
92
  private readonly rebaseDelivery: RebaseDelivery,
93
+ private readonly herdrDelivery: HerdrDelivery,
75
94
  ) {}
76
95
 
77
96
  setUI(ui: UIContext): void {
@@ -81,7 +100,7 @@ export class UserAgentWidget {
81
100
  this.activityPreviews = new WeakMap();
82
101
  this.widgetRegistered = false;
83
102
  this.tui = undefined;
84
- this.detachConfirmation.cancel();
103
+ this.confirmation.cancel();
85
104
  this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
86
105
  }
87
106
 
@@ -90,7 +109,22 @@ export class UserAgentWidget {
90
109
  resultMessage: AgentResultMessage,
91
110
  options: { squashable: boolean },
92
111
  ): void {
93
- this.completedAgents.push({
112
+ this.snapshot(agent, {
113
+ ok: resultMessage.details.ok,
114
+ pendingSquashMessage: options.squashable ? resultMessage : undefined,
115
+ });
116
+ }
117
+
118
+ /** The row for an agent dispatched straight into a herdr pane: nothing ran here, so nothing squashes. */
119
+ addHerdrDispatch(agent: RunningAgent, paneId: string): void {
120
+ this.snapshot(agent, { ok: true, herdrPane: paneId });
121
+ }
122
+
123
+ private snapshot(
124
+ agent: RunningAgent,
125
+ outcome: { ok: boolean; pendingSquashMessage?: AgentResultMessage; herdrPane?: string },
126
+ ): CompletedAgent {
127
+ const completed: CompletedAgent = {
94
128
  id: agent.id,
95
129
  sessionId: agent.sessionId,
96
130
  command: agent.command,
@@ -98,8 +132,8 @@ export class UserAgentWidget {
98
132
  task: agent.task,
99
133
  dispatchBaseFingerprint: agent.dispatchBaseFingerprint,
100
134
  mainContextState: agent.mainContextState,
101
- pendingSquashMessage: options.squashable ? resultMessage : undefined,
102
- ok: resultMessage.details.ok,
135
+ pendingSquashMessage: outcome.pendingSquashMessage,
136
+ ok: outcome.ok,
103
137
  responseText: agent.responseText,
104
138
  messages: structuredClone(transcriptMessages(agent)),
105
139
  error: agent.error,
@@ -108,8 +142,11 @@ export class UserAgentWidget {
108
142
  toolUses: agent.toolUses,
109
143
  turnCount: agent.turnCount,
110
144
  contextPercent: agent.session?.getContextUsage()?.percent ?? undefined,
111
- });
145
+ herdrPane: outcome.herdrPane,
146
+ };
147
+ this.completedAgents.push(completed);
112
148
  this.update();
149
+ return completed;
113
150
  }
114
151
 
115
152
  ensureTimer(): void {
@@ -166,7 +203,7 @@ export class UserAgentWidget {
166
203
  this.tui = undefined;
167
204
  this.active = false;
168
205
  this.activityPreviews = new WeakMap();
169
- this.detachConfirmation.cancel();
206
+ this.confirmation.cancel();
170
207
  }
171
208
 
172
209
  private clear(): void {
@@ -181,7 +218,7 @@ export class UserAgentWidget {
181
218
  }
182
219
  this.active = false;
183
220
  this.selectedIndex = 0;
184
- this.detachConfirmation.cancel();
221
+ this.confirmation.cancel();
185
222
  }
186
223
 
187
224
  private runningAgentsForWidget(): RunningAgent[] {
@@ -225,13 +262,13 @@ export class UserAgentWidget {
225
262
  }
226
263
 
227
264
  if (matchesKey(data, "down")) {
228
- this.detachConfirmation.cancel();
265
+ this.confirmation.cancel();
229
266
  this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
230
267
  this.update();
231
268
  return { consume: true };
232
269
  }
233
270
  if (matchesKey(data, "up")) {
234
- this.detachConfirmation.cancel();
271
+ this.confirmation.cancel();
235
272
  if (this.selectedIndex === 0) {
236
273
  this.deactivate();
237
274
  return { consume: true };
@@ -246,20 +283,44 @@ export class UserAgentWidget {
246
283
  }
247
284
 
248
285
  const selected = entries[this.selectedIndex];
286
+ if (!matchesKey(data, "d") && !matchesKey(data, "r")) this.confirmation.cancel();
249
287
  if (
250
288
  matchesKey(data, "ctrl+x") &&
251
289
  selected?.kind === "running" &&
252
290
  isLiveAgent(selected.agent)
253
291
  ) {
254
- this.detachConfirmation.cancel();
255
292
  this.interruptRunning(selected.agent);
256
293
  return { consume: true };
257
294
  }
258
295
  if (matchesKey(data, "enter")) {
259
- this.detachConfirmation.cancel();
260
296
  if (selected) this.openSelected(selected.agent);
261
297
  return { consume: true };
262
298
  }
299
+ if (matchesKey(data, "c") && selected) {
300
+ const text =
301
+ isRunningAgent(selected.agent) || selected.agent.ok
302
+ ? selected.agent.responseText
303
+ : (selected.agent.error ?? "");
304
+ this.copyToClipboard(selected.agent.id, text, "response");
305
+ return { consume: true };
306
+ }
307
+ if (matchesKey(data, "i") && selected) {
308
+ this.copyToClipboard(selected.agent.id, selected.agent.sessionId, "session-id");
309
+ return { consume: true };
310
+ }
311
+ if (matchesKey(data, "s")) {
312
+ if (selected && this.canSquashMainContext(selected.agent.id))
313
+ this.squashMainContext(selected.agent.id);
314
+ return { consume: true };
315
+ }
316
+ if (matchesKey(data, "r")) {
317
+ this.requestRebase(selected);
318
+ return { consume: true };
319
+ }
320
+ if (matchesKey(data, "h")) {
321
+ if (selected) this.requestHerdr(selected.agent.id);
322
+ return { consume: true };
323
+ }
263
324
  if (matchesKey(data, "d") && selected) {
264
325
  if (!this.confirmDetach(selected.agent)) return { consume: true };
265
326
  if (selected.kind === "running") this.closeRunning(selected.agent);
@@ -272,15 +333,116 @@ export class UserAgentWidget {
272
333
  }
273
334
 
274
335
  private confirmDetach(target: ViewableAgent): boolean {
275
- const confirmed = this.detachConfirmation.press(target);
336
+ const confirmed = this.confirmation.press(`detach:${target.id}`);
276
337
  if (!confirmed) this.update();
277
338
  return confirmed;
278
339
  }
279
340
 
341
+ private requestRebase(selected: UserAgentWidgetEntry | undefined): void {
342
+ if (!selected) return;
343
+ const agentId = selected.agent.id;
344
+ const reason = this.rebaseBlockReason(agentId);
345
+ if (reason) {
346
+ this.confirmation.cancel();
347
+ this.showWarning(agentId, `Can't rebase: ${reason}`);
348
+ return;
349
+ }
350
+ if (!this.deliverableAgent(agentId)) {
351
+ this.confirmation.cancel();
352
+ return;
353
+ }
354
+ if (
355
+ this.rebaseDetachCount(agentId) > 0 &&
356
+ !this.confirmation.press(`rebase:${agentId}`)
357
+ ) {
358
+ this.update();
359
+ return;
360
+ }
361
+ this.rebaseMainContext(agentId);
362
+ }
363
+
364
+ private requestHerdr(agentId: string): void {
365
+ this.openInHerdr(agentId).catch((error) =>
366
+ this.showWarning(agentId, `Can't open in herdr: ${errorMessage(error)}`),
367
+ );
368
+ }
369
+
370
+ /** Hand the agent's session to a Pi in a new herdr pane, then return focus to the editor. */
371
+ async openInHerdr(agentId: string): Promise<void> {
372
+ const reason = this.herdrBlockReason(agentId);
373
+ if (reason) throw new Error(reason);
374
+ this.herdrPending = agentId;
375
+ this.update();
376
+ try {
377
+ // The pane comes first: a herdr failure here leaves the agent untouched. Pi starts in it
378
+ // only after the session is free here, so the file has one writer at a time.
379
+ const target = this.idleAgent(agentId) ?? this.completedAgents.find((agent) => agent.id === agentId);
380
+ if (!target) throw new Error("the agent has no row anymore");
381
+ const pane = await this.herdrDelivery.split(target);
382
+ const snapshot = await this.releaseForHerdr(agentId);
383
+ await pane.start();
384
+ snapshot.herdrPane = pane.paneId;
385
+ logSteering(agentId, "opened-in-herdr", { paneId: pane.paneId });
386
+ } finally {
387
+ this.herdrPending = undefined;
388
+ this.update();
389
+ }
390
+ this.deactivate();
391
+ }
392
+
393
+ /** Why the agent cannot open in a herdr pane right now; undefined when it can. */
394
+ private herdrBlockReason(agentId: string): string | undefined {
395
+ if (!this.herdrDelivery.available()) return "Pi is not running inside a herdr pane";
396
+ if (this.herdrPending === agentId) return "a herdr pane is already opening";
397
+ const completed = this.completedAgents.find((agent) => agent.id === agentId);
398
+ if (completed?.herdrPane) return `already in ${herdrPaneLabel(completed.herdrPane)}`;
399
+ if (!completed && !this.idleAgent(agentId)) return "the agent is mid-turn (Ctrl+x interrupts it)";
400
+ return undefined;
401
+ }
402
+
403
+ /** End the agent's life here and keep its row as a snapshot; resolves once its session is disposed. */
404
+ private async releaseForHerdr(agentId: string): Promise<CompletedAgent> {
405
+ const idleAgent = this.idleAgent(agentId);
406
+ if (idleAgent) {
407
+ idleAgent.status = "posted";
408
+ this.retireRunning(idleAgent);
409
+ this.snapshot(idleAgent, { ok: true, pendingSquashMessage: idleAgent.pendingSquashMessage });
410
+ }
411
+ // The lifecycle disposes the child session asynchronously; another Pi may open the file only after that.
412
+ await [...this.runningAgents].find((agent) => agent.id === agentId)?.finished;
413
+ return this.completedAgents.find((agent) => agent.id === agentId)!;
414
+ }
415
+
416
+ private copyToClipboard(agentId: string, text: string, target: CopyTarget): void {
417
+ const child = childProcess.execFile("pbcopy", (error) => {
418
+ if (error) return;
419
+ this.copied = { agentId, target };
420
+ if (this.copiedTimer) clearTimeout(this.copiedTimer);
421
+ this.update();
422
+ this.copiedTimer = setTimeout(() => {
423
+ this.copied = undefined;
424
+ this.copiedTimer = undefined;
425
+ this.update();
426
+ }, COPY_FEEDBACK_MS);
427
+ });
428
+ child.stdin?.end(text);
429
+ }
430
+
431
+ private showWarning(agentId: string, text: string): void {
432
+ this.warning = { agentId, text };
433
+ if (this.warningTimer) clearTimeout(this.warningTimer);
434
+ this.warningTimer = setTimeout(() => {
435
+ this.warning = undefined;
436
+ this.warningTimer = undefined;
437
+ this.update();
438
+ }, CONFIRMATION_WINDOW_MS);
439
+ this.update();
440
+ }
441
+
280
442
  private deactivate(): void {
281
443
  this.active = false;
282
444
  this.selectedIndex = 0;
283
- this.detachConfirmation.cancel();
445
+ this.confirmation.cancel();
284
446
  this.update();
285
447
  }
286
448
 
@@ -316,21 +478,7 @@ export class UserAgentWidget {
316
478
  void ui
317
479
  .custom<AgentViewerAction>(
318
480
  (tui, theme, _keybindings, done) =>
319
- new AgentViewer(
320
- tui,
321
- agent,
322
- theme,
323
- done,
324
- () => this.canSquashMainContext(agent.id),
325
- () => this.squashMainContext(agent.id),
326
- () => this.canRebaseMainContext(agent.id),
327
- () => this.rebaseMainContext(agent.id),
328
- () => this.rebaseBlockReason(agent.id),
329
- () => this.rebaseDetachCount(agent.id),
330
- () => {
331
- if (isRunningAgent(agent)) this.interruptRunning(agent);
332
- },
333
- ),
481
+ new AgentViewer(tui, agent, theme, done, this.viewerActions(agent)),
334
482
  { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" } },
335
483
  )
336
484
  .then(
@@ -339,13 +487,34 @@ export class UserAgentWidget {
339
487
  );
340
488
  }
341
489
 
490
+ private viewerActions(agent: ViewableAgent): AgentViewerActions {
491
+ return {
492
+ canSquashMainContext: () => this.canSquashMainContext(agent.id),
493
+ squashMainContext: () => this.squashMainContext(agent.id),
494
+ canRebaseMainContext: () => this.canRebaseMainContext(agent.id),
495
+ rebaseMainContext: () => this.rebaseMainContext(agent.id),
496
+ rebaseBlockReason: () => this.rebaseBlockReason(agent.id),
497
+ rebaseDetachCount: () => this.rebaseDetachCount(agent.id),
498
+ interrupt: () => {
499
+ if (isRunningAgent(agent)) this.interruptRunning(agent);
500
+ },
501
+ herdrBlockReason: () => this.herdrBlockReason(agent.id),
502
+ openInHerdr: () => this.openInHerdr(agent.id),
503
+ };
504
+ }
505
+
342
506
  /** Detaching ends the live session and leaves its session file on disk, untouched. */
343
507
  private closeRunning(agent: RunningAgent): void {
508
+ this.retireRunning(agent);
509
+ this.announceDetached(agent.sessionId);
510
+ this.update();
511
+ }
512
+
513
+ /** End a live agent's session here; its row and any breadcrumb are the caller's business. */
514
+ private retireRunning(agent: RunningAgent): void {
344
515
  agent.aborted = true;
345
516
  void agent.session?.abort();
346
517
  agent.retire?.();
347
- this.announceDetached(agent.sessionId);
348
- this.update();
349
518
  }
350
519
 
351
520
  private interruptRunning(agent: RunningAgent): void {
@@ -511,24 +680,62 @@ export class UserAgentWidget {
511
680
  } else {
512
681
  const selected = entries[this.selectedIndex];
513
682
  const confirmingDetach =
514
- selected !== undefined && this.detachConfirmation.isArmedOn(selected.agent);
515
- const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
516
- if (
517
- !confirmingDetach &&
518
- selected?.kind === "running" &&
519
- isLiveAgent(selected.agent)
520
- )
521
- segments.push(theme.fg("accent", "Ctrl+x interrupt"));
522
- segments.push(
523
- theme.fg(
524
- confirmingDetach ? "error" : "accent",
525
- confirmingDetach ? "d again to confirm" : "d detach",
526
- ),
527
- theme.fg("accent", "Esc back"),
528
- );
529
- lines.push(
530
- truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
531
- );
683
+ selected !== undefined &&
684
+ this.confirmation.isArmedOn(`detach:${selected.agent.id}`);
685
+ const confirmingRebase =
686
+ selected !== undefined &&
687
+ this.confirmation.isArmedOn(`rebase:${selected.agent.id}`);
688
+ if (confirmingRebase && selected) {
689
+ const detachCount = this.rebaseDetachCount(selected.agent.id);
690
+ lines.push(
691
+ truncateToWidth(
692
+ ` ${theme.fg(
693
+ "warning",
694
+ `⚠ Rebase will detach ${detachCount} other agent session${detachCount === 1 ? "" : "s"}. r again to confirm`,
695
+ )}`,
696
+ width,
697
+ ),
698
+ );
699
+ } else if (selected && this.herdrPending === selected.agent.id) {
700
+ lines.push(truncateToWidth(` ${theme.fg("accent", "⧉ Opening a herdr pane…")}`, width));
701
+ } else if (selected && this.warning?.agentId === selected.agent.id) {
702
+ lines.push(truncateToWidth(` ${theme.fg("warning", `⚠ ${this.warning.text}`)}`, width));
703
+ } else {
704
+ const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
705
+ if (!confirmingDetach)
706
+ segments.push(
707
+ this.copied?.agentId === selected?.agent.id &&
708
+ this.copied.target === "response"
709
+ ? theme.fg("success", "✓ copied")
710
+ : theme.fg("accent", "c copy"),
711
+ this.copied?.agentId === selected?.agent.id &&
712
+ this.copied.target === "session-id"
713
+ ? theme.fg("success", "✓ copied ID")
714
+ : theme.fg("accent", "i ID"),
715
+ );
716
+ if (!confirmingDetach && selected && this.canSquashMainContext(selected.agent.id))
717
+ segments.push(theme.fg("accent", "s squash"));
718
+ if (!confirmingDetach && selected && this.canRebaseMainContext(selected.agent.id))
719
+ segments.push(theme.fg("accent", "r rebase"));
720
+ if (!confirmingDetach && selected && this.herdrBlockReason(selected.agent.id) === undefined)
721
+ segments.push(theme.fg("accent", "h herdr"));
722
+ if (
723
+ !confirmingDetach &&
724
+ selected?.kind === "running" &&
725
+ isLiveAgent(selected.agent)
726
+ )
727
+ segments.push(theme.fg("accent", "Ctrl+x interrupt"));
728
+ segments.push(
729
+ theme.fg(
730
+ confirmingDetach ? "error" : "accent",
731
+ confirmingDetach ? "d again to confirm" : "d detach",
732
+ ),
733
+ theme.fg("accent", "Esc back"),
734
+ );
735
+ lines.push(
736
+ truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
737
+ );
738
+ }
532
739
  }
533
740
 
534
741
  const maxEntries = Math.max(1, Math.floor((MAX_WIDGET_LINES - lines.length) / 2));
@@ -567,31 +774,47 @@ export class UserAgentWidget {
567
774
  ): string[] {
568
775
  const running = isRunningAgent(agent);
569
776
  const inFlight = isLiveAgent(agent);
777
+ const herdrPane = running ? undefined : agent.herdrPane;
570
778
  const ok = running ? agent.error === undefined : agent.ok;
779
+ const status = inFlight
780
+ ? "running"
781
+ : running
782
+ ? "idle"
783
+ : herdrPane
784
+ ? herdrPaneLabel(herdrPane)
785
+ : ok
786
+ ? "completed"
787
+ : "failed";
571
788
  const marker = selected ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
572
789
  const icon = inFlight
573
790
  ? theme.fg("accent", SPINNER[this.frame % SPINNER.length])
574
- : ok
575
- ? theme.fg("success", "✓")
576
- : theme.fg("error", "✗");
577
- const parts = inFlight
578
- ? [agent.modelLabel, contextLabel(agent.inheritedContext)]
579
- : [agent.modelLabel];
791
+ : running
792
+ ? theme.fg("muted", "◉")
793
+ : herdrPane
794
+ ? theme.fg("muted", "⧉")
795
+ : ok
796
+ ? theme.fg("success", "✓")
797
+ : theme.fg("error", "✗");
798
+ const parts = [agent.modelLabel, status];
799
+ if (inFlight) parts.push(contextLabel(agent.inheritedContext));
580
800
  const mainContext = mainContextLabel(agent.mainContextState);
581
801
  if (mainContext) parts.push(mainContext);
582
802
  if (agent.turnCount > 0) parts.push(formatTurns(agent.turnCount));
583
803
  if (agent.toolUses > 0) parts.push(formatToolUses(agent.toolUses));
584
- parts.push(
585
- formatMs(
586
- running ? (agent.completedAt ?? Date.now()) - agent.turnStartedAt : agent.durationMs,
587
- ),
588
- );
804
+ // An agent born in a herdr pane never ran here; a duration would be noise.
805
+ if (!herdrPane || agent.turnCount > 0)
806
+ parts.push(
807
+ formatMs(
808
+ running ? (agent.completedAt ?? Date.now()) - agent.turnStartedAt : agent.durationMs,
809
+ ),
810
+ );
589
811
 
590
812
  const header = [
591
813
  theme.fg("dim", connector),
592
814
  marker,
593
815
  icon,
594
816
  theme.bold(`/${agent.command}`),
817
+ selected ? theme.fg("dim", `ID ${agent.sessionId.slice(0, 8)}`) : "",
595
818
  renderAgentContextMeter(agent, theme),
596
819
  theme.fg("muted", truncatePlain(agent.task, 52)),
597
820
  theme.fg("dim", "·"),
@@ -611,19 +834,31 @@ export class UserAgentWidget {
611
834
  const running = isRunningAgent(agent);
612
835
  const source = isLiveAgent(agent) ? agent.latestFinalizedMessage : agent.responseText;
613
836
  const status = running ? agent.status : agent.ok;
837
+ const herdrPane = running ? undefined : agent.herdrPane;
614
838
  const cached = this.activityPreviews.get(agent);
615
839
  if (
616
840
  cached && cached.source === source && cached.status === status &&
841
+ cached.herdrPane === herdrPane &&
617
842
  cached.error === agent.error && cached.width === width && cached.theme === theme
618
843
  ) return cached.text;
619
844
  const description = running
620
845
  ? describeActivity(agent)
621
846
  : {
622
- text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
847
+ text: !agent.ok
848
+ ? `Error: ${agent.error ?? "unknown"}`
849
+ : agent.responseText || (herdrPane ? `runs in ${herdrPaneLabel(herdrPane)}` : ""),
623
850
  truncation: "tail" as const,
624
851
  };
625
852
  const text = renderActivity(description, width, theme);
626
- this.activityPreviews.set(agent, { source, status, error: agent.error, width, theme, text });
853
+ this.activityPreviews.set(agent, {
854
+ source,
855
+ status,
856
+ herdrPane,
857
+ error: agent.error,
858
+ width,
859
+ theme,
860
+ text,
861
+ });
627
862
  return text;
628
863
  }
629
864
  }