@signalridge/pi-subagents 1.11.1 → 1.13.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.
@@ -11,17 +11,31 @@
11
11
  * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
12
  */
13
13
 
14
- import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
- import { borderedComponent } from "@signalridge/pi-ui";
14
+ import { type Component, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { borderedComponent, sanitizeDisplayText } from "@signalridge/pi-ui";
16
16
  import { renderAgentName } from "../agent-color.js";
17
17
  import type { AgentManager } from "../agent-manager.js";
18
18
  import type { AgentRecord } from "../types.js";
19
19
  import { getLifetimeTotal } from "../usage.js";
20
20
  import { type AgentActivity, type Theme } from "./agent-display.js";
21
- import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
22
- import { sanitizeDisplayText } from "./safe-text.js";
21
+ import { ConversationViewer, getConversationOverlayOptions, getConversationViewerMode } from "./conversation-viewer.js";
22
+ import { type OwnerGroup, OwnerGroupViewer, workflowGroupId } from "./owner-group.js";
23
23
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
24
24
  import type { ViewerKeybindings } from "./viewer-keys.js";
25
+ import type { WorkflowUiClient } from "./workflow-ui-client.js";
26
+
27
+ /** Immutable selection, including the attempt generation on a reusable native thread. */
28
+ export function captureAgentSelection(manager: AgentManager, record: AgentRecord, isCurrent: () => boolean = () => true) {
29
+ const session = record.session;
30
+ const owner = record.owner ? { ...record.owner } : undefined;
31
+ const snapshot = { ...record, owner, lifetimeUsage: { ...record.lifetimeUsage } };
32
+ const current = (): AgentRecord | undefined => isCurrent() ? manager.listAgentsMutable().find(candidate =>
33
+ candidate.id === snapshot.id && candidate.session === session && !candidate.detached &&
34
+ candidate.owner?.extension === owner?.extension && candidate.owner?.runId === owner?.runId &&
35
+ candidate.owner?.nodeId === owner?.nodeId && candidate.owner?.attemptId === owner?.attemptId) : undefined;
36
+ return { snapshot, session, owner, current };
37
+ }
38
+ export type AgentSelection = ReturnType<typeof captureAgentSelection>;
25
39
 
26
40
  /** Widget key for the below-editor fleet list. */
27
41
  const FLEET_KEY = "fleet";
@@ -48,9 +62,10 @@ export type FleetUICtx = {
48
62
  ): Promise<T>;
49
63
  };
50
64
 
51
- type MainEntry = { kind: "main" };
52
- type AgentEntry = { kind: "agent"; record: AgentRecord };
53
- type FleetEntry = MainEntry | AgentEntry;
65
+ type MainEntry = { kind: "main"; id: string };
66
+ type AgentEntry = { kind: "agent"; id: string; record: AgentRecord };
67
+ type GroupEntry = { kind: "group"; id: string; group: OwnerGroup };
68
+ type FleetEntry = MainEntry | AgentEntry | GroupEntry;
54
69
 
55
70
  /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
56
71
  export function formatFleetElapsed(ms: number): string {
@@ -91,15 +106,44 @@ export class FleetList {
91
106
  private active = false;
92
107
  /** 0 = `main`, 1..N = subagents. */
93
108
  private selectedIndex = 0;
109
+ private selectedId = "main";
110
+ private overlayGeneration = 0;
94
111
  /** Set while a conversation overlay is open; calling it closes the overlay. */
95
112
  private viewerClose: (() => void) | undefined;
96
113
  private viewingAgentId: string | undefined;
114
+ private workflowClient: WorkflowUiClient | undefined;
115
+ private externalOpening = false;
116
+ private externalViewingId: string | undefined;
117
+ private externalTimer: ReturnType<typeof setTimeout> | undefined;
118
+
119
+ setWorkflowClient(client: WorkflowUiClient | undefined): void {
120
+ this.workflowClient = client;
121
+ this.update();
122
+ }
123
+
124
+ /** Shared exact-attempt opener for user selections and the workflow UI endpoint. */
125
+ canOpenConversation(selection: AgentSelection): boolean {
126
+ return !!this.ui && !this.viewerClose && !!selection.session && !!selection.current();
127
+ }
128
+
129
+ openConversation(selection: AgentSelection, deferConstruction = false): boolean {
130
+ if (!this.canOpenConversation(selection)) return false;
131
+ this.viewingAgentId = selection.snapshot.id;
132
+ const mode = this.conversationMode();
133
+ void this.openOverlay((tui, theme, keys, done) => {
134
+ if (!selection.current()) { done(undefined); return { render: () => [], invalidate: () => {} }; }
135
+ return this.conversation(selection, tui, theme, keys, done, undefined, mode);
136
+ }, mode, deferConstruction);
137
+ return true;
138
+ }
97
139
 
98
140
  constructor(
99
141
  private manager: AgentManager,
100
142
  private agentActivity: Map<string, AgentActivity>,
101
143
  ) {}
102
144
 
145
+ conversationMode(): "fullscreen" | "overlay" { return getConversationViewerMode(this.tui); }
146
+
103
147
  // ---- Lifecycle ----
104
148
 
105
149
  setEnabled(enabled: boolean): void {
@@ -112,7 +156,7 @@ export class FleetList {
112
156
  /** Capture the UI context and (re)register the global input handler. */
113
157
  setUICtx(ui: FleetUICtx): void {
114
158
  if (ui === this.ui) return;
115
- this.inputUnsub?.();
159
+ this.dispose();
116
160
  this.ui = ui;
117
161
  this.widgetRegistered = false;
118
162
  this.tui = undefined;
@@ -133,6 +177,12 @@ export class FleetList {
133
177
  }
134
178
 
135
179
  dispose(): void {
180
+ this.overlayGeneration++;
181
+ if (this.externalTimer) clearTimeout(this.externalTimer);
182
+ this.externalTimer = undefined;
183
+ this.externalOpening = false;
184
+ this.externalViewingId = undefined;
185
+ this.workflowClient = undefined;
136
186
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
137
187
  this.inputUnsub?.();
138
188
  this.inputUnsub = undefined;
@@ -149,7 +199,8 @@ export class FleetList {
149
199
  /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
150
200
  update(): void {
151
201
  if (!this.ui) return;
152
- const hasAgents = this.enabled && this.agentRecords().length > 0;
202
+ if (this.externalViewingId && !this.externalOpening && this.editorHasFocus()) this.externalViewingId = undefined;
203
+ const hasAgents = this.enabled && this.roster().length > 1;
153
204
 
154
205
  if (!hasAgents) {
155
206
  if (this.widgetRegistered) {
@@ -160,6 +211,7 @@ export class FleetList {
160
211
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
161
212
  this.active = false;
162
213
  this.selectedIndex = 0;
214
+ this.selectedId = "main";
163
215
  return;
164
216
  }
165
217
 
@@ -171,7 +223,8 @@ export class FleetList {
171
223
  this.tui = tui;
172
224
  return {
173
225
  render: (w: number) => this.renderBar(w, theme),
174
- invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
226
+ invalidate: () => { /* no render cache; mounting/focus remain owned */ },
227
+ dispose: () => { if (this.tui === tui) { this.widgetRegistered = false; this.tui = undefined; } },
175
228
  };
176
229
  }, { placement: "belowEditor" });
177
230
  this.widgetRegistered = true;
@@ -193,7 +246,7 @@ export class FleetList {
193
246
  private agentRecords(): AgentRecord[] {
194
247
  const now = Date.now();
195
248
  return this.manager.listAgentsMutable()
196
- .filter(a => !a.parentAgentId && a.session && (
249
+ .filter(a => !a.parentAgentId && !a.detached && !workflowGroupId(a) && a.session && (
197
250
  a.status === "running" || a.status === "queued"
198
251
  || a.id === this.viewingAgentId
199
252
  || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
@@ -201,14 +254,47 @@ export class FleetList {
201
254
  .sort((a, b) => a.startedAt - b.startedAt);
202
255
  }
203
256
 
257
+ private groups(): OwnerGroup[] {
258
+ const groups = new Map<string, OwnerGroup>();
259
+ for (const record of this.manager.listAgentsMutable()) {
260
+ if (record.parentAgentId || record.detached) continue;
261
+ const id = workflowGroupId(record);
262
+ if (!id) continue;
263
+ let group = groups.get(id);
264
+ if (!group) { group = { id, runId: record.owner!.runId, records: [] }; groups.set(id, group); }
265
+ group.records.push(record);
266
+ }
267
+ const retained = new Set([...groups.values()].map(group => group.runId));
268
+ if (this.externalViewingId) retained.add(this.externalViewingId);
269
+ for (const run of this.workflowClient?.runs(retained) ?? []) {
270
+ const id = JSON.stringify(["pi-workflows", run.runId]);
271
+ const group = groups.get(id) ?? { id, runId: run.runId, records: [] };
272
+ group.summary = run;
273
+ groups.set(id, group);
274
+ }
275
+ for (const group of groups.values()) group.records.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
276
+ return [...groups.values()];
277
+ }
278
+
204
279
  private roster(): FleetEntry[] {
205
- return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))];
280
+ return [
281
+ { kind: "main", id: "main" },
282
+ ...this.groups().map(group => ({ kind: "group" as const, id: `group:${group.id}`, group })),
283
+ ...this.agentRecords().map(record => ({ kind: "agent" as const, id: `agent:${record.id}`, record })),
284
+ ];
206
285
  }
207
286
 
208
- private clampSelection(): void {
209
- const max = this.roster().length - 1;
210
- if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
211
- if (this.selectedIndex < 0) this.selectedIndex = 0;
287
+ private clampSelection(roster = this.roster()): void {
288
+ const index = roster.findIndex(entry => entry.id === this.selectedId);
289
+ // Missing identity returns explicitly to main, never to a different child.
290
+ this.selectedIndex = index >= 0 ? index : 0;
291
+ this.selectedId = roster[this.selectedIndex].id;
292
+ }
293
+
294
+ private select(index: number): void {
295
+ const roster = this.roster();
296
+ this.selectedIndex = Math.max(0, Math.min(roster.length - 1, index));
297
+ this.selectedId = roster[this.selectedIndex].id;
212
298
  }
213
299
 
214
300
  // ---- Key handling ----
@@ -221,38 +307,52 @@ export class FleetList {
221
307
  // tap would move/fire twice. Repeats still pass through for held-key nav.
222
308
  if (isKeyRelease(data)) return undefined;
223
309
  // While an overlay is open, let it own all input.
224
- if (this.viewerClose) return undefined;
310
+ if (this.viewerClose || this.externalOpening) return undefined;
225
311
  // Input listeners fire BEFORE the focused component, and dialogs
226
312
  // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
227
313
  // while getEditorText() still reads the detached — empty — editor. So when
228
314
  // anything but the editor owns the keyboard, stay out of its keys (#123).
229
315
  if (!this.editorHasFocus()) {
230
- if (this.active) this.deactivate();
316
+ if (this.active && !this.externalViewingId) this.deactivate();
231
317
  return undefined;
232
318
  }
319
+ this.externalViewingId = undefined;
320
+
321
+ if (!this.active && this.ui.getEditorText() === "" && matchesKey(data, "ctrl+b")) {
322
+ const candidates = this.manager.listAgentsMutable().filter(record => !record.detached && !record.parentAgentId &&
323
+ !record.owner && record.isBackground === false && record.status === "running");
324
+ const eligible = candidates.filter(record => this.manager.canHandoff(record.id));
325
+ if (eligible.length > 1) this.ui.notify("Multiple foreground agents; select a conversation and press b to hand off explicitly.", "info");
326
+ else if (eligible.length === 1) this.handoff(captureAgentSelection(this.manager, eligible[0]));
327
+ else if (candidates.length) this.ui.notify(this.manager.handoffUnavailableReason(candidates[0].id) ?? "No eligible foreground agent.", "warning");
328
+ else return undefined;
329
+ return { consume: true };
330
+ }
233
331
 
234
332
  if (!this.active) {
235
333
  // Activate: ↓ or ← at an empty prompt moves focus into the list.
236
334
  const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
237
- if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
335
+ if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
238
336
  this.active = true;
239
- this.selectedIndex = 0;
337
+ this.select(0);
240
338
  this.update();
241
339
  return { consume: true };
242
340
  }
243
341
  return undefined;
244
342
  }
245
343
 
344
+ // Reconcile identity before both navigation and opening, even between ticks.
345
+ this.clampSelection();
246
346
  // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
247
347
  if (matchesKey(data, "down")) {
248
348
  const max = this.roster().length - 1;
249
- this.selectedIndex = Math.min(max, this.selectedIndex + 1);
349
+ this.select(Math.min(max, this.selectedIndex + 1));
250
350
  this.update();
251
351
  return { consume: true };
252
352
  }
253
353
  if (matchesKey(data, "up")) {
254
354
  if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
255
- this.selectedIndex -= 1;
355
+ this.select(this.selectedIndex - 1);
256
356
  this.update();
257
357
  return { consume: true };
258
358
  }
@@ -265,64 +365,129 @@ export class FleetList {
265
365
  }
266
366
 
267
367
  /**
268
- * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
269
- * subclass (CustomEditor) while every dialog/selector is not, and the loader
270
- * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
271
- * check. `focusedComponent` is TUI-private (no public accessor), hence the
272
- * best-effort peek: unknowable focus (no tui seen yet, nothing focused)
273
- * counts as the editor so activation keeps working.
368
+ * True when pi's prompt editor owns the keyboard. The editor is pluggable
369
+ * (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
370
+ * `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
371
+ * every editor and none of pi's dialogs, selectors or overlays implement.
372
+ * Use the public structural focus getter (not present in every TUI interface
373
+ * declaration). Unknown focus retains the initial editor fallback.
274
374
  */
275
375
  private editorHasFocus(): boolean {
276
- const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
277
- return focused == null || focused instanceof Editor;
376
+ const focusHost = this.tui as { getFocusedComponent?: () => unknown } | undefined;
377
+ const focused = typeof focusHost?.getFocusedComponent === "function" ? focusHost.getFocusedComponent() : undefined;
378
+ if (focused == null) return true;
379
+ const c = focused as { getText?: unknown; setText?: unknown };
380
+ return typeof c.getText === "function" && typeof c.setText === "function";
278
381
  }
279
382
 
280
383
  private deactivate(): void {
281
384
  this.active = false;
282
- this.selectedIndex = 0;
385
+ this.select(0);
283
386
  this.update();
284
387
  }
285
388
 
286
389
  private openSelected(): void {
287
- const entry = this.roster()[this.selectedIndex];
390
+ const roster = this.roster();
391
+ this.clampSelection(roster);
392
+ const entry = roster[this.selectedIndex];
288
393
  if (!entry || entry.kind === "main") {
289
394
  // `main` = return to the prompt; the native transcript is already shown.
290
395
  this.deactivate();
291
396
  return;
292
397
  }
293
- const record = entry.record;
294
398
  if (!this.ui) return;
399
+ if (entry.kind === "group") { this.openGroup(entry.group); return; }
400
+ const record = entry.record;
295
401
  if (!record.session) {
296
402
  this.ui.notify(`Agent is ${record.status}; no session available.`, "info");
297
403
  return;
298
404
  }
299
- const session = record.session;
300
- const activity = this.agentActivity.get(record.id);
301
- this.viewingAgentId = record.id;
405
+ this.openConversation(captureAgentSelection(this.manager, record));
406
+ }
302
407
 
303
- void this.ui.custom<undefined>(
304
- (tui, theme, keybindings, done) => {
305
- this.viewerClose = () => done(undefined);
306
- return borderedComponent(new ConversationViewer(
307
- tui,
308
- session,
309
- record,
310
- activity,
311
- theme,
312
- done,
313
- () => {
314
- if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info");
315
- },
316
- keybindings,
317
- (message: string) => this.manager.steer(record.id, message),
318
- () => this.manager.getRecord?.(record.id),
319
- ), (text) => theme.fg("borderAccent", text));
320
- },
321
- {
322
- overlay: true,
323
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
408
+ conversation(selection: AgentSelection, tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void,
409
+ notify = (message: string) => this.ui?.notify(message, "info"), mode: "fullscreen" | "overlay" = "overlay"): ConversationViewer {
410
+ const { snapshot: record, session, owner, current } = selection;
411
+ if (!session || !current()) throw new Error("Conversation selection is no longer active");
412
+ let last = record;
413
+ return new ConversationViewer(tui, session, record, this.agentActivity.get(record.id), theme, done,
414
+ () => { if (current() && this.manager.abort(record.id)) notify(`Stopped "${sanitizeDisplayText(record.description)}".`); },
415
+ keys,
416
+ message => current() ? this.manager.steerAccepted(record.id, message, { session, owner }) : Promise.resolve(false),
417
+ () => {
418
+ const fresh = current();
419
+ if (fresh) { last = { ...fresh, owner, lifetimeUsage: { ...fresh.lifetimeUsage } }; return fresh; }
420
+ return { ...last, detached: true };
324
421
  },
325
- ).then(() => this.clearViewer(), () => this.clearViewer());
422
+ { mode, canHandoff: () => !!current() && this.manager.canHandoff(record.id), onHandoff: () => this.handoff(selection) },
423
+ );
424
+ }
425
+
426
+ private handoff(selection: AgentSelection): boolean {
427
+ if (!selection.current()) return false;
428
+ const id = selection.snapshot.id;
429
+ if (!this.manager.handoff(id)) {
430
+ this.ui?.notify(this.manager.handoffUnavailableReason(id) ?? "Cannot hand off this agent.", "warning");
431
+ return false;
432
+ }
433
+ this.ui?.notify(`Agent ${sanitizeDisplayText(id)} continues in background.`, "info");
434
+ this.update();
435
+ return true;
436
+ }
437
+
438
+ private async openGroup(group: OwnerGroup): Promise<void> {
439
+ const client = this.workflowClient;
440
+ const ui = this.ui;
441
+ const generation = this.overlayGeneration;
442
+ if (group.summary && client) {
443
+ this.externalOpening = true;
444
+ try {
445
+ await client.open(group.summary);
446
+ if (generation === this.overlayGeneration && ui === this.ui) this.externalViewingId = group.runId;
447
+ // Admission precedes the peer's dialog factory. Yield through its next
448
+ // macrotask, then rely on public TUI focus for the entire interaction.
449
+ if (generation === this.overlayGeneration && ui === this.ui) {
450
+ this.externalTimer = setTimeout(() => { this.externalTimer = undefined; this.externalOpening = false; this.update(); }, 0);
451
+ }
452
+ return;
453
+ } catch {
454
+ if (generation !== this.overlayGeneration || ui !== this.ui) return;
455
+ ui?.notify("Workflow inspector unavailable; showing retained children only (no workflow controls).", "warning");
456
+ } finally {
457
+ if (!this.externalTimer && generation === this.overlayGeneration) this.externalOpening = false;
458
+ }
459
+ }
460
+ if (generation !== this.overlayGeneration || ui !== this.ui) return;
461
+ if (!group.records.length) { ui?.notify("No retained child conversations for this workflow.", "info"); return; }
462
+ void this.openOverlay((tui, theme, keys, done) => new OwnerGroupViewer(tui, theme, group.runId,
463
+ () => this.groups().find(candidate => candidate.id === group.id)?.records ?? [],
464
+ (record, back) => this.conversation(captureAgentSelection(this.manager, record), tui, theme, keys, back),
465
+ () => done(undefined), keys));
466
+ }
467
+
468
+ private async openOverlay(factory: (tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void) => Component,
469
+ mode: "fullscreen" | "overlay" = "overlay", deferConstruction = false): Promise<void> {
470
+ const ui = this.ui;
471
+ if (!ui) return;
472
+ const generation = ++this.overlayGeneration;
473
+ this.viewerClose = () => {}; // reserve input during asynchronous construction
474
+ try {
475
+ if (deferConstruction) await Promise.resolve();
476
+ if (generation !== this.overlayGeneration || ui !== this.ui) return;
477
+ await ui.custom<undefined>((tui, theme, keys, done) => {
478
+ if (generation !== this.overlayGeneration || ui !== this.ui) {
479
+ done(undefined);
480
+ return { render: () => [], invalidate: () => {} };
481
+ }
482
+ this.viewerClose = () => done(undefined);
483
+ const component = factory(tui, theme, keys, done);
484
+ return mode === "fullscreen" ? component : borderedComponent(component, text => theme.fg("borderAccent", text));
485
+ }, { overlay: true, overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }) });
486
+ } catch {
487
+ if (generation === this.overlayGeneration) ui.notify("Cannot open conversation; try again.", "error");
488
+ } finally {
489
+ if (generation === this.overlayGeneration) this.clearViewer();
490
+ }
326
491
  }
327
492
 
328
493
  /** Reset overlay state and return to the list (on close, auto-close, or error). */
@@ -333,7 +498,7 @@ export class FleetList {
333
498
  // update()'s clamp to settle.
334
499
  if (this.viewingAgentId) {
335
500
  const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
336
- if (idx >= 0) this.selectedIndex = idx;
501
+ if (idx >= 0) this.select(idx);
337
502
  }
338
503
  this.viewerClose = undefined;
339
504
  this.viewingAgentId = undefined;
@@ -343,9 +508,12 @@ export class FleetList {
343
508
  // ---- Rendering ----
344
509
 
345
510
  private renderBar(width: number, theme: Theme): string[] {
346
- const agents = this.roster().slice(1) as AgentEntry[];
511
+ if (width <= 0) return [];
512
+ const roster = this.roster();
513
+ this.clampSelection(roster);
514
+ const agents = roster.slice(1);
347
515
  if (agents.length === 0) return [];
348
- const sel = Math.min(this.selectedIndex, agents.length);
516
+ const sel = this.selectedIndex;
349
517
 
350
518
  // Key names read as proper nouns, the verbs after them stay lowercase, and
351
519
  // segments are separated by " · " — the same shape every hint line in the
@@ -353,9 +521,13 @@ export class FleetList {
353
521
  const hint = this.active
354
522
  ? "↑↓ navigate · Enter view · Esc back"
355
523
  : "Esc interrupt · ← agents · ↓ manage";
524
+ const height = Math.max(0, Math.floor((this.tui?.terminal.rows ?? 24) / 2));
525
+ if (height === 0) return [];
356
526
  const lines: string[] = [];
357
- lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width));
358
- lines.push("");
527
+ if (height >= 7) {
528
+ lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width));
529
+ lines.push("");
530
+ }
359
531
 
360
532
  const mainColor = sel === 0 ? "accent" : "dim";
361
533
  const main = sel === 0
@@ -365,18 +537,38 @@ export class FleetList {
365
537
  const mainMark = theme.fg(mainColor, getAgentStatusMark("running"));
366
538
  lines.push(truncateToWidth(` ${mainMark} ${main} ${theme.fg("dim", "·")} ${theme.fg("dim", "current")}`, width));
367
539
 
368
- const visible = Math.min(MAX_AGENT_ROWS, agents.length);
540
+ const rowBudget = Math.max(1, Math.floor((this.tui?.terminal.rows ?? 24) / 2) - 7);
541
+ const visible = Math.min(MAX_AGENT_ROWS, rowBudget, agents.length);
369
542
  const selAgent = Math.max(0, sel - 1);
370
543
  const start = selAgent < visible ? 0 : selAgent - visible + 1;
371
544
  const hiddenBelow = agents.length - (start + visible);
372
545
 
373
546
  if (start > 0) lines.push(rightAlign("", theme.fg("dim", `${start} more above`), width));
374
547
  for (let a = start; a < start + visible; a++) {
375
- lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
548
+ const entry = agents[a];
549
+ if (entry.kind === "agent") lines.push(this.renderAgentRow(a + 1, sel, entry.record, width, theme));
550
+ else if (entry.kind === "group") {
551
+ const run = entry.group.summary;
552
+ if (run) {
553
+ const selected = a + 1 === sel;
554
+ const status = run.status === "failed" ? "error" : run.status === "pending" || run.status === "paused" ? "queued" :
555
+ run.status === "stopped" || run.status === "interrupted" ? "stopped" : run.status === "completed" ? "completed" : "running";
556
+ const color = status === "error" ? "error" : status === "stopped" ? "warning" : selected ? "accent" : "dim";
557
+ const name = sanitizeDisplayText(run.name);
558
+ const phase = run.currentPhase ? ` · ${sanitizeDisplayText(run.currentPhase)}` : "";
559
+ const left = ` ${theme.fg(color, getAgentStatusMark(status))} ${theme.fg(selected ? "accent" : "muted", name)} · ${theme.fg(color, run.status)}${phase} · ${run.runningCalls}/${run.callCount} running · ${run.completedCalls} done · ${run.failedCalls} failed${run.restored ? " · restored" : ""}`;
560
+ const right = `${formatFleetElapsed(run.elapsedMs)}${run.tokenCount === undefined ? "" : ` · ${formatFleetTokens(run.tokenCount)}`}`;
561
+ lines.push(rightAlign(left, theme.fg("dim", right), width));
562
+ } else {
563
+ const label = ` ▸ Workflow ${sanitizeDisplayText(entry.group.runId)} · ${entry.group.records.length} retained children`;
564
+ lines.push(truncateToWidth(theme.fg(a + 1 === sel ? "accent" : "muted", label), width));
565
+ }
566
+ }
376
567
  }
377
568
  if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
378
569
 
379
- return lines;
570
+ lines.push(""); // breathing room below the roster, including narrow terminals
571
+ return [...lines.slice(0, height - 1), ""].map(line => truncateToWidth(line, Math.max(0, width)));
380
572
  }
381
573
 
382
574
  private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
@@ -0,0 +1,110 @@
1
+ /** Observation-only retained workflow children; no workflow engine/RPC dependency. */
2
+ import { type Component, type Focusable, isKeyRelease, matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { WorkflowUiRunSummary } from "@signalridge/pi-subagents-protocol";
4
+ import { sanitizeDisplayText } from "@signalridge/pi-ui";
5
+ import type { AgentRecord } from "../types.js";
6
+ import type { Theme } from "./agent-display.js";
7
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
8
+
9
+ type DisposableComponent = Component & Partial<Focusable> & { dispose?(): void };
10
+
11
+ export type OwnerGroup = { id: string; runId: string; records: AgentRecord[]; summary?: WorkflowUiRunSummary };
12
+
13
+ export function workflowGroupId(record: AgentRecord): string | undefined {
14
+ const owner = record.owner;
15
+ if (owner?.extension !== "pi-workflows" || typeof owner.runId !== "string" || !owner.runId.trim()) return undefined;
16
+ return JSON.stringify([owner.extension, owner.runId]);
17
+ }
18
+
19
+ /** A bounded child list that swaps its body for the existing conversation viewer. */
20
+ export class OwnerGroupViewer implements Component, Focusable {
21
+ private hasFocus = false;
22
+ get focused(): boolean { return this.hasFocus; }
23
+ set focused(value: boolean) {
24
+ this.hasFocus = value;
25
+ if (this.child && typeof this.child.focused === "boolean") this.child.focused = value;
26
+ }
27
+ private selectedId: string | undefined;
28
+ private initialized = false;
29
+ private child: DisposableComponent | undefined;
30
+ private closed = false;
31
+ private error: string | undefined;
32
+ private keys: ViewerKeys;
33
+
34
+ constructor(
35
+ private tui: TUI,
36
+ private theme: Theme,
37
+ private runId: string,
38
+ private records: () => AgentRecord[],
39
+ private open: (record: AgentRecord, done: () => void) => DisposableComponent,
40
+ private done: () => void,
41
+ keybindings?: ViewerKeybindings,
42
+ ) { this.keys = createViewerKeys(keybindings); }
43
+
44
+ private roster(): AgentRecord[] {
45
+ const records = this.records();
46
+ if (!this.initialized) { this.selectedId = records[0]?.id; this.initialized = true; }
47
+ else if (!records.some(record => record.id === this.selectedId)) this.selectedId = undefined;
48
+ return records;
49
+ }
50
+
51
+ handleInput(data: string): void {
52
+ if (this.closed || isKeyRelease(data)) return;
53
+ if (this.child) { this.child.handleInput?.(data); return; }
54
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) { this.done(); return; }
55
+ const records = this.roster();
56
+ const index = records.findIndex(record => record.id === this.selectedId);
57
+ const page = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
58
+ let next = index;
59
+ if (this.keys.scrollUp(data)) next--;
60
+ else if (this.keys.scrollDown(data)) next++;
61
+ else if (this.keys.pageUp(data)) next -= page;
62
+ else if (this.keys.pageDown(data)) next += page;
63
+ else if (matchesKey(data, "home")) next = 0;
64
+ else if (matchesKey(data, "end")) next = records.length - 1;
65
+ else if (matchesKey(data, "enter")) {
66
+ const record = records[index];
67
+ if (!record?.session) this.error = record ? "No session yet; child retained" : "Selection gone; use ↑↓";
68
+ else {
69
+ try {
70
+ this.child = this.open(record, () => {
71
+ this.child?.dispose?.();
72
+ this.child = undefined;
73
+ this.tui.requestRender();
74
+ });
75
+ if (typeof this.child.focused === "boolean") this.child.focused = this.focused;
76
+ this.error = undefined;
77
+ } catch { this.error = "Cannot open conversation; try again"; }
78
+ }
79
+ }
80
+ if (records.length && next !== index) this.selectedId = records[Math.max(0, Math.min(records.length - 1, next))].id;
81
+ this.tui.requestRender();
82
+ }
83
+
84
+ render(width: number): string[] {
85
+ if (this.child) return this.child.render(width);
86
+ const records = this.roster();
87
+ const selected = records.findIndex(record => record.id === this.selectedId);
88
+ // Reserve header, two controls rows, and the shared adapter's two borders.
89
+ const bodyRows = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
90
+ const start = Math.max(0, selected - bodyRows + 1);
91
+ const labels = records.map(record => sanitizeDisplayText(record.description));
92
+ const counts = new Map<string, number>();
93
+ for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
94
+ const lines = [this.theme.fg("accent", `Workflow ${sanitizeDisplayText(this.runId)} · ${records.length} retained children`)];
95
+ if (!records.length) lines.push("No retained children");
96
+ for (let index = start; index < Math.min(records.length, start + bodyRows); index++) {
97
+ const record = records[index];
98
+ const discriminator = (counts.get(labels[index]) ?? 0) > 1 ? ` [${sanitizeDisplayText(record.id)}]` : "";
99
+ // The discriminator leads the label so it survives narrow-width clipping.
100
+ const text = `${record.id === this.selectedId ? ">" : " "}${discriminator} ${labels[index]} · ${record.status}${record.session ? "" : " · no session yet"}`;
101
+ lines.push(this.theme.fg(record.id === this.selectedId ? "accent" : "muted", text));
102
+ }
103
+ lines.push(this.error ? this.theme.fg("error", this.error) : `${selected + 1}/${records.length} · ↑↓/PgUp/PgDn`);
104
+ lines.push(width >= 22 ? "Esc back · Enter view" : width >= 10 ? "Esc back ↵" : "Esc ↵");
105
+ return lines.map(line => truncateToWidth(line, Math.max(0, width)));
106
+ }
107
+
108
+ invalidate(): void { this.child?.invalidate(); }
109
+ dispose(): void { this.closed = true; this.child?.dispose?.(); this.child = undefined; }
110
+ }