@zachwill/pi-orchestrate 0.8.0 → 0.9.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.
@@ -17,7 +17,13 @@ import {
17
17
  import { Result } from "effect";
18
18
  import type { WorkerDeliveryDetails } from "./delivery.js";
19
19
  import type { WorkerOutcome, WorkerRecord, WorkerStatus } from "./domain.js";
20
- import type { OrchestratorRuntime, RuntimeSnapshot } from "./runtime.js";
20
+ import type { RuntimeSnapshot } from "./runtime.js";
21
+ import {
22
+ disposeComponent,
23
+ formatElapsed,
24
+ resultAppearance,
25
+ WidthBoundComponent,
26
+ } from "./tui.js";
21
27
  import {
22
28
  decodePersistedWorkerSettlementDetails,
23
29
  type WorkerSettlementDetails,
@@ -45,9 +51,12 @@ const ANIMATION_CYCLE_TICKS = 40;
45
51
  const SPINNER_INTERVAL_MS = 140;
46
52
  const ACTIVE_STATUSES: ReadonlySet<WorkerStatus> = new Set(["starting", "running", "stopping"]);
47
53
 
48
- export type PresentationRuntime = Pick<OrchestratorRuntime, "snapshot" | "subscribeState">;
49
-
50
- type DecodedSettlement = WorkerSettlementDetails;
54
+ export interface PresentationRuntime {
55
+ subscribeState(
56
+ ownerSessionId: string,
57
+ listener: (snapshot: RuntimeSnapshot) => void,
58
+ ): () => void;
59
+ }
51
60
 
52
61
  interface StatusBinding {
53
62
  readonly ownerSessionId: string;
@@ -71,8 +80,6 @@ export function formatFooterStatus(snapshot: RuntimeSnapshot): string | undefine
71
80
 
72
81
  export class StatusController {
73
82
  private binding: StatusBinding | undefined;
74
- private bindingGeneration = 0;
75
- private refreshGeneration = 0;
76
83
  private disposed = false;
77
84
  private unsubscribeState: (() => void) | undefined;
78
85
  private widget: WorkerStatusComponent | undefined;
@@ -84,38 +91,23 @@ export class StatusController {
84
91
  bind(ownerSessionId: string, ctx: ExtensionContext): void {
85
92
  if (this.disposed) return;
86
93
  this.clearBinding();
87
- this.bindingGeneration += 1;
88
- this.binding = { ownerSessionId, ctx };
89
- this.unsubscribeState = this.runtime.subscribeState((changedOwner) => {
90
- if (changedOwner === this.binding?.ownerSessionId) void this.refresh();
94
+ const binding = { ownerSessionId, ctx };
95
+ this.binding = binding;
96
+ this.unsubscribeState = this.runtime.subscribeState(ownerSessionId, (snapshot) => {
97
+ if (this.binding !== binding) return;
98
+ this.present(binding.ctx, snapshot);
91
99
  });
92
- void this.refresh();
93
100
  }
94
101
 
95
102
  unbind(ownerSessionId?: string): void {
96
103
  if (ownerSessionId !== undefined && ownerSessionId !== this.binding?.ownerSessionId) return;
97
104
  this.clearBinding();
98
- this.bindingGeneration += 1;
99
- this.refreshGeneration += 1;
100
- }
101
-
102
- async refresh(): Promise<void> {
103
- const binding = this.binding;
104
- if (!binding || this.disposed) return;
105
- const bindingGeneration = this.bindingGeneration;
106
- const refreshGeneration = ++this.refreshGeneration;
107
- let snapshot: RuntimeSnapshot;
108
- try { snapshot = await this.runtime.snapshot(binding.ownerSessionId); } catch { return; }
109
- if (this.disposed || this.binding !== binding || this.bindingGeneration !== bindingGeneration || this.refreshGeneration !== refreshGeneration) return;
110
- this.present(binding.ctx, snapshot);
111
105
  }
112
106
 
113
107
  dispose(): void {
114
108
  if (this.disposed) return;
115
109
  this.disposed = true;
116
110
  this.clearBinding();
117
- this.bindingGeneration += 1;
118
- this.refreshGeneration += 1;
119
111
  }
120
112
 
121
113
  private present(ctx: ExtensionContext, snapshot: RuntimeSnapshot): void {
@@ -142,8 +134,9 @@ export class StatusController {
142
134
  }
143
135
 
144
136
  private clearBinding(): void {
145
- this.unsubscribeState?.();
137
+ const unsubscribeState = this.unsubscribeState;
146
138
  this.unsubscribeState = undefined;
139
+ unsubscribeState?.();
147
140
  this.widget = undefined;
148
141
  this.pendingSnapshot = undefined;
149
142
  const current = this.binding;
@@ -235,18 +228,6 @@ export class WorkerStatusComponent implements Component {
235
228
  }
236
229
  }
237
230
 
238
- class WidthBoundComponent implements Component {
239
- constructor(private readonly child: Component, private readonly maxLines?: number) {}
240
- render(width: number): string[] {
241
- const bounded = Math.max(1, Math.floor(width));
242
- const lines = this.child.render(bounded);
243
- const selected = this.maxLines === undefined ? lines : lines.slice(0, this.maxLines);
244
- return selected.map((line) => truncateToWidth(line, bounded, "…"));
245
- }
246
- invalidate(): void { this.child.invalidate(); }
247
- dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
248
- }
249
-
250
231
  export class WorkerResultComponent implements Component {
251
232
  private child: Component;
252
233
 
@@ -261,10 +242,10 @@ export class WorkerResultComponent implements Component {
261
242
 
262
243
  render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
263
244
  invalidate(): void {
264
- (this.child as Component & { dispose?: () => void }).dispose?.();
245
+ disposeComponent(this.child);
265
246
  this.child = this.build();
266
247
  }
267
- dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
248
+ dispose(): void { disposeComponent(this.child); }
268
249
 
269
250
  private build(): Component {
270
251
  const details = readSettlement(this.rawDetails);
@@ -276,16 +257,19 @@ export class WorkerResultComponent implements Component {
276
257
  return box;
277
258
  }
278
259
 
279
- const color = resultColor(details.status);
280
- const elapsed = elapsedBetween(details.startedAt, details.settledAt);
281
- const qualifier = resultQualifier(details);
260
+ const appearance = resultAppearance(
261
+ details.status,
262
+ "interactive ready",
263
+ details.failureStage === "startup" ? "could not start" : "failed",
264
+ );
265
+ const elapsed = formatElapsed(details.settledAt - details.startedAt);
282
266
  const title = this.theme.bold(details.title);
283
267
  const workerName = this.theme.fg("muted", this.theme.italic(details.worker));
284
- const suffix = [qualifier, elapsed].filter(Boolean).join(" · ");
268
+ const suffix = [appearance.qualifier, elapsed].filter(Boolean).join(" · ");
285
269
  const header = [
286
- this.theme.fg(color, `${statusIcon(details)} ${title}`),
270
+ this.theme.fg(appearance.color, `${appearance.icon} ${title}`),
287
271
  workerName,
288
- ...(suffix ? [this.theme.fg(color, suffix)] : []),
272
+ ...(suffix ? [this.theme.fg(appearance.color, suffix)] : []),
289
273
  ].join(" · ");
290
274
  const outcome = presentedOutcome(details);
291
275
  box.addChild(new Text(header, 0, 0));
@@ -308,7 +292,7 @@ export class WorkerResultComponent implements Component {
308
292
  }
309
293
  }
310
294
 
311
- function readSettlement(value: unknown): DecodedSettlement | undefined {
295
+ function readSettlement(value: unknown): WorkerSettlementDetails | undefined {
312
296
  const decoded = decodePersistedWorkerSettlementDetails(value);
313
297
  return Result.isSuccess(decoded) ? decoded.success : undefined;
314
298
  }
@@ -319,28 +303,6 @@ function workerAnimation(status: WorkerStatus) {
319
303
  return WORKER_ANIMATIONS.running;
320
304
  }
321
305
 
322
- function resultColor(status: DecodedSettlement["status"]): "success" | "error" | "warning" {
323
- if (status === "failed") return "error";
324
- if (status === "aborted") return "warning";
325
- return "success";
326
- }
327
-
328
- function resultQualifier(result: DecodedSettlement): string | undefined {
329
- if (result.status === "aborted") return "aborted";
330
- if (result.status === "failed" && result.failureStage === "startup") {
331
- return "could not start";
332
- }
333
- if (result.status === "failed") return "failed";
334
- if (result.status === "ready") return "interactive ready";
335
- return undefined;
336
- }
337
-
338
- function statusIcon(result: DecodedSettlement): string {
339
- if (result.status === "failed") return "✗";
340
- if (result.status === "aborted") return "■";
341
- return "✓";
342
- }
343
-
344
306
  function outcomeText(outcome: WorkerOutcome): string {
345
307
  if (outcome.status === "completed" || outcome.status === "ready") return outcome.assistantText;
346
308
  if (outcome.status === "failed") return outcome.assistantText ? `${outcome.message}\n\n${outcome.assistantText}` : outcome.message;
@@ -348,7 +310,7 @@ function outcomeText(outcome: WorkerOutcome): string {
348
310
  return "Worker session closed.";
349
311
  }
350
312
 
351
- function presentedOutcome(result: DecodedSettlement): string {
313
+ function presentedOutcome(result: WorkerSettlementDetails): string {
352
314
  const body = outcomeText(result.outcome);
353
315
  if (result.status !== "completed" && result.status !== "ready") return body;
354
316
 
@@ -363,12 +325,12 @@ function presentedOutcome(result: DecodedSettlement): string {
363
325
  return lines.join("\n").trimEnd();
364
326
  }
365
327
 
366
- function settlementMetadata(result: DecodedSettlement): string[] {
328
+ function settlementMetadata(result: WorkerSettlementDetails): string[] {
367
329
  return [
368
330
  `worker ID ${result.workerId} · run ID ${result.runId}`,
369
331
  `status ${result.status} · generation ${result.generation}`,
370
- `turns ${numberOrZero(result.usage.turns)} · current context ${formatCompactNumber(numberOrZero(result.usage.contextTokens))}`,
371
- `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)}`,
332
+ `turns ${result.usage.turns} · current context ${formatCompactNumber(result.usage.contextTokens)}`,
333
+ `input ${result.usage.input} · output ${result.usage.output} · cache read ${result.usage.cacheRead} · cache write ${result.usage.cacheWrite} · cost $${result.usage.cost.toFixed(4)}`,
372
334
  `session ${result.sessionFile ?? "unavailable"}`,
373
335
  ];
374
336
  }
@@ -387,15 +349,6 @@ function formatTurnMarker(worker: Pick<WorkerRecord, "messageDirection" | "usage
387
349
  const direction = worker.messageDirection === "from-model" ? "↓" : "↑";
388
350
  return `${numberOrZero(worker.usage?.turns)}${direction}`;
389
351
  }
390
- function elapsedBetween(start?: number, end?: number): string | undefined {
391
- return start !== undefined && end !== undefined && end >= start ? formatElapsed(end - start) : undefined;
392
- }
393
- function formatElapsed(milliseconds: number): string {
394
- const seconds = Math.floor(milliseconds / 1000);
395
- if (seconds < 60) return `${seconds}s`;
396
- const minutes = Math.floor(seconds / 60);
397
- return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`;
398
- }
399
352
  function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
400
353
  function formatContextTokens(value: number): string {
401
354
  if (value < 1_000) return String(Math.round(value));