@zachwill/pi-orchestrate 0.1.0 → 0.1.1

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.
@@ -6,7 +6,7 @@ import {
6
6
  type Theme,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import {
9
- Container,
9
+ Box,
10
10
  Markdown,
11
11
  Spacer,
12
12
  Text,
@@ -14,39 +14,40 @@ import {
14
14
  visibleWidth,
15
15
  type Component,
16
16
  } from "@earendil-works/pi-tui";
17
- import type { WaveDeliveryDetails } from "./delivery.js";
18
- import {
19
- isWorkerCompleteForWave,
20
- type WaveRecord,
21
- type WorkerOutcome,
22
- type WorkerRecord,
23
- type WorkerStatus,
24
- type WorkerUsage,
25
- } from "./domain.js";
17
+ import type { WorkerDeliveryDetails } from "./delivery.js";
18
+ import type { WorkerOutcome, WorkerRecord, WorkerStatus, WorkerUsage } from "./domain.js";
26
19
  import type { OrchestratorRuntime, RuntimeSnapshot } from "./runtime.js";
27
20
 
28
21
  export const ORCHESTRATION_PRESENTATION_KEY = "pi-orchestrate";
29
- export const MAX_RESULT_PREVIEW_LINES = 5;
22
+ export const MAX_RESULT_PREVIEW_LINES = 6;
30
23
  export const MAX_WIDGET_WORKERS = 8;
31
24
 
32
- export type PresentationRuntime = Pick<
33
- OrchestratorRuntime,
34
- "snapshot" | "subscribeState"
35
- >;
36
-
37
- interface SafeResult {
38
- readonly workerId: string;
39
- readonly worker: string;
40
- readonly title: string;
41
- readonly status: string;
42
- readonly outcome: WorkerOutcome;
43
- readonly usage: Partial<WorkerUsage>;
44
- readonly sessionFile?: string;
45
- }
46
-
47
- interface SafeDetails {
48
- readonly id: string;
49
- readonly results: readonly SafeResult[];
25
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
26
+ const SPINNER_INTERVAL_MS = 140;
27
+ const ACTIVE_STATUSES: ReadonlySet<WorkerStatus> = new Set(["starting", "running", "stopping"]);
28
+
29
+ export type PresentationRuntime = Pick<OrchestratorRuntime, "snapshot" | "subscribeState">;
30
+
31
+ interface SafeSettlement {
32
+ eventId?: string;
33
+ sequence?: number;
34
+ ownerSessionId: string;
35
+ waveId: string;
36
+ workerId: string;
37
+ generation: number;
38
+ mode: "async" | "inline";
39
+ worker: string;
40
+ title: string;
41
+ lifecycle: "one-shot" | "reusable";
42
+ status: "completed" | "ready" | "failed" | "aborted";
43
+ outcome: Exclude<WorkerOutcome, { status: "closed" }>;
44
+ usage: Partial<WorkerUsage>;
45
+ startedAt?: number;
46
+ settledAt?: number;
47
+ remainingActive?: number;
48
+ waveComplete?: boolean;
49
+ sessionFile?: string;
50
+ failureStage?: "startup" | "prompt" | "workflow" | "cancellation";
50
51
  }
51
52
 
52
53
  interface StatusBinding {
@@ -54,52 +55,19 @@ interface StatusBinding {
54
55
  readonly ctx: ExtensionContext;
55
56
  }
56
57
 
57
- const ACTIVE_STATUSES: ReadonlySet<WorkerStatus> = new Set([
58
- "starting",
59
- "running",
60
- "stopping",
61
- ]);
62
-
63
- const KNOWN_RESULT_STATUSES = new Set([
64
- "completed",
65
- "failed",
66
- "aborted",
67
- "ready",
68
- "closed",
69
- ]);
58
+ interface RenderRequester { requestRender(): void }
70
59
 
71
60
  export function registerOrchestrationPresentation(pi: ExtensionAPI): void {
72
- pi.registerMessageRenderer<WaveDeliveryDetails>(
73
- "pi-orchestrate-wave",
74
- (message, { expanded }, theme) => {
75
- const details = readDeliveryDetails(message.details);
76
- const content = messageText(message.content);
77
-
78
- if (expanded) return expandedResult(content, details, theme);
79
- return new BoundedLines(collapsedResultLines(content, details, theme));
80
- },
61
+ pi.registerMessageRenderer<WorkerDeliveryDetails>(
62
+ "pi-orchestrate-worker-result",
63
+ (message, { expanded }, theme) =>
64
+ new WorkerResultComponent(messageText(message.content), message.details, expanded, theme),
81
65
  );
82
66
  }
83
67
 
84
68
  export function formatResultStatusSummary(details: unknown): string {
85
- const parsed = readDeliveryDetails(details);
86
- if (!parsed) return "Result details unavailable";
87
-
88
- const resultWord = parsed.results.length === 1 ? "result" : "results";
89
- if (parsed.results.length === 0) return `0 ${resultWord}`;
90
-
91
- const counts = new Map<string, number>();
92
- for (const result of parsed.results) {
93
- const status = KNOWN_RESULT_STATUSES.has(result.status) ? result.status : "unknown";
94
- counts.set(status, (counts.get(status) ?? 0) + 1);
95
- }
96
-
97
- const statusOrder = ["completed", "ready", "failed", "aborted", "closed", "unknown"];
98
- const statuses = statusOrder.flatMap((status) => {
99
- const count = counts.get(status);
100
- return count === undefined ? [] : [`${count} ${status}`];
101
- });
102
- return `${parsed.results.length} ${resultWord} · ${statuses.join(" · ")}`;
69
+ const result = readSettlement(details);
70
+ return result ? statusHeading(result) : "Worker result details unavailable";
103
71
  }
104
72
 
105
73
  export function formatResultPreviews(
@@ -108,52 +76,32 @@ export function formatResultPreviews(
108
76
  limit = MAX_RESULT_PREVIEW_LINES,
109
77
  ): string[] {
110
78
  if (limit <= 0) return [];
111
- const parsed = readDeliveryDetails(details);
112
- if (!parsed || parsed.results.length === 0) {
113
- return firstNonEmptyLines(fallbackContent, limit);
114
- }
115
-
116
- const visibleResults = parsed.results.slice(0, limit);
117
- const previews = visibleResults.map((result) => {
118
- const label = `${statusIcon(result.status)} ${result.worker} — ${result.title} · ${result.status}`;
119
- const outcome = outcomePreview(result);
120
- return outcome ? `${label}: ${outcome}` : label;
121
- });
122
-
123
- if (parsed.results.length > limit && previews.length > 0) {
124
- previews[previews.length - 1] = `… ${parsed.results.length - limit + 1} more results`;
125
- }
126
- return previews;
79
+ const result = readSettlement(details);
80
+ const body = result ? outcomeText(result.outcome) : fallbackContent;
81
+ return firstNonEmptyLines(body, limit);
127
82
  }
128
83
 
129
84
  export function formatWorkerUsage(usage: Partial<WorkerUsage> | undefined): string | undefined {
130
85
  if (!usage) return undefined;
131
-
132
- const parts = contextTurnUsageParts(usage);
133
- if (isPositiveNumber(usage.input)) parts.push(`↑${formatCompactNumber(usage.input)}`);
134
- if (isPositiveNumber(usage.output)) parts.push(`↓${formatCompactNumber(usage.output)}`);
135
- if (isPositiveNumber(usage.cacheRead)) parts.push(`R${formatCompactNumber(usage.cacheRead)}`);
136
- if (isPositiveNumber(usage.cacheWrite)) parts.push(`W${formatCompactNumber(usage.cacheWrite)}`);
137
- if (isPositiveNumber(usage.cost)) parts.push(`$${usage.cost.toFixed(4)}`);
138
- return parts.length > 0 ? parts.join(" · ") : undefined;
86
+ const parts = [
87
+ `${numberOrZero(usage.turns)}t`,
88
+ `${formatCompactNumber(numberOrZero(usage.contextTokens))} ctx`,
89
+ `↑${formatCompactNumber(numberOrZero(usage.input))}`,
90
+ `↓${formatCompactNumber(numberOrZero(usage.output))}`,
91
+ `R${formatCompactNumber(numberOrZero(usage.cacheRead))}`,
92
+ `W${formatCompactNumber(numberOrZero(usage.cacheWrite))}`,
93
+ `$${numberOrZero(usage.cost).toFixed(4)}`,
94
+ ];
95
+ return parts.join(" · ");
139
96
  }
140
97
 
141
98
  export function formatWorkerStatusLine(worker: WorkerRecord): string {
142
- const metadata = [
143
- worker.worker,
144
- workerStateLabel(worker),
145
- formatContextCost(worker.usage),
146
- ].filter((part): part is string => Boolean(part));
147
-
148
- return `${worker.title} · ${metadata.join(" · ")} · ${worker.id}`;
99
+ return `${worker.worker} → ${worker.title} · ${workerStateLabel(worker)} · ${compactLiveUsage(worker.usage)}`;
149
100
  }
150
101
 
151
102
  export function formatFooterStatus(snapshot: RuntimeSnapshot): string | undefined {
152
- const active = snapshot.workers.filter((worker) => ACTIVE_STATUSES.has(worker.status)).length;
153
103
  const ready = snapshot.workers.filter((worker) => worker.status === "ready").length;
154
- if (active === 0 && ready === 0) return undefined;
155
-
156
- return `Orchestrate: ${active} active · ${ready} ready`;
104
+ return ready > 0 ? `${ready} available for follow-up` : undefined;
157
105
  }
158
106
 
159
107
  export class StatusController {
@@ -162,6 +110,9 @@ export class StatusController {
162
110
  private refreshGeneration = 0;
163
111
  private disposed = false;
164
112
  private unsubscribeState: (() => void) | undefined;
113
+ private widget: WorkerStatusComponent | undefined;
114
+ private widgetInstalled = false;
115
+ private pendingSnapshot: RuntimeSnapshot | undefined;
165
116
 
166
117
  constructor(private readonly runtime: PresentationRuntime) {}
167
118
 
@@ -170,9 +121,8 @@ export class StatusController {
170
121
  this.clearBinding();
171
122
  this.bindingGeneration += 1;
172
123
  this.binding = { ownerSessionId, ctx };
173
- this.unsubscribeState = this.runtime.subscribeState((changedOwnerSessionId) => {
174
- if (changedOwnerSessionId !== this.binding?.ownerSessionId) return;
175
- void this.refresh();
124
+ this.unsubscribeState = this.runtime.subscribeState((changedOwner) => {
125
+ if (changedOwner === this.binding?.ownerSessionId) void this.refresh();
176
126
  });
177
127
  void this.refresh();
178
128
  }
@@ -187,25 +137,11 @@ export class StatusController {
187
137
  async refresh(): Promise<void> {
188
138
  const binding = this.binding;
189
139
  if (!binding || this.disposed) return;
190
-
191
140
  const bindingGeneration = this.bindingGeneration;
192
141
  const refreshGeneration = ++this.refreshGeneration;
193
142
  let snapshot: RuntimeSnapshot;
194
- try {
195
- snapshot = await this.runtime.snapshot(binding.ownerSessionId);
196
- } catch {
197
- return;
198
- }
199
-
200
- if (
201
- this.disposed ||
202
- this.binding !== binding ||
203
- this.bindingGeneration !== bindingGeneration ||
204
- this.refreshGeneration !== refreshGeneration
205
- ) {
206
- return;
207
- }
208
-
143
+ try { snapshot = await this.runtime.snapshot(binding.ownerSessionId); } catch { return; }
144
+ if (this.disposed || this.binding !== binding || this.bindingGeneration !== bindingGeneration || this.refreshGeneration !== refreshGeneration) return;
209
145
  this.present(binding.ctx, snapshot);
210
146
  }
211
147
 
@@ -218,33 +154,38 @@ export class StatusController {
218
154
  }
219
155
 
220
156
  private present(ctx: ExtensionContext, snapshot: RuntimeSnapshot): void {
221
- const footer = formatFooterStatus(snapshot);
222
- ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, footer);
223
-
157
+ ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, formatFooterStatus(snapshot));
224
158
  if (ctx.mode !== "tui") return;
225
- if (widgetWorkerCount(snapshot) === 0) {
226
- ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
159
+ const active = activeWorkers(snapshot);
160
+ this.pendingSnapshot = snapshot;
161
+ if (active.length === 0) {
162
+ if (this.widgetInstalled) ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
163
+ this.widget = undefined;
164
+ this.widgetInstalled = false;
227
165
  return;
228
166
  }
229
-
230
- ctx.ui.setWidget(
231
- ORCHESTRATION_PRESENTATION_KEY,
232
- (_tui, theme) => new WorkerStatusComponent(snapshot, theme),
233
- { placement: "aboveEditor" },
234
- );
167
+ if (this.widget) {
168
+ this.widget.update(snapshot);
169
+ return;
170
+ }
171
+ if (this.widgetInstalled) return;
172
+ ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, (tui, theme) => {
173
+ this.widget = new WorkerStatusComponent(this.pendingSnapshot ?? snapshot, theme, tui);
174
+ return this.widget;
175
+ }, { placement: "aboveEditor" });
176
+ this.widgetInstalled = true;
235
177
  }
236
178
 
237
179
  private clearBinding(): void {
238
180
  this.unsubscribeState?.();
239
181
  this.unsubscribeState = undefined;
240
-
182
+ this.widget = undefined;
183
+ this.pendingSnapshot = undefined;
241
184
  const current = this.binding;
242
185
  if (!current) return;
243
-
244
186
  current.ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, undefined);
245
- if (current.ctx.mode === "tui") {
246
- current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
247
- }
187
+ if (current.ctx.mode === "tui" && this.widgetInstalled) current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
188
+ this.widgetInstalled = false;
248
189
  this.binding = undefined;
249
190
  }
250
191
  }
@@ -253,377 +194,278 @@ export function createStatusController(runtime: PresentationRuntime): StatusCont
253
194
  return new StatusController(runtime);
254
195
  }
255
196
 
256
- class BoundedLines implements Component {
257
- constructor(private readonly lines: readonly string[]) {}
197
+ export class WorkerStatusComponent implements Component {
198
+ private frameIndex = 0;
199
+ private snapshot: RuntimeSnapshot;
200
+ private timer: ReturnType<typeof setInterval> | undefined;
258
201
 
259
- render(width: number): string[] {
260
- const boundedWidth = Math.max(1, width);
261
- return this.lines.map((line) => truncateToWidth(line, boundedWidth, "…"));
202
+ constructor(snapshot: RuntimeSnapshot, private readonly theme: Theme, private readonly tui?: RenderRequester) {
203
+ this.snapshot = snapshot;
204
+ this.startTimer();
262
205
  }
263
206
 
264
- invalidate(): void {}
265
- }
266
-
267
- interface WaveGroup {
268
- readonly wave: WaveRecord;
269
- readonly workers: readonly WorkerRecord[];
270
- readonly settled: number;
271
- }
272
-
273
- class WorkerStatusComponent implements Component {
274
- constructor(
275
- private readonly snapshot: RuntimeSnapshot,
276
- private readonly theme: Theme,
277
- ) {}
207
+ update(snapshot: RuntimeSnapshot): void {
208
+ this.snapshot = snapshot;
209
+ if (activeWorkers(snapshot).length > 0) this.startTimer();
210
+ else this.stopTimer();
211
+ this.tui?.requestRender();
212
+ }
278
213
 
279
214
  render(width: number): string[] {
280
215
  const boundedWidth = Math.max(1, width);
281
- const groups = activeWaveGroups(this.snapshot);
282
- const groupedWorkerIds = new Set(groups.flatMap((group) => group.workers.map((worker) => worker.id)));
283
- const ready = readyWorkers(this.snapshot).filter((worker) => !groupedWorkerIds.has(worker.id));
284
- const totalWorkers = groups.reduce((total, group) => total + group.workers.length, 0) + ready.length;
285
- const lines: string[] = [];
286
- let remaining = MAX_WIDGET_WORKERS;
287
- let shown = 0;
288
-
289
- for (const group of groups) {
290
- if (remaining === 0) break;
291
- const visibleWorkers = group.workers.slice(0, remaining);
292
- if (visibleWorkers.length === 0) continue;
293
- lines.push(this.waveHeader(group));
294
- for (const worker of visibleWorkers) lines.push(this.workerLine(worker, boundedWidth));
295
- shown += visibleWorkers.length;
296
- remaining -= visibleWorkers.length;
297
- }
298
-
299
- if (remaining > 0) {
300
- const visibleReady = ready.slice(0, remaining);
301
- if (visibleReady.length > 0) {
302
- lines.push(this.theme.fg("toolTitle", this.theme.bold(`Ready · ${ready.length}`)));
303
- for (const worker of visibleReady) lines.push(this.workerLine(worker, boundedWidth));
304
- shown += visibleReady.length;
305
- }
306
- }
307
-
308
- if (totalWorkers > shown) {
309
- lines.push(this.theme.fg("dim", `… ${totalWorkers - shown} more workers`));
310
- }
216
+ const active = activeWorkers(this.snapshot);
217
+ if (active.length === 0) return [];
218
+ const oldest = Math.min(...active.map((worker) => worker.startedAt));
219
+ const elapsed = formatElapsed(Math.max(0, Date.now() - oldest));
220
+ const lines = [this.theme.fg("toolTitle", this.theme.bold(`Workers · ${active.length} active · ${elapsed}`))];
221
+ for (const worker of active.slice(0, MAX_WIDGET_WORKERS)) lines.push(this.workerLine(worker, boundedWidth));
222
+ if (active.length > MAX_WIDGET_WORKERS) lines.push(this.theme.fg("dim", `… ${active.length - MAX_WIDGET_WORKERS} more active`));
311
223
  return lines.map((line) => truncateToWidth(line, boundedWidth, "…"));
312
224
  }
313
225
 
314
226
  invalidate(): void {}
227
+ dispose(): void { this.stopTimer(); }
228
+
229
+ private startTimer(): void {
230
+ if (this.timer || activeWorkers(this.snapshot).length === 0) return;
231
+ this.timer = setInterval(() => {
232
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
233
+ this.tui?.requestRender();
234
+ }, SPINNER_INTERVAL_MS);
235
+ const timer = this.timer;
236
+ if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") timer.unref();
237
+ }
315
238
 
316
- private waveHeader(group: WaveGroup): string {
317
- return this.theme.fg(
318
- "toolTitle",
319
- this.theme.bold(
320
- `Wave ${group.wave.id} · ${group.settled}/${group.wave.workerIds.length} settled`,
321
- ),
322
- );
239
+ private stopTimer(): void {
240
+ if (!this.timer) return;
241
+ clearInterval(this.timer);
242
+ this.timer = undefined;
323
243
  }
324
244
 
325
245
  private workerLine(worker: WorkerRecord, width: number): string {
326
- const state = workerStateLabel(worker);
327
- const title = `${statusIcon(worker.status)} ${this.theme.fg("text", this.theme.bold(worker.title))}`;
328
- const workerType = this.theme.fg("muted", worker.worker);
329
- const activity = this.theme.fg("text", state);
330
- const context = isPositiveNumber(worker.usage.contextTokens)
331
- ? this.theme.fg("dim", `${formatCompactNumber(worker.usage.contextTokens)} ctx`)
332
- : undefined;
333
- const cost = isPositiveNumber(worker.usage.cost)
334
- ? this.theme.fg("dim", `$${worker.usage.cost.toFixed(4)}`)
335
- : undefined;
336
- const id = this.theme.fg("muted", String(worker.id));
337
- const variants = [
338
- [title, workerType, activity, context, cost, id],
339
- [title, workerType, activity, context, id],
340
- [title, workerType, activity, id],
341
- [title, activity, id],
342
- ].map((parts) => parts.filter((part): part is string => Boolean(part)).join(" · "));
343
- const fitting = variants.find((line) => visibleWidth(line) <= width);
344
- if (fitting) return fitting;
345
-
346
- const suffix = ` · ${activity} · ${id}`;
347
- const titleWidth = Math.max(1, width - visibleWidth(suffix));
348
- if (titleWidth > 1) return `${truncateToWidth(title, titleWidth, "…")}${suffix}`;
349
- return truncateToWidth(`${statusIcon(worker.status)} ${id}`, width, "…");
246
+ const glyph = this.theme.fg("warning", SPINNER_FRAMES[this.frameIndex] ?? SPINNER_FRAMES[0]);
247
+ const turns = `${numberOrZero(worker.usage?.turns)}t`;
248
+ const context = `${formatCompactNumber(numberOrZero(worker.usage?.contextTokens))} ctx`;
249
+ const suffixFields = width >= 28 ? [turns, context] : width >= 12 ? [turns] : [];
250
+ const activity = workerStateLabel(worker);
251
+ const requiredWidth = (fields: readonly string[]) => visibleWidth(`⠋ · ${fields.join(" · ")}`) + 10;
252
+ if (width >= 42 && requiredWidth([activity, ...suffixFields]) <= width) suffixFields.unshift(activity);
253
+ const showWorker = width >= 72 && requiredWidth([worker.worker, ...suffixFields]) <= width;
254
+ const prefix = showWorker ? `${glyph} ${this.theme.fg("muted", worker.worker)} → ` : `${glyph} `;
255
+ const suffix = suffixFields.length ? ` · ${suffixFields.join(" · ")}` : "";
256
+ const titleWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(suffix));
257
+ const title = truncateToWidth(this.theme.fg("text", this.theme.bold(worker.title)), titleWidth, "…");
258
+ return `${prefix}${title}${suffix}`;
350
259
  }
351
260
  }
352
261
 
353
- function collapsedResultLines(
354
- content: string,
355
- details: SafeDetails | undefined,
356
- theme: Theme,
357
- ): string[] {
358
- const waveId = details?.id ?? "unknown wave";
359
- const heading = `${theme.fg("toolTitle", theme.bold("Worker results"))} ${theme.fg("dim", `· ${waveId}`)}`;
360
- const summary = theme.fg("muted", formatResultStatusSummary(details));
361
- const previews = formatResultPreviews(details, content).map((line) => theme.fg("customMessageText", line));
362
- const hint = theme.fg("dim", keyHint("app.tools.expand", "to expand results"));
363
- return [heading, summary, ...previews, hint];
364
- }
365
-
366
- function expandedResult(
367
- content: string,
368
- details: SafeDetails | undefined,
369
- theme: Theme,
370
- ): Component {
371
- const container = new Container();
372
- const waveId = details?.id ?? "unknown wave";
373
- container.addChild(
374
- new Text(
375
- `${theme.fg("toolTitle", theme.bold("Worker results"))} ${theme.fg("dim", `· ${waveId}`)}`,
376
- 0,
377
- 0,
378
- ),
379
- );
380
- container.addChild(new Text(theme.fg("muted", formatResultStatusSummary(details)), 0, 0));
381
- container.addChild(new Spacer(1));
382
-
383
- if (!details) {
384
- container.addChild(new Markdown(content, 0, 0, getMarkdownTheme()));
385
- container.addChild(new Spacer(1));
386
- container.addChild(new Text(theme.fg("dim", "Structured worker metadata unavailable"), 0, 0));
387
- return container;
388
- }
389
-
390
- container.addChild(new Markdown(reconstructOutcomeMarkdown(details), 0, 0, getMarkdownTheme()));
391
- container.addChild(new Spacer(1));
392
- container.addChild(new Text(theme.fg("toolTitle", theme.bold("Worker details")), 0, 0));
393
-
394
- for (const result of details.results) {
395
- const usage = formatWorkerUsage(result.usage) ?? "unavailable";
396
- const session = result.sessionFile ?? "unavailable";
397
- container.addChild(new Text(theme.fg("text", `${result.worker} — ${result.title}`), 0, 0));
398
- container.addChild(
399
- new Text(
400
- `${theme.fg("muted", "ID")} ${result.workerId} · ${theme.fg("muted", "status")} ${result.status}`,
401
- 0,
402
- 0,
403
- ),
404
- );
405
- container.addChild(new Text(theme.fg("dim", `usage ${usage}`), 0, 0));
406
- container.addChild(new Text(theme.fg("dim", `session ${session}`), 0, 0));
262
+ class WidthBoundComponent implements Component {
263
+ constructor(private readonly child: Component, private readonly maxLines?: number) {}
264
+ render(width: number): string[] {
265
+ const bounded = Math.max(1, Math.floor(width));
266
+ const lines = this.child.render(bounded);
267
+ const selected = this.maxLines === undefined ? lines : lines.slice(0, this.maxLines);
268
+ return selected.map((line) => truncateToWidth(line, bounded, "…"));
407
269
  }
408
- return container;
270
+ invalidate(): void { this.child.invalidate(); }
271
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
409
272
  }
410
273
 
411
- function readDeliveryDetails(value: unknown): SafeDetails | undefined {
412
- if (!isRecord(value) || typeof value.id !== "string" || !Array.isArray(value.results)) {
413
- return undefined;
414
- }
415
-
416
- const results: SafeResult[] = [];
417
- for (const candidate of value.results) {
418
- if (
419
- !isRecord(candidate) ||
420
- typeof candidate.workerId !== "string" ||
421
- typeof candidate.worker !== "string" ||
422
- typeof candidate.title !== "string" ||
423
- typeof candidate.status !== "string" ||
424
- !KNOWN_RESULT_STATUSES.has(candidate.status) ||
425
- !isRecord(candidate.usage)
426
- ) {
427
- return undefined;
428
- }
274
+ export class WorkerResultComponent implements Component {
275
+ private child: Component;
429
276
 
430
- const outcome = readOutcome(candidate.outcome, candidate.status);
431
- if (!outcome) return undefined;
432
- results.push({
433
- workerId: candidate.workerId,
434
- worker: candidate.worker,
435
- title: candidate.title,
436
- status: candidate.status,
437
- outcome,
438
- usage: candidate.usage as Partial<WorkerUsage>,
439
- ...(typeof candidate.sessionFile === "string" ? { sessionFile: candidate.sessionFile } : {}),
440
- });
277
+ constructor(
278
+ private readonly content: string,
279
+ private readonly rawDetails: unknown,
280
+ private readonly expanded: boolean,
281
+ private readonly theme: Theme,
282
+ ) {
283
+ this.child = this.build();
441
284
  }
442
- return { id: value.id, results };
443
- }
444
-
445
- function messageText(content: unknown): string {
446
- if (typeof content === "string") return content;
447
- if (!Array.isArray(content)) return "";
448
285
 
449
- return content.flatMap((part) => {
450
- if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
451
- return [part.text];
286
+ render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
287
+ invalidate(): void {
288
+ (this.child as Component & { dispose?: () => void }).dispose?.();
289
+ this.child = this.build();
290
+ }
291
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
292
+
293
+ private build(): Component {
294
+ const details = readSettlement(this.rawDetails);
295
+ const box = new Box(1, 1, (text) => this.theme.bg("customMessageBg", text));
296
+ if (!details) {
297
+ box.addChild(new Text(this.theme.fg("warning", this.theme.bold("Worker result details unavailable")), 0, 0));
298
+ box.addChild(new WidthBoundComponent(new Markdown(this.content, 0, 0, getMarkdownTheme()), this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES));
299
+ if (!this.expanded) box.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to expand")), 0, 0));
300
+ return box;
452
301
  }
453
- return [];
454
- }).join("\n");
455
- }
456
-
457
- function outcomePreview(result: SafeResult): string | undefined {
458
- const outcome = result.outcome;
459
- const assistantText = "assistantText" in outcome
460
- ? firstNonEmptyLines(outcome.assistantText ?? "", 1)[0]
461
- : undefined;
462
- const message = "message" in outcome ? outcome.message?.trim() : undefined;
463
- if (result.status === "failed") return message;
464
- if (result.status === "aborted") return message;
465
- return assistantText;
466
- }
467
302
 
468
- function reconstructOutcomeMarkdown(details: SafeDetails): string {
469
- return details.results.map((result) => {
470
- const heading = `### ${result.worker} — ${result.title}`;
471
- const outcome = renderOutcomeMarkdown(result.outcome);
472
- return outcome ? `${heading}\n\n${outcome}` : heading;
473
- }).join("\n\n");
474
- }
475
-
476
- function renderOutcomeMarkdown(outcome: WorkerOutcome): string {
477
- switch (outcome.status) {
478
- case "completed":
479
- case "ready":
480
- return outcome.assistantText;
481
- case "failed":
482
- return outcome.assistantText
483
- ? `Failed: ${outcome.message}\n\n${outcome.assistantText}`
484
- : `Failed: ${outcome.message}`;
485
- case "aborted": {
486
- const reason = outcome.message ? `Aborted: ${outcome.message}` : "Aborted";
487
- return outcome.assistantText ? `${reason}\n\n${outcome.assistantText}` : reason;
303
+ const color = details.status === "failed" ? "error" : details.status === "aborted" ? "warning" : "success";
304
+ const elapsed = elapsedBetween(details.startedAt, details.settledAt);
305
+ const status = details.status === "aborted"
306
+ ? " · aborted"
307
+ : details.status === "failed" && details.failureStage === "startup"
308
+ ? " · could not start"
309
+ : "";
310
+ const header = `${statusIcon(details)} ${details.worker} · ${details.title}${status}${elapsed ? ` · ${elapsed}` : ""}`;
311
+ box.addChild(new Text(this.theme.fg(color, this.theme.bold(header)), 0, 0));
312
+ box.addChild(new Spacer(1));
313
+ box.addChild(new WidthBoundComponent(new Markdown(outcomeText(details.outcome), 0, 0, getMarkdownTheme()), this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES));
314
+ if (!this.expanded) {
315
+ box.addChild(new Spacer(1));
316
+ box.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to expand")), 0, 0));
317
+ return box;
488
318
  }
489
- case "closed":
490
- return "Closed";
319
+ box.addChild(new Spacer(1));
320
+ box.addChild(new Text(this.theme.fg("toolTitle", this.theme.bold("Worker details")), 0, 0));
321
+ for (const line of settlementMetadata(details)) box.addChild(new Text(this.theme.fg("dim", line), 0, 0));
322
+ return box;
491
323
  }
492
324
  }
493
325
 
494
- function readOutcome(value: unknown, status: string): WorkerOutcome | undefined {
495
- if (!isRecord(value) || value.status !== status) return undefined;
496
- if (status === "completed" || status === "ready") {
497
- return typeof value.assistantText === "string"
498
- ? { status, assistantText: value.assistantText }
499
- : undefined;
500
- }
501
- if (status === "failed") {
502
- if (typeof value.message !== "string") return undefined;
503
- return {
504
- status: "failed",
505
- message: value.message,
506
- ...(typeof value.assistantText === "string" ? { assistantText: value.assistantText } : {}),
507
- };
508
- }
509
- if (status === "aborted") {
510
- return {
511
- status: "aborted",
512
- ...(typeof value.message === "string" ? { message: value.message } : {}),
513
- ...(typeof value.assistantText === "string" ? { assistantText: value.assistantText } : {}),
514
- };
515
- }
516
- if (status === "closed") return { status: "closed" };
326
+ function readSettlement(value: unknown): SafeSettlement | undefined {
327
+ const candidate = isRecord(value) && isRecord(value.settlement) ? value.settlement : value;
328
+ if (!isRecord(candidate)) return undefined;
329
+ const mode = enumField(candidate.mode, ["async", "inline"]);
330
+ const lifecycle = enumField(candidate.lifecycle, ["one-shot", "reusable"]);
331
+ const status = enumField(candidate.status, ["completed", "ready", "failed", "aborted"]);
332
+ const outcome = readOutcome(candidate.outcome);
333
+ const usage = readUsage(candidate.usage);
334
+ const generation = nonnegativeInteger(candidate.generation);
335
+ if (!mode || !lifecycle || !status || !outcome || outcome.status !== status || !usage || generation === undefined) return undefined;
336
+ const ownerSessionId = requiredString(candidate.ownerSessionId);
337
+ const waveId = requiredString(candidate.waveId);
338
+ const workerId = requiredString(candidate.workerId);
339
+ const worker = requiredString(candidate.worker);
340
+ const title = requiredString(candidate.title);
341
+ if (ownerSessionId === undefined || waveId === undefined || workerId === undefined || worker === undefined || title === undefined) return undefined;
342
+ const eventId = optionalString(candidate.eventId);
343
+ const sessionFile = optionalString(candidate.sessionFile);
344
+ const sequence = optionalInteger(candidate.sequence);
345
+ const startedAt = optionalInteger(candidate.startedAt);
346
+ const settledAt = optionalInteger(candidate.settledAt);
347
+ const remainingActive = optionalInteger(candidate.remainingActive);
348
+ const waveComplete = optionalBoolean(candidate.waveComplete);
349
+ const failureStage = optionalEnum(candidate.failureStage, ["startup", "prompt", "workflow", "cancellation"]);
350
+ if (eventId === INVALID || sessionFile === INVALID || sequence === INVALID || startedAt === INVALID || settledAt === INVALID ||
351
+ remainingActive === INVALID || waveComplete === INVALID || failureStage === INVALID) return undefined;
352
+ if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
353
+ if (failureStage !== undefined && status !== "failed" && status !== "aborted") return undefined;
354
+ return {
355
+ ownerSessionId, waveId, workerId, generation, mode, worker, title, lifecycle, status, outcome, usage,
356
+ ...(eventId === undefined ? {} : { eventId }),
357
+ ...(sequence === undefined ? {} : { sequence }),
358
+ ...(startedAt === undefined ? {} : { startedAt }),
359
+ ...(settledAt === undefined ? {} : { settledAt }),
360
+ ...(remainingActive === undefined ? {} : { remainingActive }),
361
+ ...(waveComplete === undefined ? {} : { waveComplete }),
362
+ ...(sessionFile === undefined ? {} : { sessionFile }),
363
+ ...(failureStage === undefined ? {} : { failureStage }),
364
+ };
365
+ }
366
+
367
+ function readOutcome(value: unknown): WorkerOutcome | undefined {
368
+ if (!isRecord(value) || typeof value.status !== "string") return undefined;
369
+ if ((value.status === "completed" || value.status === "ready") && typeof value.assistantText === "string") return { status: value.status, assistantText: value.assistantText };
370
+ const message = optionalString(value.message);
371
+ const assistantText = optionalString(value.assistantText);
372
+ if (message === INVALID || assistantText === INVALID) return undefined;
373
+ if (value.status === "failed" && message !== undefined) return { status: "failed", message, ...(assistantText === undefined ? {} : { assistantText }) };
374
+ if (value.status === "aborted") return { status: "aborted", ...(message === undefined ? {} : { message }), ...(assistantText === undefined ? {} : { assistantText }) };
517
375
  return undefined;
518
376
  }
519
377
 
520
- function firstNonEmptyLines(text: string, limit: number): string[] {
521
- const lines: string[] = [];
522
- let start = 0;
523
- for (let index = 0; index <= text.length && lines.length < limit; index += 1) {
524
- if (index !== text.length && text[index] !== "\n") continue;
525
- const line = text.slice(start, index).replace(/\r$/, "").trim();
526
- if (line) lines.push(line);
527
- start = index + 1;
528
- }
529
- return lines;
530
- }
531
-
532
- function activeWaveGroups(snapshot: RuntimeSnapshot): WaveGroup[] {
533
- const workersById = new Map(snapshot.workers.map((worker) => [worker.id, worker]));
534
- return snapshot.waves.flatMap((wave) => {
535
- if (wave.state !== "running") return [];
536
- const workers = wave.workerIds.flatMap((workerId) => {
537
- const worker = workersById.get(workerId);
538
- return worker?.waveId === wave.id ? [worker] : [];
539
- });
540
- const settled = workers.filter((worker) => isWorkerCompleteForWave(worker.status)).length;
541
- return [{ wave, workers, settled }];
542
- });
378
+ const INVALID = Symbol("invalid");
379
+ type Invalid = typeof INVALID;
380
+ function requiredString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; }
381
+ function optionalString(value: unknown): string | undefined | Invalid { return value === undefined ? undefined : typeof value === "string" ? value : INVALID; }
382
+ function nonnegativeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 ? value : undefined; }
383
+ function optionalInteger(value: unknown): number | undefined | Invalid { return value === undefined ? undefined : nonnegativeInteger(value) ?? INVALID; }
384
+ function optionalBoolean(value: unknown): boolean | undefined | Invalid { return value === undefined ? undefined : typeof value === "boolean" ? value : INVALID; }
385
+ function enumField<const T extends string>(value: unknown, values: readonly T[]): T | undefined { return typeof value === "string" && values.includes(value as T) ? value as T : undefined; }
386
+ function optionalEnum<const T extends string>(value: unknown, values: readonly T[]): T | undefined | Invalid { return value === undefined ? undefined : enumField(value, values) ?? INVALID; }
387
+ function readUsage(value: unknown): WorkerUsage | undefined {
388
+ if (!isRecord(value)) return undefined;
389
+ const input = nonnegativeNumber(value.input);
390
+ const output = nonnegativeNumber(value.output);
391
+ const cacheRead = nonnegativeNumber(value.cacheRead);
392
+ const cacheWrite = nonnegativeNumber(value.cacheWrite);
393
+ const cost = nonnegativeNumber(value.cost);
394
+ const contextTokens = nonnegativeNumber(value.contextTokens);
395
+ const turns = nonnegativeInteger(value.turns);
396
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined || contextTokens === undefined || turns === undefined) return undefined;
397
+ return { input, output, cacheRead, cacheWrite, cost, contextTokens, turns };
398
+ }
399
+ function nonnegativeNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; }
400
+
401
+ function statusHeading(result: SafeSettlement): string {
402
+ if (result.status === "failed") return result.failureStage === "startup" ? "could not start" : "failed";
403
+ if (result.status === "aborted") return "aborted";
404
+ if (result.status === "ready") return "response complete";
405
+ return "completed";
406
+ }
407
+
408
+ function statusIcon(result: SafeSettlement): string {
409
+ if (result.status === "failed") return "✗";
410
+ if (result.status === "aborted") return "■";
411
+ return "✓";
412
+ }
413
+
414
+ function outcomeText(outcome: WorkerOutcome): string {
415
+ if (outcome.status === "completed" || outcome.status === "ready") return outcome.assistantText;
416
+ if (outcome.status === "failed") return outcome.assistantText ? `${outcome.message}\n\n${outcome.assistantText}` : outcome.message;
417
+ if (outcome.status === "aborted") return [outcome.message || "Worker was aborted.", outcome.assistantText].filter(Boolean).join("\n\n");
418
+ return "Worker session closed.";
419
+ }
420
+
421
+ function settlementMetadata(result: SafeSettlement): string[] {
422
+ return [
423
+ `worker ID ${result.workerId} · wave ID ${result.waveId}`,
424
+ `status ${result.status} · generation ${result.generation}`,
425
+ `turns ${numberOrZero(result.usage.turns)} · current context ${formatCompactNumber(numberOrZero(result.usage.contextTokens))}`,
426
+ `input ${numberOrZero(result.usage.input)} · output ${numberOrZero(result.usage.output)} · cache read ${numberOrZero(result.usage.cacheRead)} · cache write ${numberOrZero(result.usage.cacheWrite)} · cost $${numberOrZero(result.usage.cost).toFixed(4)}`,
427
+ `session ${result.sessionFile ?? "unavailable"}`,
428
+ ];
543
429
  }
544
430
 
545
- function readyWorkers(snapshot: RuntimeSnapshot): WorkerRecord[] {
546
- return snapshot.workers.filter((worker) => worker.status === "ready");
431
+ function messageText(content: unknown): string {
432
+ if (typeof content === "string") return content;
433
+ if (!Array.isArray(content)) return "";
434
+ return content.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
547
435
  }
548
436
 
549
- function widgetWorkerCount(snapshot: RuntimeSnapshot): number {
550
- const groups = activeWaveGroups(snapshot);
551
- const groupedWorkerIds = new Set(groups.flatMap((group) => group.workers.map((worker) => worker.id)));
552
- return groups.reduce((total, group) => total + group.workers.length, 0) +
553
- readyWorkers(snapshot).filter((worker) => !groupedWorkerIds.has(worker.id)).length;
437
+ function activeWorkers(snapshot: RuntimeSnapshot): WorkerRecord[] {
438
+ return snapshot.workers.filter((worker) => ACTIVE_STATUSES.has(worker.status));
554
439
  }
555
440
 
556
- const TOOL_ACTIVITY: Readonly<Record<string, string>> = {
557
- read: "reading",
558
- grep: "searching",
559
- find: "finding files",
560
- ls: "listing",
561
- bash: "running command",
562
- edit: "editing",
563
- write: "writing",
564
- };
565
-
441
+ const TOOL_ACTIVITY: Readonly<Record<string, string>> = { read: "reading", grep: "searching", find: "finding files", ls: "listing", bash: "running command", edit: "editing", write: "writing" };
566
442
  function workerStateLabel(worker: Pick<WorkerRecord, "status" | "activity">): string {
567
443
  if (worker.status === "starting" || worker.status === "stopping") return worker.status;
568
444
  if (worker.status !== "running") return worker.status;
569
- if (!worker.activity?.trim()) return "thinking";
445
+ if (!worker.activity?.trim()) return "working";
570
446
  return TOOL_ACTIVITY[worker.activity] ?? worker.activity;
571
447
  }
572
448
 
573
- function formatContextCost(usage: Partial<WorkerUsage> | undefined): string | undefined {
574
- if (!usage) return undefined;
575
- const parts: string[] = [];
576
- if (isPositiveNumber(usage.contextTokens)) parts.push(`${formatCompactNumber(usage.contextTokens)} ctx`);
577
- if (isPositiveNumber(usage.cost)) parts.push(`$${usage.cost.toFixed(4)}`);
578
- return parts.length > 0 ? parts.join(" · ") : undefined;
449
+ function compactLiveUsage(usage: Partial<WorkerUsage> | undefined): string {
450
+ return `${numberOrZero(usage?.turns)}t · ${formatCompactNumber(numberOrZero(usage?.contextTokens))} ctx`;
579
451
  }
580
-
581
- function contextTurnUsageParts(usage: Partial<WorkerUsage>): string[] {
582
- const parts: string[] = [];
583
- if (isPositiveNumber(usage.contextTokens)) parts.push(`${formatCompactNumber(usage.contextTokens)} ctx`);
584
- if (isPositiveNumber(usage.turns)) {
585
- parts.push(`${usage.turns} ${usage.turns === 1 ? "turn" : "turns"}`);
586
- }
587
- return parts;
452
+ function elapsedBetween(start?: number, end?: number): string | undefined {
453
+ return start !== undefined && end !== undefined && end >= start ? formatElapsed(end - start) : undefined;
588
454
  }
589
-
455
+ function formatElapsed(milliseconds: number): string {
456
+ const seconds = Math.floor(milliseconds / 1000);
457
+ if (seconds < 60) return `${seconds}s`;
458
+ const minutes = Math.floor(seconds / 60);
459
+ return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`;
460
+ }
461
+ function firstNonEmptyLines(text: string, limit: number): string[] {
462
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, limit);
463
+ }
464
+ function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
590
465
  function formatCompactNumber(value: number): string {
591
466
  if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`;
592
467
  if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`;
593
468
  return String(Math.round(value));
594
469
  }
595
-
596
- function trimDecimal(value: number): string {
597
- return value.toFixed(1).replace(/\.0$/, "");
598
- }
599
-
600
- function statusIcon(status: string): string {
601
- switch (status) {
602
- case "starting":
603
- return "◌";
604
- case "running":
605
- return "●";
606
- case "stopping":
607
- return "◍";
608
- case "ready":
609
- return "○";
610
- case "completed":
611
- return "✓";
612
- case "failed":
613
- return "✗";
614
- case "aborted":
615
- return "■";
616
- case "closed":
617
- return "×";
618
- default:
619
- return "·";
620
- }
621
- }
622
-
623
- function isPositiveNumber(value: unknown): value is number {
624
- return typeof value === "number" && Number.isFinite(value) && value > 0;
625
- }
626
-
627
- function isRecord(value: unknown): value is Record<string, unknown> {
628
- return typeof value === "object" && value !== null;
629
- }
470
+ function trimDecimal(value: number): string { return value.toFixed(1).replace(/\.0$/, ""); }
471
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; }