@zachwill/pi-orchestrate 0.8.0 → 0.9.2

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,86 +80,67 @@ 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;
79
- private widgetInstalled = false;
80
- private pendingSnapshot: RuntimeSnapshot | undefined;
81
86
 
82
87
  constructor(private readonly runtime: PresentationRuntime) {}
83
88
 
84
89
  bind(ownerSessionId: string, ctx: ExtensionContext): void {
85
90
  if (this.disposed) return;
86
91
  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();
92
+ const binding = { ownerSessionId, ctx };
93
+ this.binding = binding;
94
+ this.unsubscribeState = this.runtime.subscribeState(ownerSessionId, (snapshot) => {
95
+ if (this.binding !== binding) return;
96
+ this.present(binding.ctx, snapshot);
91
97
  });
92
- void this.refresh();
93
98
  }
94
99
 
95
100
  unbind(ownerSessionId?: string): void {
96
101
  if (ownerSessionId !== undefined && ownerSessionId !== this.binding?.ownerSessionId) return;
97
102
  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
103
  }
112
104
 
113
105
  dispose(): void {
114
106
  if (this.disposed) return;
115
107
  this.disposed = true;
116
108
  this.clearBinding();
117
- this.bindingGeneration += 1;
118
- this.refreshGeneration += 1;
119
109
  }
120
110
 
121
111
  private present(ctx: ExtensionContext, snapshot: RuntimeSnapshot): void {
122
112
  ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, formatFooterStatus(snapshot));
123
113
  if (ctx.mode !== "tui") return;
124
114
  const active = activeWorkers(snapshot);
125
- this.pendingSnapshot = snapshot;
126
115
  if (active.length === 0) {
127
- if (this.widgetInstalled) ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
128
- this.widget = undefined;
129
- this.widgetInstalled = false;
116
+ if (this.widget !== undefined) {
117
+ ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
118
+ this.widget = undefined;
119
+ }
130
120
  return;
131
121
  }
132
- if (this.widget) {
122
+ if (this.widget !== undefined) {
133
123
  this.widget.update(snapshot);
134
124
  return;
135
125
  }
136
- if (this.widgetInstalled) return;
137
126
  ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, (tui, theme) => {
138
- this.widget = new WorkerStatusComponent(this.pendingSnapshot ?? snapshot, theme, tui);
139
- return this.widget;
127
+ const widget = new WorkerStatusComponent(snapshot, theme, tui);
128
+ this.widget = widget;
129
+ return widget;
140
130
  }, { placement: "aboveEditor" });
141
- this.widgetInstalled = true;
142
131
  }
143
132
 
144
133
  private clearBinding(): void {
145
- this.unsubscribeState?.();
134
+ const unsubscribeState = this.unsubscribeState;
146
135
  this.unsubscribeState = undefined;
147
- this.widget = undefined;
148
- this.pendingSnapshot = undefined;
136
+ unsubscribeState?.();
149
137
  const current = this.binding;
150
138
  if (!current) return;
151
139
  current.ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, undefined);
152
- if (current.ctx.mode === "tui" && this.widgetInstalled) current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
153
- this.widgetInstalled = false;
140
+ if (current.ctx.mode === "tui" && this.widget !== undefined) {
141
+ current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
142
+ this.widget = undefined;
143
+ }
154
144
  this.binding = undefined;
155
145
  }
156
146
  }
@@ -235,18 +225,6 @@ export class WorkerStatusComponent implements Component {
235
225
  }
236
226
  }
237
227
 
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
228
  export class WorkerResultComponent implements Component {
251
229
  private child: Component;
252
230
 
@@ -261,10 +239,10 @@ export class WorkerResultComponent implements Component {
261
239
 
262
240
  render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
263
241
  invalidate(): void {
264
- (this.child as Component & { dispose?: () => void }).dispose?.();
242
+ disposeComponent(this.child);
265
243
  this.child = this.build();
266
244
  }
267
- dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
245
+ dispose(): void { disposeComponent(this.child); }
268
246
 
269
247
  private build(): Component {
270
248
  const details = readSettlement(this.rawDetails);
@@ -276,16 +254,19 @@ export class WorkerResultComponent implements Component {
276
254
  return box;
277
255
  }
278
256
 
279
- const color = resultColor(details.status);
280
- const elapsed = elapsedBetween(details.startedAt, details.settledAt);
281
- const qualifier = resultQualifier(details);
257
+ const appearance = resultAppearance(
258
+ details.status,
259
+ "interactive ready",
260
+ details.failureStage === "startup" ? "could not start" : "failed",
261
+ );
262
+ const elapsed = formatElapsed(details.settledAt - details.startedAt);
282
263
  const title = this.theme.bold(details.title);
283
264
  const workerName = this.theme.fg("muted", this.theme.italic(details.worker));
284
- const suffix = [qualifier, elapsed].filter(Boolean).join(" · ");
265
+ const suffix = [appearance.qualifier, elapsed].filter(Boolean).join(" · ");
285
266
  const header = [
286
- this.theme.fg(color, `${statusIcon(details)} ${title}`),
267
+ this.theme.fg(appearance.color, `${appearance.icon} ${title}`),
287
268
  workerName,
288
- ...(suffix ? [this.theme.fg(color, suffix)] : []),
269
+ ...(suffix ? [this.theme.fg(appearance.color, suffix)] : []),
289
270
  ].join(" · ");
290
271
  const outcome = presentedOutcome(details);
291
272
  box.addChild(new Text(header, 0, 0));
@@ -308,7 +289,7 @@ export class WorkerResultComponent implements Component {
308
289
  }
309
290
  }
310
291
 
311
- function readSettlement(value: unknown): DecodedSettlement | undefined {
292
+ function readSettlement(value: unknown): WorkerSettlementDetails | undefined {
312
293
  const decoded = decodePersistedWorkerSettlementDetails(value);
313
294
  return Result.isSuccess(decoded) ? decoded.success : undefined;
314
295
  }
@@ -319,28 +300,6 @@ function workerAnimation(status: WorkerStatus) {
319
300
  return WORKER_ANIMATIONS.running;
320
301
  }
321
302
 
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
303
  function outcomeText(outcome: WorkerOutcome): string {
345
304
  if (outcome.status === "completed" || outcome.status === "ready") return outcome.assistantText;
346
305
  if (outcome.status === "failed") return outcome.assistantText ? `${outcome.message}\n\n${outcome.assistantText}` : outcome.message;
@@ -348,7 +307,7 @@ function outcomeText(outcome: WorkerOutcome): string {
348
307
  return "Worker session closed.";
349
308
  }
350
309
 
351
- function presentedOutcome(result: DecodedSettlement): string {
310
+ function presentedOutcome(result: WorkerSettlementDetails): string {
352
311
  const body = outcomeText(result.outcome);
353
312
  if (result.status !== "completed" && result.status !== "ready") return body;
354
313
 
@@ -363,12 +322,12 @@ function presentedOutcome(result: DecodedSettlement): string {
363
322
  return lines.join("\n").trimEnd();
364
323
  }
365
324
 
366
- function settlementMetadata(result: DecodedSettlement): string[] {
325
+ function settlementMetadata(result: WorkerSettlementDetails): string[] {
367
326
  return [
368
327
  `worker ID ${result.workerId} · run ID ${result.runId}`,
369
328
  `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)}`,
329
+ `turns ${result.usage.turns} · current context ${formatCompactNumber(result.usage.contextTokens)}`,
330
+ `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
331
  `session ${result.sessionFile ?? "unavailable"}`,
373
332
  ];
374
333
  }
@@ -387,15 +346,6 @@ function formatTurnMarker(worker: Pick<WorkerRecord, "messageDirection" | "usage
387
346
  const direction = worker.messageDirection === "from-model" ? "↓" : "↑";
388
347
  return `${numberOrZero(worker.usage?.turns)}${direction}`;
389
348
  }
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
349
  function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
400
350
  function formatContextTokens(value: number): string {
401
351
  if (value < 1_000) return String(Math.round(value));