@zachwill/pi-orchestrate 0.1.0 → 0.2.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.
@@ -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,92 +14,125 @@ 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 { Result, Schema } from "effect";
18
+ import type { WorkerDeliveryDetails } from "./delivery.js";
19
+ import type { WorkerOutcome, WorkerRecord, WorkerStatus, WorkerUsage } from "./domain.js";
26
20
  import type { OrchestratorRuntime, RuntimeSnapshot } from "./runtime.js";
27
21
 
28
22
  export const ORCHESTRATION_PRESENTATION_KEY = "pi-orchestrate";
29
- export const MAX_RESULT_PREVIEW_LINES = 5;
23
+ export const MAX_RESULT_PREVIEW_LINES = 6;
30
24
  export const MAX_WIDGET_WORKERS = 8;
31
25
 
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
- }
26
+ const WORKER_ANIMATIONS = {
27
+ starting: {
28
+ frames: ["⠂", "⠌", "⡑", "⢕", "⣫", "⣿", "⣫", "⢕"],
29
+ color: "muted",
30
+ },
31
+ running: {
32
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
33
+ color: "accent",
34
+ },
35
+ stopping: {
36
+ frames: ["⣿", "⣶", "⣤", "⣀", "⠠", "⠐", "⠈", "⠁"],
37
+ color: "dim",
38
+ },
39
+ } as const;
40
+ const ANIMATION_CYCLE_TICKS = 40;
41
+ const SPINNER_INTERVAL_MS = 140;
42
+ const ACTIVE_STATUSES: ReadonlySet<WorkerStatus> = new Set(["starting", "running", "stopping"]);
43
+
44
+ export type PresentationRuntime = Pick<OrchestratorRuntime, "snapshot" | "subscribeState">;
45
+
46
+ const NonnegativeFinite = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0));
47
+ const NonnegativeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
48
+ const legacyOptionalKey = <S extends Schema.Constraint>(schema: S) =>
49
+ Schema.optionalKey(Schema.UndefinedOr(schema));
50
+
51
+ const WorkerUsageSchema = Schema.Struct({
52
+ input: NonnegativeFinite,
53
+ output: NonnegativeFinite,
54
+ cacheRead: NonnegativeFinite,
55
+ cacheWrite: NonnegativeFinite,
56
+ cost: NonnegativeFinite,
57
+ contextTokens: NonnegativeFinite,
58
+ turns: NonnegativeInteger,
59
+ });
60
+
61
+ const WorkerCompletedOutcomeSchema = Schema.Struct({
62
+ status: Schema.Literal("completed"),
63
+ assistantText: Schema.String,
64
+ });
65
+ const WorkerReadyOutcomeSchema = Schema.Struct({
66
+ status: Schema.Literal("ready"),
67
+ assistantText: Schema.String,
68
+ });
69
+ const WorkerFailedOutcomeSchema = Schema.Struct({
70
+ status: Schema.Literal("failed"),
71
+ message: Schema.String,
72
+ assistantText: legacyOptionalKey(Schema.String),
73
+ });
74
+ const WorkerAbortedOutcomeSchema = Schema.Struct({
75
+ status: Schema.Literal("aborted"),
76
+ message: legacyOptionalKey(Schema.String),
77
+ assistantText: legacyOptionalKey(Schema.String),
78
+ });
79
+ const WorkerOutcomeSchema = Schema.Union([
80
+ WorkerCompletedOutcomeSchema,
81
+ WorkerReadyOutcomeSchema,
82
+ WorkerFailedOutcomeSchema,
83
+ WorkerAbortedOutcomeSchema,
84
+ ]);
46
85
 
47
- interface SafeDetails {
48
- readonly id: string;
49
- readonly results: readonly SafeResult[];
50
- }
86
+ const SettlementPayloadSchema = Schema.Struct({
87
+ eventId: legacyOptionalKey(Schema.String),
88
+ sequence: legacyOptionalKey(NonnegativeInteger),
89
+ ownerSessionId: Schema.String,
90
+ waveId: Schema.String,
91
+ workerId: Schema.String,
92
+ generation: NonnegativeInteger,
93
+ mode: Schema.Literals(["async", "inline"]),
94
+ worker: Schema.String,
95
+ title: Schema.String,
96
+ lifecycle: Schema.Literals(["one-shot", "reusable"]),
97
+ status: Schema.Literals(["completed", "ready", "failed", "aborted"]),
98
+ outcome: WorkerOutcomeSchema,
99
+ usage: WorkerUsageSchema,
100
+ startedAt: NonnegativeInteger,
101
+ settledAt: NonnegativeInteger,
102
+ remainingActive: legacyOptionalKey(NonnegativeInteger),
103
+ waveComplete: legacyOptionalKey(Schema.Boolean),
104
+ sessionFile: legacyOptionalKey(Schema.String),
105
+ failureStage: legacyOptionalKey(Schema.Literals(["startup", "prompt", "workflow", "cancellation"])),
106
+ }).check(Schema.makeFilter((settlement) => {
107
+ if (settlement.outcome.status !== settlement.status) return "outcome status must match settlement status";
108
+ if (settlement.settledAt < settlement.startedAt) return "settlement timestamp must not precede start timestamp";
109
+ if (settlement.failureStage !== undefined && settlement.status !== "failed" && settlement.status !== "aborted") {
110
+ return "failure stage requires a failed or aborted settlement";
111
+ }
112
+ }));
113
+
114
+ const SettlementEnvelopeSchema = Schema.Struct({ settlement: SettlementPayloadSchema });
115
+ const decodeSettlementEnvelope = Schema.decodeUnknownResult(SettlementEnvelopeSchema);
116
+ type SafeSettlement = Schema.Schema.Type<typeof SettlementPayloadSchema>;
51
117
 
52
118
  interface StatusBinding {
53
119
  readonly ownerSessionId: string;
54
120
  readonly ctx: ExtensionContext;
55
121
  }
56
122
 
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
- ]);
123
+ interface RenderRequester { requestRender(): void }
70
124
 
71
125
  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
- },
126
+ pi.registerMessageRenderer<WorkerDeliveryDetails>(
127
+ "pi-orchestrate-worker-result",
128
+ (message, { expanded }, theme) =>
129
+ new WorkerResultComponent(messageText(message.content), message.details, expanded, theme),
81
130
  );
82
131
  }
83
132
 
84
133
  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(" · ")}`;
134
+ const result = readSettlement(details);
135
+ return result ? statusHeading(result) : "Worker result details unavailable";
103
136
  }
104
137
 
105
138
  export function formatResultPreviews(
@@ -108,52 +141,32 @@ export function formatResultPreviews(
108
141
  limit = MAX_RESULT_PREVIEW_LINES,
109
142
  ): string[] {
110
143
  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;
144
+ const result = readSettlement(details);
145
+ const body = result ? outcomeText(result.outcome) : fallbackContent;
146
+ return firstNonEmptyLines(body, limit);
127
147
  }
128
148
 
129
149
  export function formatWorkerUsage(usage: Partial<WorkerUsage> | undefined): string | undefined {
130
150
  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;
151
+ const parts = [
152
+ `${numberOrZero(usage.turns)}t`,
153
+ `${formatCompactNumber(numberOrZero(usage.contextTokens))} ctx`,
154
+ `↑${formatCompactNumber(numberOrZero(usage.input))}`,
155
+ `↓${formatCompactNumber(numberOrZero(usage.output))}`,
156
+ `R${formatCompactNumber(numberOrZero(usage.cacheRead))}`,
157
+ `W${formatCompactNumber(numberOrZero(usage.cacheWrite))}`,
158
+ `$${numberOrZero(usage.cost).toFixed(4)}`,
159
+ ];
160
+ return parts.join(" · ");
139
161
  }
140
162
 
141
163
  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}`;
164
+ return `${worker.worker} → ${worker.title} · ${workerStateLabel(worker)} · ${formatTurnMarker(worker)} · ${formatCompactNumber(numberOrZero(worker.usage?.contextTokens))} ctx`;
149
165
  }
150
166
 
151
167
  export function formatFooterStatus(snapshot: RuntimeSnapshot): string | undefined {
152
- const active = snapshot.workers.filter((worker) => ACTIVE_STATUSES.has(worker.status)).length;
153
168
  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`;
169
+ return ready > 0 ? `${ready} available for follow-up` : undefined;
157
170
  }
158
171
 
159
172
  export class StatusController {
@@ -162,6 +175,9 @@ export class StatusController {
162
175
  private refreshGeneration = 0;
163
176
  private disposed = false;
164
177
  private unsubscribeState: (() => void) | undefined;
178
+ private widget: WorkerStatusComponent | undefined;
179
+ private widgetInstalled = false;
180
+ private pendingSnapshot: RuntimeSnapshot | undefined;
165
181
 
166
182
  constructor(private readonly runtime: PresentationRuntime) {}
167
183
 
@@ -170,9 +186,8 @@ export class StatusController {
170
186
  this.clearBinding();
171
187
  this.bindingGeneration += 1;
172
188
  this.binding = { ownerSessionId, ctx };
173
- this.unsubscribeState = this.runtime.subscribeState((changedOwnerSessionId) => {
174
- if (changedOwnerSessionId !== this.binding?.ownerSessionId) return;
175
- void this.refresh();
189
+ this.unsubscribeState = this.runtime.subscribeState((changedOwner) => {
190
+ if (changedOwner === this.binding?.ownerSessionId) void this.refresh();
176
191
  });
177
192
  void this.refresh();
178
193
  }
@@ -187,25 +202,11 @@ export class StatusController {
187
202
  async refresh(): Promise<void> {
188
203
  const binding = this.binding;
189
204
  if (!binding || this.disposed) return;
190
-
191
205
  const bindingGeneration = this.bindingGeneration;
192
206
  const refreshGeneration = ++this.refreshGeneration;
193
207
  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
-
208
+ try { snapshot = await this.runtime.snapshot(binding.ownerSessionId); } catch { return; }
209
+ if (this.disposed || this.binding !== binding || this.bindingGeneration !== bindingGeneration || this.refreshGeneration !== refreshGeneration) return;
209
210
  this.present(binding.ctx, snapshot);
210
211
  }
211
212
 
@@ -218,33 +219,38 @@ export class StatusController {
218
219
  }
219
220
 
220
221
  private present(ctx: ExtensionContext, snapshot: RuntimeSnapshot): void {
221
- const footer = formatFooterStatus(snapshot);
222
- ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, footer);
223
-
222
+ ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, formatFooterStatus(snapshot));
224
223
  if (ctx.mode !== "tui") return;
225
- if (widgetWorkerCount(snapshot) === 0) {
226
- ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
224
+ const active = activeWorkers(snapshot);
225
+ this.pendingSnapshot = snapshot;
226
+ if (active.length === 0) {
227
+ if (this.widgetInstalled) ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
228
+ this.widget = undefined;
229
+ this.widgetInstalled = false;
227
230
  return;
228
231
  }
229
-
230
- ctx.ui.setWidget(
231
- ORCHESTRATION_PRESENTATION_KEY,
232
- (_tui, theme) => new WorkerStatusComponent(snapshot, theme),
233
- { placement: "aboveEditor" },
234
- );
232
+ if (this.widget) {
233
+ this.widget.update(snapshot);
234
+ return;
235
+ }
236
+ if (this.widgetInstalled) return;
237
+ ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, (tui, theme) => {
238
+ this.widget = new WorkerStatusComponent(this.pendingSnapshot ?? snapshot, theme, tui);
239
+ return this.widget;
240
+ }, { placement: "aboveEditor" });
241
+ this.widgetInstalled = true;
235
242
  }
236
243
 
237
244
  private clearBinding(): void {
238
245
  this.unsubscribeState?.();
239
246
  this.unsubscribeState = undefined;
240
-
247
+ this.widget = undefined;
248
+ this.pendingSnapshot = undefined;
241
249
  const current = this.binding;
242
250
  if (!current) return;
243
-
244
251
  current.ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, undefined);
245
- if (current.ctx.mode === "tui") {
246
- current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
247
- }
252
+ if (current.ctx.mode === "tui" && this.widgetInstalled) current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
253
+ this.widgetInstalled = false;
248
254
  this.binding = undefined;
249
255
  }
250
256
  }
@@ -253,377 +259,248 @@ export function createStatusController(runtime: PresentationRuntime): StatusCont
253
259
  return new StatusController(runtime);
254
260
  }
255
261
 
256
- class BoundedLines implements Component {
257
- constructor(private readonly lines: readonly string[]) {}
262
+ export class WorkerStatusComponent implements Component {
263
+ private frameIndex = 0;
264
+ private snapshot: RuntimeSnapshot;
265
+ private timer: ReturnType<typeof setInterval> | undefined;
258
266
 
259
- render(width: number): string[] {
260
- const boundedWidth = Math.max(1, width);
261
- return this.lines.map((line) => truncateToWidth(line, boundedWidth, "…"));
267
+ constructor(snapshot: RuntimeSnapshot, private readonly theme: Theme, private readonly tui?: RenderRequester) {
268
+ this.snapshot = snapshot;
269
+ this.startTimer();
262
270
  }
263
271
 
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
- ) {}
272
+ update(snapshot: RuntimeSnapshot): void {
273
+ this.snapshot = snapshot;
274
+ if (activeWorkers(snapshot).length > 0) this.startTimer();
275
+ else this.stopTimer();
276
+ this.tui?.requestRender();
277
+ }
278
278
 
279
279
  render(width: number): string[] {
280
280
  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
- }
281
+ const active = activeWorkers(this.snapshot);
282
+ if (active.length === 0) return [];
283
+ const oldest = Math.min(...active.map((worker) => worker.startedAt));
284
+ const elapsed = formatElapsed(Math.max(0, Date.now() - oldest));
285
+ const lines = [this.theme.fg("toolTitle", this.theme.bold(`Workers · ${active.length} active · ${elapsed}`))];
286
+ for (const worker of active.slice(0, MAX_WIDGET_WORKERS)) lines.push(this.workerLine(worker, boundedWidth));
287
+ if (active.length > MAX_WIDGET_WORKERS) lines.push(this.theme.fg("dim", `… ${active.length - MAX_WIDGET_WORKERS} more active`));
311
288
  return lines.map((line) => truncateToWidth(line, boundedWidth, "…"));
312
289
  }
313
290
 
314
291
  invalidate(): void {}
292
+ dispose(): void { this.stopTimer(); }
293
+
294
+ private startTimer(): void {
295
+ if (this.timer || activeWorkers(this.snapshot).length === 0) return;
296
+ this.timer = setInterval(() => {
297
+ this.frameIndex = (this.frameIndex + 1) % ANIMATION_CYCLE_TICKS;
298
+ this.tui?.requestRender();
299
+ }, SPINNER_INTERVAL_MS);
300
+ const timer = this.timer;
301
+ if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") timer.unref();
302
+ }
315
303
 
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
- );
304
+ private stopTimer(): void {
305
+ if (!this.timer) return;
306
+ clearInterval(this.timer);
307
+ this.timer = undefined;
323
308
  }
324
309
 
325
310
  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, "…");
311
+ const animation = worker.status === "starting"
312
+ ? WORKER_ANIMATIONS.starting
313
+ : worker.status === "stopping"
314
+ ? WORKER_ANIMATIONS.stopping
315
+ : WORKER_ANIMATIONS.running;
316
+ const glyph = this.theme.fg(
317
+ animation.color,
318
+ animation.frames[this.frameIndex % animation.frames.length] ?? animation.frames[0],
319
+ );
320
+ const turns = formatTurnMarker(worker);
321
+ const context = `${formatContextTokens(numberOrZero(worker.usage?.contextTokens))} ctx`;
322
+ const suffixFields = width >= 28 ? [turns, context] : width >= 12 ? [turns] : [];
323
+ const requiredWidth = (fields: readonly string[]) => visibleWidth(`⠋ · ${fields.join(" · ")}`) + 10;
324
+ const showWorker = width >= 72 && requiredWidth([worker.worker, ...suffixFields]) <= width;
325
+ const prefix = showWorker ? `${glyph} ${this.theme.fg("muted", worker.worker)} → ` : `${glyph} `;
326
+ const suffix = suffixFields.length ? ` · ${suffixFields.join(" · ")}` : "";
327
+ const titleWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(suffix));
328
+ const title = truncateToWidth(this.theme.fg("text", this.theme.bold(worker.title)), titleWidth, "…");
329
+ return `${prefix}${title}${suffix}`;
350
330
  }
351
331
  }
352
332
 
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];
333
+ class WidthBoundComponent implements Component {
334
+ constructor(private readonly child: Component, private readonly maxLines?: number) {}
335
+ render(width: number): string[] {
336
+ const bounded = Math.max(1, Math.floor(width));
337
+ const lines = this.child.render(bounded);
338
+ const selected = this.maxLines === undefined ? lines : lines.slice(0, this.maxLines);
339
+ return selected.map((line) => truncateToWidth(line, bounded, "…"));
340
+ }
341
+ invalidate(): void { this.child.invalidate(); }
342
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
364
343
  }
365
344
 
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
- }
345
+ export class WorkerResultComponent implements Component {
346
+ private child: Component;
389
347
 
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));
348
+ constructor(
349
+ private readonly content: string,
350
+ private readonly rawDetails: unknown,
351
+ private readonly expanded: boolean,
352
+ private readonly theme: Theme,
353
+ ) {
354
+ this.child = this.build();
407
355
  }
408
- return container;
409
- }
410
356
 
411
- function readDeliveryDetails(value: unknown): SafeDetails | undefined {
412
- if (!isRecord(value) || typeof value.id !== "string" || !Array.isArray(value.results)) {
413
- return undefined;
357
+ render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
358
+ invalidate(): void {
359
+ (this.child as Component & { dispose?: () => void }).dispose?.();
360
+ this.child = this.build();
414
361
  }
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;
362
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
363
+
364
+ private build(): Component {
365
+ const details = readSettlement(this.rawDetails);
366
+ const box = new Box(1, 1, (text) => this.theme.bg("customMessageBg", text));
367
+ if (!details) {
368
+ box.addChild(new Text(this.theme.fg("warning", this.theme.bold("Worker result details unavailable")), 0, 0));
369
+ box.addChild(new WidthBoundComponent(new Markdown(this.content, 0, 0, getMarkdownTheme()), this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES));
370
+ if (!this.expanded) box.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to expand")), 0, 0));
371
+ return box;
428
372
  }
429
373
 
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
- });
374
+ const color = details.status === "failed" ? "error" : details.status === "aborted" ? "warning" : "success";
375
+ const elapsed = elapsedBetween(details.startedAt, details.settledAt);
376
+ const status = details.status === "aborted"
377
+ ? " · aborted"
378
+ : details.status === "failed" && details.failureStage === "startup"
379
+ ? " · could not start"
380
+ : "";
381
+ const header = `${statusIcon(details)} ${details.worker} · ${details.title}${status}${elapsed ? ` · ${elapsed}` : ""}`;
382
+ const outcome = presentedOutcome(details);
383
+ box.addChild(new Text(this.theme.fg(color, this.theme.bold(header)), 0, 0));
384
+ box.addChild(new Spacer(1));
385
+ if (outcome.heading) {
386
+ box.addChild(new Text(this.theme.fg("success", this.theme.bold(outcome.heading)), 0, 0));
387
+ if (outcome.body) box.addChild(new Spacer(1));
388
+ }
389
+ if (outcome.body) {
390
+ box.addChild(new WidthBoundComponent(
391
+ new Markdown(outcome.body, 0, 0, getMarkdownTheme()),
392
+ this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES,
393
+ ));
394
+ }
395
+ if (!this.expanded) {
396
+ box.addChild(new Spacer(1));
397
+ box.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to expand")), 0, 0));
398
+ return box;
399
+ }
400
+ box.addChild(new Spacer(1));
401
+ box.addChild(new Text(this.theme.fg("toolTitle", this.theme.bold("Worker details")), 0, 0));
402
+ for (const line of settlementMetadata(details)) box.addChild(new Text(this.theme.fg("dim", line), 0, 0));
403
+ return box;
441
404
  }
442
- return { id: value.id, results };
443
405
  }
444
406
 
445
- function messageText(content: unknown): string {
446
- if (typeof content === "string") return content;
447
- if (!Array.isArray(content)) return "";
448
-
449
- return content.flatMap((part) => {
450
- if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
451
- return [part.text];
452
- }
453
- return [];
454
- }).join("\n");
407
+ function readSettlement(value: unknown): SafeSettlement | undefined {
408
+ const envelope = isRecord(value) && isRecord(value.settlement) ? value : { settlement: value };
409
+ const decoded = decodeSettlementEnvelope(envelope);
410
+ return Result.isSuccess(decoded) ? decoded.success.settlement : undefined;
455
411
  }
456
412
 
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;
413
+ function statusHeading(result: SafeSettlement): string {
414
+ if (result.status === "failed") return result.failureStage === "startup" ? "could not start" : "failed";
415
+ if (result.status === "aborted") return "aborted";
416
+ if (result.status === "ready") return "response complete";
417
+ return "completed";
466
418
  }
467
419
 
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");
420
+ function statusIcon(result: SafeSettlement): string {
421
+ if (result.status === "failed") return "✗";
422
+ if (result.status === "aborted") return "■";
423
+ return "✓";
474
424
  }
475
425
 
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;
488
- }
489
- case "closed":
490
- return "Closed";
491
- }
426
+ function outcomeText(outcome: WorkerOutcome): string {
427
+ if (outcome.status === "completed" || outcome.status === "ready") return outcome.assistantText;
428
+ if (outcome.status === "failed") return outcome.assistantText ? `${outcome.message}\n\n${outcome.assistantText}` : outcome.message;
429
+ if (outcome.status === "aborted") return [outcome.message || "Worker was aborted.", outcome.assistantText].filter(Boolean).join("\n\n");
430
+ return "Worker session closed.";
492
431
  }
493
432
 
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" };
517
- return undefined;
518
- }
433
+ function presentedOutcome(result: SafeSettlement): { heading?: string; body: string } {
434
+ const body = outcomeText(result.outcome);
435
+ if (result.status !== "completed" && result.status !== "ready") return { body };
519
436
 
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;
437
+ const lines = body.split("\n");
438
+ const headingIndex = lines.findIndex((line) => line.trim() !== "");
439
+ if (headingIndex < 0 || !/^#{1,6}\s+(?:completed|complete|done)\s*#*\s*$/i.test(lines[headingIndex]!)) {
440
+ return { body };
528
441
  }
529
- return lines;
530
- }
531
442
 
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
- });
443
+ lines.splice(headingIndex, 1);
444
+ while (lines[headingIndex]?.trim() === "") lines.splice(headingIndex, 1);
445
+ return {
446
+ heading: result.status === "ready" ? "✓ Response complete" : "✓ Completed",
447
+ body: lines.join("\n").trimEnd(),
448
+ };
543
449
  }
544
450
 
545
- function readyWorkers(snapshot: RuntimeSnapshot): WorkerRecord[] {
546
- return snapshot.workers.filter((worker) => worker.status === "ready");
451
+ function settlementMetadata(result: SafeSettlement): string[] {
452
+ return [
453
+ `worker ID ${result.workerId} · wave ID ${result.waveId}`,
454
+ `status ${result.status} · generation ${result.generation}`,
455
+ `turns ${numberOrZero(result.usage.turns)} · current context ${formatCompactNumber(numberOrZero(result.usage.contextTokens))}`,
456
+ `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)}`,
457
+ `session ${result.sessionFile ?? "unavailable"}`,
458
+ ];
547
459
  }
548
460
 
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;
461
+ function messageText(content: unknown): string {
462
+ if (typeof content === "string") return content;
463
+ if (!Array.isArray(content)) return "";
464
+ return content.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
554
465
  }
555
466
 
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
- };
467
+ function activeWorkers(snapshot: RuntimeSnapshot): WorkerRecord[] {
468
+ return snapshot.workers.filter((worker) => ACTIVE_STATUSES.has(worker.status));
469
+ }
565
470
 
471
+ const TOOL_ACTIVITY: Readonly<Record<string, string>> = { read: "reading", grep: "searching", find: "finding files", ls: "listing", bash: "running command", edit: "editing", write: "writing" };
566
472
  function workerStateLabel(worker: Pick<WorkerRecord, "status" | "activity">): string {
567
473
  if (worker.status === "starting" || worker.status === "stopping") return worker.status;
568
474
  if (worker.status !== "running") return worker.status;
569
- if (!worker.activity?.trim()) return "thinking";
475
+ if (!worker.activity?.trim()) return "working";
570
476
  return TOOL_ACTIVITY[worker.activity] ?? worker.activity;
571
477
  }
572
478
 
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;
479
+ function formatTurnMarker(worker: Pick<WorkerRecord, "messageDirection" | "usage">): string {
480
+ const direction = worker.messageDirection === "from-model" ? "↓" : "↑";
481
+ return `${numberOrZero(worker.usage?.turns)}${direction}`;
579
482
  }
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;
483
+ function elapsedBetween(start?: number, end?: number): string | undefined {
484
+ return start !== undefined && end !== undefined && end >= start ? formatElapsed(end - start) : undefined;
485
+ }
486
+ function formatElapsed(milliseconds: number): string {
487
+ const seconds = Math.floor(milliseconds / 1000);
488
+ if (seconds < 60) return `${seconds}s`;
489
+ const minutes = Math.floor(seconds / 60);
490
+ return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`;
491
+ }
492
+ function firstNonEmptyLines(text: string, limit: number): string[] {
493
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, limit);
494
+ }
495
+ function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
496
+ function formatContextTokens(value: number): string {
497
+ if (value < 1_000) return String(Math.round(value));
498
+ return `${Math.round(value / 1_000)}k`;
588
499
  }
589
-
590
500
  function formatCompactNumber(value: number): string {
591
501
  if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`;
592
502
  if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`;
593
503
  return String(Math.round(value));
594
504
  }
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
- }
505
+ function trimDecimal(value: number): string { return value.toFixed(1).replace(/\.0$/, ""); }
506
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; }