effective-progress 0.11.0 → 0.12.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.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
2
- import { Brand, Cause, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Schema } from "effect";
2
+ import { Brand, Cause, Clock, Context, Effect, Exit, Layer, Option, Queue, Schema } from "effect";
3
3
  import { dual } from "effect/Function";
4
4
  import { Box, Text, render, useBoxMetrics } from "ink";
5
- import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import { createContext, use, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
  import cliSpinners from "cli-spinners";
8
8
  import stringWidth from "fast-string-width";
@@ -10,14 +10,22 @@ import stringWidth from "fast-string-width";
10
10
  //#region src/types.ts
11
11
  const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
12
12
  const TaskId = Brand.nominal();
13
- const TaskStatusSchema = Schema.Literal("running", "done", "failed");
14
- const TaskCountDisplaySchema = Schema.Literal("processedOnly", "detailed");
13
+ const TaskStatusSchema = Schema.Literals([
14
+ "running",
15
+ "done",
16
+ "failed"
17
+ ]);
18
+ const TaskCountDisplaySchema = Schema.Literals(["processedOnly", "detailed"]);
15
19
  const TaskUnitsSchema = Schema.Struct({
16
20
  succeeded: Schema.Number,
17
21
  failed: Schema.Number,
18
22
  processed: Schema.Number,
19
23
  total: Schema.optional(Schema.Number)
20
24
  });
25
+ const TaskProgressSampleSchema = Schema.Struct({
26
+ timestamp: Schema.Number,
27
+ processed: Schema.Number
28
+ });
21
29
  const TaskSnapshotSchema = Schema.Struct({
22
30
  id: TaskIdSchema,
23
31
  parentId: Schema.NullOr(TaskIdSchema),
@@ -28,10 +36,11 @@ const TaskSnapshotSchema = Schema.Struct({
28
36
  units: TaskUnitsSchema,
29
37
  startedAt: Schema.Number,
30
38
  completedAt: Schema.NullOr(Schema.Number),
39
+ progressSamples: Schema.Array(TaskProgressSampleSchema),
31
40
  metadata: Schema.Unknown
32
41
  });
33
42
  const TaskSnapshot = (snapshot) => snapshot;
34
- var Task = class extends Context.Tag("stromseng.dev/effective-progress/Task")() {};
43
+ var Task = class extends Context.Service()("stromseng.dev/effective-progress/Task") {};
35
44
  var TaskAddedEvent = class extends Schema.TaggedClass()("TaskAdded", {
36
45
  taskId: TaskIdSchema,
37
46
  parentId: Schema.NullOr(TaskIdSchema),
@@ -53,25 +62,30 @@ var TaskUpdatedEvent = class extends Schema.TaggedClass()("TaskUpdated", {
53
62
  var TaskAdvancedEvent = class extends Schema.TaggedClass()("TaskAdvanced", {
54
63
  taskId: TaskIdSchema,
55
64
  amount: Schema.Number,
56
- kind: Schema.Literal("succeeded", "failed")
65
+ kind: Schema.Literals(["succeeded", "failed"])
57
66
  }) {};
58
67
  var TaskCompletedEvent = class extends Schema.TaggedClass()("TaskCompleted", { taskId: TaskIdSchema }) {};
59
68
  var TaskFailedEvent = class extends Schema.TaggedClass()("TaskFailed", { taskId: TaskIdSchema }) {};
60
69
  var TaskRemovedEvent = class extends Schema.TaggedClass()("TaskRemoved", { taskId: TaskIdSchema }) {};
61
- const ProgressTaskEventSchema = Schema.Union(TaskAddedEvent, TaskUpdatedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskRemovedEvent);
70
+ const ProgressTaskEventSchema = Schema.Union([
71
+ TaskAddedEvent,
72
+ TaskUpdatedEvent,
73
+ TaskAdvancedEvent,
74
+ TaskCompletedEvent,
75
+ TaskFailedEvent,
76
+ TaskRemovedEvent
77
+ ]);
62
78
  const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema);
63
79
 
64
80
  //#endregion
65
- //#region src/renderer/store.ts
81
+ //#region src/services/store/store.ts
82
+ const ETA_SAMPLE_WINDOW_MILLIS = 3e4;
83
+ const ETA_SAMPLE_MAX_LENGTH = 1e3;
66
84
  const hasExplicitTotal = (options) => Object.prototype.hasOwnProperty.call(options, "total");
67
- const sanitizeTotalOnAdd = (total) => {
85
+ const sanitizeTotal = (total) => {
68
86
  if (total === void 0) return;
69
87
  return total < 0 ? void 0 : total;
70
88
  };
71
- const sanitizeTotalOnUpdate = (nextTotal) => {
72
- if (nextTotal === void 0) return;
73
- return nextTotal < 0 ? void 0 : nextTotal;
74
- };
75
89
  const normalizeUnits = (counts) => {
76
90
  const succeeded = Math.max(0, counts.succeeded);
77
91
  const failed = Math.max(0, counts.failed);
@@ -86,38 +100,41 @@ const normalizeUnits = (counts) => {
86
100
  total: counts.total
87
101
  };
88
102
  };
89
- const updatedSnapshot = (snapshot, options) => {
103
+ /**
104
+ * Returns a new progress sample deque with the latest processed count appended.
105
+ *
106
+ * Samples are retained for the ETA rolling window and capped by count so very chatty tasks do not
107
+ * grow memory without bound.
108
+ */
109
+ const appendProgressSample = (samples, now, processed) => {
110
+ const previousSamples = samples ?? [];
111
+ if (previousSamples.at(-1)?.processed === processed) return previousSamples;
112
+ const windowStart = now - ETA_SAMPLE_WINDOW_MILLIS;
113
+ const appendedLength = previousSamples.length + 1;
114
+ let firstRetainedIndex = Math.max(0, appendedLength - ETA_SAMPLE_MAX_LENGTH);
115
+ while (firstRetainedIndex + 1 < previousSamples.length && previousSamples[firstRetainedIndex + 1].timestamp < windowStart) firstRetainedIndex++;
116
+ return [...previousSamples.slice(firstRetainedIndex), {
117
+ timestamp: now,
118
+ processed
119
+ }];
120
+ };
121
+ /** Applies mutable task fields and records a progress sample when the processed count changes. */
122
+ const updatedSnapshot = (snapshot, options, now) => {
90
123
  const currentUnits = snapshot.units;
91
124
  const units = options.succeeded === void 0 && options.failed === void 0 && options.total === void 0 && !hasExplicitTotal(options) ? currentUnits : normalizeUnits({
92
125
  succeeded: options.succeeded ?? currentUnits.succeeded,
93
126
  failed: options.failed ?? currentUnits.failed,
94
- total: hasExplicitTotal(options) ? sanitizeTotalOnUpdate(options.total) : currentUnits.total
127
+ total: hasExplicitTotal(options) ? sanitizeTotal(options.total) : currentUnits.total
95
128
  });
96
129
  return TaskSnapshot({
97
- id: snapshot.id,
98
- parentId: snapshot.parentId,
130
+ ...snapshot,
99
131
  description: options.description ?? snapshot.description,
100
- status: snapshot.status,
101
132
  countDisplay: options.countDisplay ?? snapshot.countDisplay,
102
133
  transient: options.transient ?? snapshot.transient,
103
134
  units,
104
- startedAt: snapshot.startedAt,
105
- completedAt: snapshot.completedAt,
106
- metadata: snapshot.metadata
135
+ progressSamples: appendProgressSample(snapshot.progressSamples, now, units.processed)
107
136
  });
108
137
  };
109
- const withTransient = (snapshot, transient) => TaskSnapshot({
110
- id: snapshot.id,
111
- parentId: snapshot.parentId,
112
- description: snapshot.description,
113
- status: snapshot.status,
114
- countDisplay: snapshot.countDisplay,
115
- transient,
116
- units: snapshot.units,
117
- startedAt: snapshot.startedAt,
118
- completedAt: snapshot.completedAt,
119
- metadata: snapshot.metadata
120
- });
121
138
  const findInsertionIndex = (renderOrder, parentId) => {
122
139
  if (parentId === null) return {
123
140
  index: renderOrder.length,
@@ -154,8 +171,19 @@ const subtreeTaskIds = (renderOrder, taskId) => {
154
171
  while (end < renderOrder.length && renderOrder[end].depth > taskDepth) end++;
155
172
  return renderOrder.slice(idx, end).map((row) => row.id);
156
173
  };
174
+ const removeTransientSubtree = (current, nextTasks, taskId) => {
175
+ const removedTaskIds = subtreeTaskIds(current.renderOrder, taskId);
176
+ for (const removedTaskId of removedTaskIds) nextTasks.delete(removedTaskId);
177
+ const nextColumns = new Map(current.columns);
178
+ for (const removedTaskId of removedTaskIds) nextColumns.delete(removedTaskId);
179
+ return {
180
+ removedTaskIds,
181
+ renderOrder: removeFromRenderOrder(current.renderOrder, taskId),
182
+ columns: nextColumns
183
+ };
184
+ };
157
185
  const SNAPSHOT_PUBLISH_INTERVAL_MILLIS = 100;
158
- const makeProgressRenderStore = () => {
186
+ const makeProgressStoreRuntime = (publishQueue) => {
159
187
  let nextTaskId = 0;
160
188
  let state = {
161
189
  tasks: /* @__PURE__ */ new Map(),
@@ -168,13 +196,13 @@ const makeProgressRenderStore = () => {
168
196
  events: []
169
197
  };
170
198
  let hasPendingPublish = false;
171
- let lastPublishAt = 0;
172
- let publishTimeout;
199
+ let lastPublishAt = -SNAPSHOT_PUBLISH_INTERVAL_MILLIS;
200
+ let latestObservedAt = 0;
173
201
  const listeners = /* @__PURE__ */ new Set();
174
202
  const notifyListeners = () => {
175
203
  for (const listener of listeners) listener();
176
204
  };
177
- const publishNow = () => {
205
+ const publishNow = (publishedAt) => {
178
206
  const nextPublication = {
179
207
  snapshot: state,
180
208
  events: [...pendingEvents]
@@ -183,253 +211,238 @@ const makeProgressRenderStore = () => {
183
211
  publishedPublication = nextPublication;
184
212
  notifyListeners();
185
213
  pendingEvents = [];
186
- lastPublishAt = Date.now();
214
+ lastPublishAt = publishedAt;
187
215
  };
188
- const clearScheduledPublish = () => {
189
- if (publishTimeout === void 0) return;
190
- clearTimeout(publishTimeout);
191
- publishTimeout = void 0;
192
- };
193
- const schedulePublish = () => {
194
- if (!hasPendingPublish) return;
195
- const now = Date.now();
216
+ const publisherLoop = Effect.forever(Effect.gen(function* () {
217
+ yield* Queue.take(publishQueue);
218
+ const now = yield* Clock.currentTimeMillis;
196
219
  const waitMillis = Math.max(0, SNAPSHOT_PUBLISH_INTERVAL_MILLIS - (now - lastPublishAt));
197
- if (waitMillis === 0) {
198
- clearScheduledPublish();
199
- publishNow();
220
+ if (waitMillis > 0) yield* Effect.sleep(waitMillis);
221
+ if (!hasPendingPublish) return;
222
+ publishNow(yield* Clock.currentTimeMillis);
223
+ }));
224
+ const schedulePublish = Effect.gen(function* () {
225
+ if (!hasPendingPublish) return;
226
+ const now = yield* Clock.currentTimeMillis;
227
+ if (Math.max(0, SNAPSHOT_PUBLISH_INTERVAL_MILLIS - (now - lastPublishAt)) === 0) {
228
+ publishNow(now);
200
229
  return;
201
230
  }
202
- if (publishTimeout !== void 0) return;
203
- publishTimeout = setTimeout(() => {
204
- publishTimeout = void 0;
205
- if (!hasPendingPublish) return;
206
- publishNow();
207
- }, waitMillis);
208
- };
209
- const publish = (update) => {
210
- if (update.state === state) return;
231
+ yield* Queue.offer(publishQueue, void 0);
232
+ });
233
+ const publish = (update, now) => {
234
+ if (update.state === state) return Effect.void;
235
+ latestObservedAt = now;
211
236
  state = update.state;
212
237
  if (update.events.length > 0) pendingEvents.push(...update.events);
213
238
  hasPendingPublish = true;
214
- schedulePublish();
239
+ return schedulePublish;
215
240
  };
216
- const updateState = (transform) => {
217
- publish(transform(state));
241
+ const updateState = (transform, now) => {
242
+ return publish(transform(state), now);
218
243
  };
219
244
  return {
220
- getSnapshot: () => publishedPublication,
221
- subscribe: (listener) => {
222
- listeners.add(listener);
223
- return () => {
224
- listeners.delete(listener);
225
- };
226
- },
227
- flush: () => {
228
- if (!hasPendingPublish) return;
229
- clearScheduledPublish();
230
- publishNow();
231
- },
232
- addTask: (options) => Effect.gen(function* () {
233
- const taskId = TaskId(++nextTaskId);
234
- const units = normalizeUnits({
235
- succeeded: 0,
236
- failed: 0,
237
- total: sanitizeTotalOnAdd(options.total)
238
- });
239
- const parentSnapshot = options.parentId === void 0 ? void 0 : state.tasks.get(options.parentId);
240
- const now = yield* Clock.currentTimeMillis;
241
- const parentId = options.parentId ?? null;
242
- const countDisplay = options.countDisplay ?? parentSnapshot?.countDisplay ?? "detailed";
243
- const task = TaskSnapshot({
244
- id: taskId,
245
- parentId,
246
- description: options.description,
247
- status: "running",
248
- countDisplay,
249
- transient: (parentSnapshot?.transient ?? false) || (options.transient ?? false),
250
- units,
251
- startedAt: now,
252
- completedAt: null,
253
- metadata: options.metadata
254
- });
255
- updateState((current) => {
256
- const nextTasks = new Map(current.tasks);
257
- nextTasks.set(taskId, task);
258
- const { index, depth } = findInsertionIndex(current.renderOrder, parentId);
259
- const nextRenderOrder = [...current.renderOrder];
260
- nextRenderOrder.splice(index, 0, {
245
+ store: {
246
+ getSnapshot: () => publishedPublication,
247
+ subscribe: (listener) => {
248
+ listeners.add(listener);
249
+ return () => {
250
+ listeners.delete(listener);
251
+ };
252
+ },
253
+ flush: () => {
254
+ if (!hasPendingPublish) return;
255
+ publishNow(latestObservedAt);
256
+ },
257
+ addTask: (options) => Effect.gen(function* () {
258
+ const taskId = TaskId(++nextTaskId);
259
+ const units = normalizeUnits({
260
+ succeeded: 0,
261
+ failed: 0,
262
+ total: sanitizeTotal(options.total)
263
+ });
264
+ const parentSnapshot = options.parentId === void 0 ? void 0 : state.tasks.get(options.parentId);
265
+ const now = yield* Clock.currentTimeMillis;
266
+ const parentId = options.parentId ?? null;
267
+ const countDisplay = options.countDisplay ?? parentSnapshot?.countDisplay ?? "detailed";
268
+ const task = TaskSnapshot({
261
269
  id: taskId,
262
- depth
270
+ parentId,
271
+ description: options.description,
272
+ status: "running",
273
+ countDisplay,
274
+ transient: (parentSnapshot?.transient ?? false) || (options.transient ?? false),
275
+ units,
276
+ startedAt: now,
277
+ completedAt: null,
278
+ progressSamples: [{
279
+ timestamp: now,
280
+ processed: units.processed
281
+ }],
282
+ metadata: options.metadata
263
283
  });
264
- return {
265
- state: {
266
- tasks: nextTasks,
267
- renderOrder: nextRenderOrder,
268
- columns: options.columns ? new Map(current.columns).set(taskId, options.columns) : current.columns
269
- },
270
- events: [new TaskAddedEvent({
284
+ yield* updateState((current) => {
285
+ const nextTasks = new Map(current.tasks);
286
+ nextTasks.set(taskId, task);
287
+ const { index, depth } = findInsertionIndex(current.renderOrder, parentId);
288
+ const nextRenderOrder = [...current.renderOrder];
289
+ nextRenderOrder.splice(index, 0, {
290
+ id: taskId,
291
+ depth
292
+ });
293
+ return {
294
+ state: {
295
+ tasks: nextTasks,
296
+ renderOrder: nextRenderOrder,
297
+ columns: options.columns ? new Map(current.columns).set(taskId, options.columns) : current.columns
298
+ },
299
+ events: [new TaskAddedEvent({
300
+ taskId,
301
+ parentId,
302
+ description: task.description,
303
+ total: task.units.total,
304
+ transient: task.transient,
305
+ countDisplay: task.countDisplay
306
+ })]
307
+ };
308
+ }, now);
309
+ return taskId;
310
+ }),
311
+ updateTask: (taskId, options) => Effect.gen(function* () {
312
+ const now = yield* Clock.currentTimeMillis;
313
+ yield* updateState((current) => {
314
+ const currentTask = current.tasks.get(taskId);
315
+ if (!currentTask) return {
316
+ state: current,
317
+ events: []
318
+ };
319
+ const nextTask = updatedSnapshot(currentTask, options, now);
320
+ const nextTasks = new Map(current.tasks);
321
+ nextTasks.set(taskId, nextTask);
322
+ const events = [new TaskUpdatedEvent({
271
323
  taskId,
272
- parentId,
273
- description: task.description,
274
- total: task.units.total,
275
- transient: task.transient,
276
- countDisplay: task.countDisplay
277
- })]
278
- };
279
- });
280
- return taskId;
281
- }),
282
- updateTask: (taskId, options) => Effect.sync(() => {
283
- updateState((current) => {
284
- const currentTask = current.tasks.get(taskId);
285
- if (!currentTask) return {
286
- state: current,
287
- events: []
288
- };
289
- const nextTask = updatedSnapshot(currentTask, options);
290
- const nextTasks = new Map(current.tasks);
291
- nextTasks.set(taskId, nextTask);
292
- const events = [new TaskUpdatedEvent({
293
- taskId,
294
- description: options.description ?? void 0,
295
- succeeded: options.succeeded ?? void 0,
296
- failed: options.failed ?? void 0,
297
- processed: options.succeeded !== void 0 || options.failed !== void 0 ? nextTask.units.processed : void 0,
298
- total: hasExplicitTotal(options) ? nextTask.units.total : void 0,
299
- transient: options.transient ?? void 0,
300
- countDisplay: options.countDisplay ?? void 0
301
- })];
302
- if (options.transient !== void 0) for (const candidateId of subtreeTaskIds(current.renderOrder, taskId).slice(1)) {
303
- const candidate = current.tasks.get(candidateId);
304
- if (!candidate) continue;
305
- const nextCandidate = withTransient(candidate, nextTask.transient);
306
- nextTasks.set(candidateId, nextCandidate);
307
- events.push(new TaskUpdatedEvent({
308
- taskId: candidateId,
309
- transient: nextCandidate.transient
310
- }));
311
- }
312
- return {
313
- state: {
314
- tasks: nextTasks,
315
- renderOrder: current.renderOrder,
316
- columns: current.columns
317
- },
318
- events
319
- };
320
- });
321
- }),
322
- incrementSucceeded: (taskId, amount = 1) => Effect.sync(() => {
323
- updateState((current) => {
324
- const currentTask = current.tasks.get(taskId);
325
- if (!currentTask) return {
326
- state: current,
327
- events: []
328
- };
329
- const nextTasks = new Map(current.tasks);
330
- nextTasks.set(taskId, TaskSnapshot({
331
- id: currentTask.id,
332
- parentId: currentTask.parentId,
333
- description: currentTask.description,
334
- status: currentTask.status,
335
- countDisplay: currentTask.countDisplay,
336
- transient: currentTask.transient,
337
- units: normalizeUnits({
324
+ description: options.description ?? void 0,
325
+ succeeded: options.succeeded ?? void 0,
326
+ failed: options.failed ?? void 0,
327
+ processed: options.succeeded !== void 0 || options.failed !== void 0 ? nextTask.units.processed : void 0,
328
+ total: hasExplicitTotal(options) ? nextTask.units.total : void 0,
329
+ transient: options.transient ?? void 0,
330
+ countDisplay: options.countDisplay ?? void 0
331
+ })];
332
+ if (options.transient !== void 0) for (const candidateId of subtreeTaskIds(current.renderOrder, taskId).slice(1)) {
333
+ const candidate = current.tasks.get(candidateId);
334
+ if (!candidate) continue;
335
+ const nextCandidate = TaskSnapshot({
336
+ ...candidate,
337
+ transient: nextTask.transient
338
+ });
339
+ nextTasks.set(candidateId, nextCandidate);
340
+ events.push(new TaskUpdatedEvent({
341
+ taskId: candidateId,
342
+ transient: nextCandidate.transient
343
+ }));
344
+ }
345
+ return {
346
+ state: {
347
+ tasks: nextTasks,
348
+ renderOrder: current.renderOrder,
349
+ columns: current.columns
350
+ },
351
+ events
352
+ };
353
+ }, now);
354
+ }),
355
+ incrementSucceeded: (taskId, amount = 1) => Effect.gen(function* () {
356
+ const now = yield* Clock.currentTimeMillis;
357
+ yield* updateState((current) => {
358
+ const currentTask = current.tasks.get(taskId);
359
+ if (!currentTask) return {
360
+ state: current,
361
+ events: []
362
+ };
363
+ const units = normalizeUnits({
338
364
  succeeded: currentTask.units.succeeded + amount,
339
365
  failed: currentTask.units.failed,
340
366
  total: currentTask.units.total
341
- }),
342
- startedAt: currentTask.startedAt,
343
- completedAt: currentTask.completedAt,
344
- metadata: currentTask.metadata
345
- }));
346
- return {
347
- state: {
348
- tasks: nextTasks,
349
- renderOrder: current.renderOrder,
350
- columns: current.columns
351
- },
352
- events: [new TaskAdvancedEvent({
353
- taskId,
354
- amount,
355
- kind: "succeeded"
356
- })]
357
- };
358
- });
359
- }),
360
- incrementFailed: (taskId, amount = 1) => Effect.sync(() => {
361
- updateState((current) => {
362
- const currentTask = current.tasks.get(taskId);
363
- if (!currentTask) return {
364
- state: current,
365
- events: []
366
- };
367
- const nextTasks = new Map(current.tasks);
368
- nextTasks.set(taskId, TaskSnapshot({
369
- id: currentTask.id,
370
- parentId: currentTask.parentId,
371
- description: currentTask.description,
372
- status: currentTask.status,
373
- countDisplay: currentTask.countDisplay,
374
- transient: currentTask.transient,
375
- units: normalizeUnits({
367
+ });
368
+ const nextTasks = new Map(current.tasks);
369
+ nextTasks.set(taskId, TaskSnapshot({
370
+ ...currentTask,
371
+ units,
372
+ progressSamples: appendProgressSample(currentTask.progressSamples, now, units.processed)
373
+ }));
374
+ return {
375
+ state: {
376
+ tasks: nextTasks,
377
+ renderOrder: current.renderOrder,
378
+ columns: current.columns
379
+ },
380
+ events: [new TaskAdvancedEvent({
381
+ taskId,
382
+ amount,
383
+ kind: "succeeded"
384
+ })]
385
+ };
386
+ }, now);
387
+ }),
388
+ incrementFailed: (taskId, amount = 1) => Effect.gen(function* () {
389
+ const now = yield* Clock.currentTimeMillis;
390
+ yield* updateState((current) => {
391
+ const currentTask = current.tasks.get(taskId);
392
+ if (!currentTask) return {
393
+ state: current,
394
+ events: []
395
+ };
396
+ const units = normalizeUnits({
376
397
  succeeded: currentTask.units.succeeded,
377
398
  failed: currentTask.units.failed + amount,
378
399
  total: currentTask.units.total
379
- }),
380
- startedAt: currentTask.startedAt,
381
- completedAt: currentTask.completedAt,
382
- metadata: currentTask.metadata
383
- }));
384
- return {
385
- state: {
386
- tasks: nextTasks,
387
- renderOrder: current.renderOrder,
388
- columns: current.columns
389
- },
390
- events: [new TaskAdvancedEvent({
391
- taskId,
392
- amount,
393
- kind: "failed"
394
- })]
395
- };
396
- });
397
- }),
398
- completeTask: (taskId) => Effect.gen(function* () {
399
- const now = yield* Clock.currentTimeMillis;
400
- updateState((current) => {
401
- const currentTask = current.tasks.get(taskId);
402
- if (!currentTask) return {
403
- state: current,
404
- events: []
405
- };
406
- if (currentTask.status !== "running") return {
407
- state: current,
408
- events: []
409
- };
410
- const nextTasks = new Map(current.tasks);
411
- if (currentTask.transient) {
412
- const removedTaskIds = subtreeTaskIds(current.renderOrder, taskId);
413
- for (const removedTaskId of removedTaskIds) nextTasks.delete(removedTaskId);
414
- const nextColumns = new Map(current.columns);
415
- for (const removedTaskId of removedTaskIds) nextColumns.delete(removedTaskId);
400
+ });
401
+ const nextTasks = new Map(current.tasks);
402
+ nextTasks.set(taskId, TaskSnapshot({
403
+ ...currentTask,
404
+ units,
405
+ progressSamples: appendProgressSample(currentTask.progressSamples, now, units.processed)
406
+ }));
416
407
  return {
417
408
  state: {
418
409
  tasks: nextTasks,
419
- renderOrder: removeFromRenderOrder(current.renderOrder, taskId),
420
- columns: nextColumns
410
+ renderOrder: current.renderOrder,
411
+ columns: current.columns
421
412
  },
422
- events: [new TaskCompletedEvent({ taskId }), ...removedTaskIds.map((removedTaskId) => new TaskRemovedEvent({ taskId: removedTaskId }))]
413
+ events: [new TaskAdvancedEvent({
414
+ taskId,
415
+ amount,
416
+ kind: "failed"
417
+ })]
423
418
  };
424
- }
425
- nextTasks.set(taskId, TaskSnapshot({
426
- id: currentTask.id,
427
- parentId: currentTask.parentId,
428
- description: currentTask.description,
429
- status: "done",
430
- countDisplay: currentTask.countDisplay,
431
- transient: currentTask.transient,
432
- units: currentTask.units.total !== void 0 ? currentTask.units.processed < currentTask.units.total ? normalizeUnits({
419
+ }, now);
420
+ }),
421
+ completeTask: (taskId) => Effect.gen(function* () {
422
+ const now = yield* Clock.currentTimeMillis;
423
+ yield* updateState((current) => {
424
+ const currentTask = current.tasks.get(taskId);
425
+ if (!currentTask) return {
426
+ state: current,
427
+ events: []
428
+ };
429
+ if (currentTask.status !== "running") return {
430
+ state: current,
431
+ events: []
432
+ };
433
+ const nextTasks = new Map(current.tasks);
434
+ if (currentTask.transient) {
435
+ const removedSubtree = removeTransientSubtree(current, nextTasks, taskId);
436
+ return {
437
+ state: {
438
+ tasks: nextTasks,
439
+ renderOrder: removedSubtree.renderOrder,
440
+ columns: removedSubtree.columns
441
+ },
442
+ events: [new TaskCompletedEvent({ taskId }), ...removedSubtree.removedTaskIds.map((removedTaskId) => new TaskRemovedEvent({ taskId: removedTaskId }))]
443
+ };
444
+ }
445
+ const units = currentTask.units.total !== void 0 ? currentTask.units.processed < currentTask.units.total ? normalizeUnits({
433
446
  succeeded: currentTask.units.succeeded + (currentTask.units.total - currentTask.units.processed),
434
447
  failed: currentTask.units.failed,
435
448
  total: currentTask.units.total
@@ -437,117 +450,112 @@ const makeProgressRenderStore = () => {
437
450
  succeeded: currentTask.units.succeeded,
438
451
  failed: currentTask.units.failed,
439
452
  total: currentTask.units.processed
440
- }) : currentTask.units,
441
- startedAt: currentTask.startedAt,
442
- completedAt: now,
443
- metadata: currentTask.metadata
444
- }));
445
- return {
446
- state: {
447
- tasks: nextTasks,
448
- renderOrder: current.renderOrder,
449
- columns: current.columns
450
- },
451
- events: [new TaskCompletedEvent({ taskId })]
452
- };
453
- });
454
- }),
455
- failTask: (taskId) => Effect.gen(function* () {
456
- const now = yield* Clock.currentTimeMillis;
457
- updateState((current) => {
458
- const currentTask = current.tasks.get(taskId);
459
- if (!currentTask) return {
460
- state: current,
461
- events: []
462
- };
463
- if (currentTask.status !== "running") return {
464
- state: current,
465
- events: []
466
- };
467
- const nextTasks = new Map(current.tasks);
468
- if (currentTask.transient) {
469
- const removedTaskIds = subtreeTaskIds(current.renderOrder, taskId);
470
- for (const removedTaskId of removedTaskIds) nextTasks.delete(removedTaskId);
471
- const nextColumns = new Map(current.columns);
472
- for (const removedTaskId of removedTaskIds) nextColumns.delete(removedTaskId);
453
+ }) : currentTask.units;
454
+ nextTasks.set(taskId, TaskSnapshot({
455
+ ...currentTask,
456
+ status: "done",
457
+ units,
458
+ completedAt: now,
459
+ progressSamples: appendProgressSample(currentTask.progressSamples, now, units.processed)
460
+ }));
473
461
  return {
474
462
  state: {
475
463
  tasks: nextTasks,
476
- renderOrder: removeFromRenderOrder(current.renderOrder, taskId),
477
- columns: nextColumns
464
+ renderOrder: current.renderOrder,
465
+ columns: current.columns
478
466
  },
479
- events: [new TaskFailedEvent({ taskId }), ...removedTaskIds.map((removedTaskId) => new TaskRemovedEvent({ taskId: removedTaskId }))]
467
+ events: [new TaskCompletedEvent({ taskId })]
480
468
  };
481
- }
482
- nextTasks.set(taskId, TaskSnapshot({
483
- id: currentTask.id,
484
- parentId: currentTask.parentId,
485
- description: currentTask.description,
486
- status: "failed",
487
- countDisplay: currentTask.countDisplay,
488
- transient: currentTask.transient,
489
- units: currentTask.units,
490
- startedAt: currentTask.startedAt,
491
- completedAt: now,
492
- metadata: currentTask.metadata
493
- }));
494
- return {
495
- state: {
496
- tasks: nextTasks,
497
- renderOrder: current.renderOrder,
498
- columns: current.columns
499
- },
500
- events: [new TaskFailedEvent({ taskId })]
501
- };
502
- });
503
- }),
504
- getTask: (taskId) => Effect.sync(() => Option.fromNullable(state.tasks.get(taskId))),
505
- listTasks: Effect.sync(() => Array.from(state.tasks.values())),
506
- setMetadata: (taskId, metadata) => Effect.sync(() => {
507
- updateState((current) => {
508
- const currentTask = current.tasks.get(taskId);
509
- if (!currentTask) return {
510
- state: current,
511
- events: []
512
- };
513
- const nextTasks = new Map(current.tasks);
514
- nextTasks.set(taskId, TaskSnapshot({
515
- id: currentTask.id,
516
- parentId: currentTask.parentId,
517
- description: currentTask.description,
518
- status: currentTask.status,
519
- countDisplay: currentTask.countDisplay,
520
- transient: currentTask.transient,
521
- units: currentTask.units,
522
- startedAt: currentTask.startedAt,
523
- completedAt: currentTask.completedAt,
524
- metadata
525
- }));
526
- return {
527
- state: {
528
- tasks: nextTasks,
529
- renderOrder: current.renderOrder,
530
- columns: current.columns
531
- },
532
- events: []
533
- };
534
- });
535
- }),
536
- getMetadata: (taskId) => Effect.sync(() => {
537
- return state.tasks.get(taskId)?.metadata;
538
- })
469
+ }, now);
470
+ }),
471
+ failTask: (taskId) => Effect.gen(function* () {
472
+ const now = yield* Clock.currentTimeMillis;
473
+ yield* updateState((current) => {
474
+ const currentTask = current.tasks.get(taskId);
475
+ if (!currentTask) return {
476
+ state: current,
477
+ events: []
478
+ };
479
+ if (currentTask.status !== "running") return {
480
+ state: current,
481
+ events: []
482
+ };
483
+ const nextTasks = new Map(current.tasks);
484
+ if (currentTask.transient) {
485
+ const removedSubtree = removeTransientSubtree(current, nextTasks, taskId);
486
+ return {
487
+ state: {
488
+ tasks: nextTasks,
489
+ renderOrder: removedSubtree.renderOrder,
490
+ columns: removedSubtree.columns
491
+ },
492
+ events: [new TaskFailedEvent({ taskId }), ...removedSubtree.removedTaskIds.map((removedTaskId) => new TaskRemovedEvent({ taskId: removedTaskId }))]
493
+ };
494
+ }
495
+ nextTasks.set(taskId, TaskSnapshot({
496
+ ...currentTask,
497
+ status: "failed",
498
+ completedAt: now
499
+ }));
500
+ return {
501
+ state: {
502
+ tasks: nextTasks,
503
+ renderOrder: current.renderOrder,
504
+ columns: current.columns
505
+ },
506
+ events: [new TaskFailedEvent({ taskId })]
507
+ };
508
+ }, now);
509
+ }),
510
+ getTask: (taskId) => Effect.sync(() => Option.fromNullishOr(state.tasks.get(taskId))),
511
+ listTasks: Effect.sync(() => Array.from(state.tasks.values())),
512
+ setMetadata: (taskId, metadata) => Effect.gen(function* () {
513
+ yield* updateState((current) => {
514
+ const currentTask = current.tasks.get(taskId);
515
+ if (!currentTask) return {
516
+ state: current,
517
+ events: []
518
+ };
519
+ const nextTasks = new Map(current.tasks);
520
+ nextTasks.set(taskId, TaskSnapshot({
521
+ ...currentTask,
522
+ metadata
523
+ }));
524
+ return {
525
+ state: {
526
+ tasks: nextTasks,
527
+ renderOrder: current.renderOrder,
528
+ columns: current.columns
529
+ },
530
+ events: []
531
+ };
532
+ }, yield* Clock.currentTimeMillis);
533
+ }),
534
+ getMetadata: (taskId) => Effect.sync(() => {
535
+ return state.tasks.get(taskId)?.metadata;
536
+ })
537
+ },
538
+ publisherLoop
539
539
  };
540
540
  };
541
+ const makeProgressStore = Effect.gen(function* () {
542
+ const runtime = makeProgressStoreRuntime(yield* Queue.sliding(1));
543
+ yield* Effect.forkDetach(runtime.publisherLoop);
544
+ return runtime.store;
545
+ });
546
+ var ProgressStore = class ProgressStore extends Context.Service()("stromseng.dev/effective-progress/ProgressStore") {
547
+ static layer = Layer.effect(ProgressStore, makeProgressStore);
548
+ };
541
549
 
542
550
  //#endregion
543
- //#region src/renderer/hooks/use-now-clock.ts
551
+ //#region src/services/renderer/hooks/use-now-clock.ts
544
552
  const useNowClock = (active, intervalMillis) => {
545
- const [now, setNow] = useState(() => Date.now());
553
+ const [now, updateNow] = useReducer(() => Date.now(), void 0, Date.now);
546
554
  useEffect(() => {
547
555
  if (!active) return;
548
- setNow(Date.now());
556
+ updateNow();
549
557
  const interval = setInterval(() => {
550
- setNow(Date.now());
558
+ updateNow();
551
559
  }, intervalMillis);
552
560
  return () => {
553
561
  clearInterval(interval);
@@ -557,7 +565,7 @@ const useNowClock = (active, intervalMillis) => {
557
565
  };
558
566
 
559
567
  //#endregion
560
- //#region src/renderer/context/now-context.tsx
568
+ //#region src/services/renderer/context/now-context.tsx
561
569
  const NOW_INTERVAL_MILLIS = 1e3;
562
570
  const NowContext = createContext(Date.now());
563
571
  const NowProvider = ({ active, children, nowOverride }) => {
@@ -568,10 +576,10 @@ const NowProvider = ({ active, children, nowOverride }) => {
568
576
  children
569
577
  });
570
578
  };
571
- const useNow = () => useContext(NowContext);
579
+ const useNow = () => use(NowContext);
572
580
 
573
581
  //#endregion
574
- //#region src/renderer/hooks/use-spinner-clock.ts
582
+ //#region src/services/renderer/hooks/use-spinner-clock.ts
575
583
  const normalizeIntervalMillis = (intervalMillis) => Math.max(1, intervalMillis);
576
584
  const getSpinnerTickAtTime = (baseTick, startedAt, now, intervalMillis) => {
577
585
  const elapsedMillis = Math.max(0, now - startedAt);
@@ -602,7 +610,7 @@ const useSpinnerClock = (active, intervalMillis) => {
602
610
  };
603
611
 
604
612
  //#endregion
605
- //#region src/renderer/context/spinner-context.tsx
613
+ //#region src/services/renderer/context/spinner-context.tsx
606
614
  const DEFAULT_SPINNER_INTERVAL_MILLIS = cliSpinners.dots.interval;
607
615
  const SpinnerContext = createContext(0);
608
616
  const SpinnerProvider = ({ active, children, intervalMillis = DEFAULT_SPINNER_INTERVAL_MILLIS, tickOverride }) => {
@@ -613,14 +621,14 @@ const SpinnerProvider = ({ active, children, intervalMillis = DEFAULT_SPINNER_IN
613
621
  children
614
622
  });
615
623
  };
616
- const useSpinnerTick = () => useContext(SpinnerContext);
624
+ const useSpinnerTick = () => use(SpinnerContext);
617
625
 
618
626
  //#endregion
619
- //#region src/renderer/shared/determinate.ts
627
+ //#region src/services/renderer/shared/determinate.ts
620
628
  const isDeterminate$2 = (task) => task.units.total !== void 0;
621
629
 
622
630
  //#endregion
623
- //#region src/renderer/shared/format.ts
631
+ //#region src/services/renderer/shared/format.ts
624
632
  const isDeterminate$1 = (task) => task.units.total !== void 0;
625
633
  const showsUnknownTotalCounts = (task) => task.units.total === void 0 && task.units.processed > 0;
626
634
  const formatDurationSeconds = (seconds) => {
@@ -635,17 +643,58 @@ const formatDurationSeconds = (seconds) => {
635
643
  const mins = Math.floor(value % 3600 / 60);
636
644
  return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
637
645
  };
646
+ const formatClockDurationSeconds = (seconds) => {
647
+ const value = Math.max(0, Math.floor(seconds));
648
+ const hours = Math.floor(value / 3600);
649
+ const mins = Math.floor(value % 3600 / 60);
650
+ const secs = value % 60;
651
+ const clock = `${`${mins}`.padStart(2, "0")}:${`${secs}`.padStart(2, "0")}`;
652
+ return hours > 0 ? `${`${hours}`.padStart(2, "0")}:${clock}` : clock;
653
+ };
654
+ /**
655
+ * Estimates remaining time from the task's retained progress sample deque.
656
+ *
657
+ * Returns undefined until there are at least two samples with positive processed and time deltas.
658
+ */
659
+ const getSmoothedEtaMillis = (task) => {
660
+ const { processed, total } = task.units;
661
+ const remaining = total - processed;
662
+ if (processed <= 0 || remaining <= 0) return;
663
+ const samples = task.progressSamples;
664
+ const lastSample = samples.at(-1);
665
+ if (lastSample === void 0) return;
666
+ const firstSample = samples[0];
667
+ if (firstSample === void 0 || firstSample === lastSample) return;
668
+ const deltaProcessed = lastSample.processed - firstSample.processed;
669
+ const deltaMillis = lastSample.timestamp - firstSample.timestamp;
670
+ if (deltaProcessed <= 0 || deltaMillis <= 0) return;
671
+ return Math.max(0, Math.floor(remaining * deltaMillis / deltaProcessed));
672
+ };
638
673
  const formatElapsed = (task, now) => {
639
674
  return formatDurationSeconds(Math.max(0, (task.completedAt ?? now) - task.startedAt) / 1e3);
640
675
  };
641
- const formatEta = (task, now) => {
676
+ const formatElapsedClock = (task, now) => {
677
+ return formatClockDurationSeconds(Math.max(0, (task.completedAt ?? now) - task.startedAt) / 1e3);
678
+ };
679
+ const formatEta = (task) => {
642
680
  if (task.status !== "running" || !isDeterminate$1(task)) return "";
643
681
  const { processed, total } = task.units;
644
682
  const remaining = total - processed;
645
683
  if (processed <= 0 || remaining <= 0) return "";
646
- const elapsedMillis = Math.max(1, now - task.startedAt);
647
- return formatDurationSeconds(Math.max(0, Math.floor(elapsedMillis / processed * remaining)) / 1e3);
684
+ const etaMillis = getSmoothedEtaMillis(task);
685
+ if (etaMillis === void 0) return "";
686
+ return formatClockDurationSeconds(etaMillis / 1e3);
648
687
  };
688
+ const formatEtaClock = (task) => {
689
+ if (task.status !== "running" || !isDeterminate$1(task)) return;
690
+ const { processed, total } = task.units;
691
+ const remaining = total - processed;
692
+ if (processed <= 0 || remaining <= 0) return;
693
+ const etaMillis = getSmoothedEtaMillis(task);
694
+ if (etaMillis === void 0) return;
695
+ return formatClockDurationSeconds(etaMillis / 1e3);
696
+ };
697
+ const formatElapsedEta = (task, now) => `${formatElapsedClock(task, now)}<${formatEtaClock(task) ?? "00:00"}`;
649
698
  const formatDeterminateAmountParts = (task) => {
650
699
  if (!isDeterminate$1(task)) return;
651
700
  const totalText = `${task.units.total}`;
@@ -674,7 +723,7 @@ const formatAmount = (task, _tick) => {
674
723
  };
675
724
 
676
725
  //#endregion
677
- //#region src/renderer/shared/text-width.ts
726
+ //#region src/services/renderer/shared/text-width.ts
678
727
  const WIDTH_CACHE_LIMIT = 8192;
679
728
  const widthCache = /* @__PURE__ */ new Map();
680
729
  const textWidth = (text) => {
@@ -687,7 +736,7 @@ const textWidth = (text) => {
687
736
  };
688
737
 
689
738
  //#endregion
690
- //#region src/renderer/columns/amount-column.tsx
739
+ //#region src/services/renderer/columns/amount-column.tsx
691
740
  const hasUnknownTotalCounts = (task) => task.units.total === void 0 && task.units.processed > 0;
692
741
  const hasCountedAmount = (task) => isDeterminate$2(task) || hasUnknownTotalCounts(task);
693
742
  const totalTextFor = (task) => task.units.total === void 0 ? "?" : `${task.units.total}`;
@@ -723,7 +772,7 @@ const measureAmountLayout = (rows) => {
723
772
  }, countedWidth)
724
773
  };
725
774
  };
726
- const renderAmount = (task, layout) => {
775
+ const AmountValue = ({ task, layout }) => {
727
776
  if (!hasCountedAmount(task)) return formatAmount(task, 0);
728
777
  const processed = `${task.units.processed}`.padStart(layout.processedWidth, " ");
729
778
  const total = totalTextFor(task).padStart(layout.totalWidth, " ");
@@ -746,16 +795,19 @@ const renderAmount = (task, layout) => {
746
795
  };
747
796
  const AmountCell = ({ task, layout }) => /* @__PURE__ */ jsx(Text, {
748
797
  wrap: "truncate-end",
749
- children: renderAmount(task, layout)
798
+ children: /* @__PURE__ */ jsx(AmountValue, {
799
+ task,
800
+ layout
801
+ })
750
802
  });
751
803
 
752
804
  //#endregion
753
- //#region src/renderer/columns/bar-column.tsx
805
+ //#region src/services/renderer/columns/bar-column.tsx
754
806
  const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
755
807
  const prepareBar = (rows) => {
756
808
  return { hasDeterminateRows: rows.some((row) => row.derived.isDeterminate) };
757
809
  };
758
- const renderProgressBar = (task, width) => {
810
+ const ProgressBarSegments = ({ task, width }) => {
759
811
  if (!isDeterminate$2(task)) return /* @__PURE__ */ jsx(Text, { children: ` `.repeat(Math.max(0, width)) });
760
812
  const displayTotal = Math.max(task.units.total, task.units.succeeded + task.units.failed);
761
813
  const succeededEnd = displayTotal === 0 ? width : Math.round(task.units.succeeded / displayTotal * width);
@@ -783,11 +835,14 @@ const renderProgressBar = (task, width) => {
783
835
  };
784
836
  const BarCell = ({ task, width }) => /* @__PURE__ */ jsx(Text, {
785
837
  wrap: "truncate-end",
786
- children: renderProgressBar(task, width ?? 0)
838
+ children: /* @__PURE__ */ jsx(ProgressBarSegments, {
839
+ task,
840
+ width: width ?? 0
841
+ })
787
842
  });
788
843
 
789
844
  //#endregion
790
- //#region src/renderer/columns/description-column.tsx
845
+ //#region src/services/renderer/columns/description-column.tsx
791
846
  const MIN_TREE_DESCRIPTION_TEXT_WIDTH = 6;
792
847
  const DEFAULT_SPINNER_TYPE = "dots";
793
848
  const isDeterminate = (task) => task.units.total !== void 0;
@@ -826,7 +881,10 @@ const getTaskIndicator = (task, tick, spinnerType = DEFAULT_SPINNER_TYPE) => {
826
881
  color: "green"
827
882
  };
828
883
  };
829
- const prepareDescription = (rows) => ({ minTreeWidth: rows.reduce((max, row) => Math.max(max, row.derived.treePrefixWidth + 2 + MIN_TREE_DESCRIPTION_TEXT_WIDTH), MIN_TREE_DESCRIPTION_TEXT_WIDTH + 2) });
884
+ const prepareDescription = (rows) => ({
885
+ minTreeWidth: rows.reduce((max, row) => Math.max(max, row.derived.treePrefixWidth + 2 + MIN_TREE_DESCRIPTION_TEXT_WIDTH), MIN_TREE_DESCRIPTION_TEXT_WIDTH + 2),
886
+ preferredWidth: rows.reduce((max, row) => Math.max(max, row.derived.treePrefixWidth + 2 + row.derived.descriptionWidth), 2)
887
+ });
830
888
  const TaskIndicatorGlyph = ({ task, tick, spinnerType = DEFAULT_SPINNER_TYPE }) => {
831
889
  const indicator = getTaskIndicator(task, tick, spinnerType);
832
890
  return /* @__PURE__ */ jsx(Text, {
@@ -862,7 +920,17 @@ const DescriptionCell = ({ cell, width, minTreeWidth, spinnerTick }) => {
862
920
  };
863
921
 
864
922
  //#endregion
865
- //#region src/renderer/columns/elapsed-column.tsx
923
+ //#region src/services/renderer/columns/elapsed-eta-column.tsx
924
+ const ElapsedEtaCell = ({ task, now }) => {
925
+ return /* @__PURE__ */ jsx(Text, {
926
+ wrap: "truncate-end",
927
+ color: "gray",
928
+ children: formatElapsedEta(task, now)
929
+ });
930
+ };
931
+
932
+ //#endregion
933
+ //#region src/services/renderer/columns/elapsed-column.tsx
866
934
  const ElapsedCell = ({ task, now }) => {
867
935
  return /* @__PURE__ */ jsx(Text, {
868
936
  wrap: "truncate-end",
@@ -872,9 +940,9 @@ const ElapsedCell = ({ task, now }) => {
872
940
  };
873
941
 
874
942
  //#endregion
875
- //#region src/renderer/columns/eta-column.tsx
876
- const EtaCell = ({ task, now }) => {
877
- const eta = formatEta(task, now);
943
+ //#region src/services/renderer/columns/eta-column.tsx
944
+ const EtaCell = ({ task }) => {
945
+ const eta = formatEta(task);
878
946
  if (eta === "") return null;
879
947
  return /* @__PURE__ */ jsx(Text, {
880
948
  wrap: "truncate-end",
@@ -891,10 +959,17 @@ var columns_exports = /* @__PURE__ */ __exportAll({
891
959
  defaults: () => defaults,
892
960
  description: () => description,
893
961
  elapsed: () => elapsed,
962
+ elapsedEta: () => elapsedEta,
894
963
  eta: () => eta,
895
964
  resolveColumnSizeValue: () => resolveColumnSizeValue,
896
965
  spacer: () => spacer
897
966
  });
967
+ const DEFAULT_BAR_SIZE = 30;
968
+ const resolveBarSize = (size) => {
969
+ if (size === "fullwidth") return size;
970
+ if (typeof size !== "number" || !Number.isFinite(size)) return DEFAULT_BAR_SIZE;
971
+ return Math.max(1, Math.floor(size));
972
+ };
898
973
  const spacer = ({ flexGrow, flexShrink, flexBasis, minWidth } = {}) => ({
899
974
  render: () => null,
900
975
  flexGrow,
@@ -904,8 +979,8 @@ const spacer = ({ flexGrow, flexShrink, flexBasis, minWidth } = {}) => ({
904
979
  });
905
980
  const description = () => ({
906
981
  prepare: prepareDescription,
907
- flexGrow: 1,
908
982
  flexShrink: 1,
983
+ flexBasis: (prepared) => prepared.preferredWidth,
909
984
  minWidth: 1,
910
985
  render: (cell, ctx) => /* @__PURE__ */ jsx(DescriptionCell, {
911
986
  cell,
@@ -914,16 +989,20 @@ const description = () => ({
914
989
  spinnerTick: ctx.spinnerTick
915
990
  })
916
991
  });
917
- const bar = () => ({
918
- prepare: prepareBar,
919
- flexShrink: (prepared) => prepared.hasDeterminateRows ? 1 : 0,
920
- flexBasis: (prepared) => prepared.hasDeterminateRows ? 30 : 0,
921
- minWidth: (prepared) => prepared.hasDeterminateRows ? 4 : 0,
922
- render: ({ task }, ctx) => /* @__PURE__ */ jsx(BarCell, {
923
- task,
924
- width: ctx.width
925
- })
926
- });
992
+ const bar = ({ size } = {}) => {
993
+ const resolvedSize = resolveBarSize(size);
994
+ return {
995
+ prepare: prepareBar,
996
+ flexGrow: (prepared) => prepared.hasDeterminateRows && resolvedSize === "fullwidth" ? 1 : 0,
997
+ flexShrink: (prepared) => prepared.hasDeterminateRows ? 1 : 0,
998
+ flexBasis: (prepared) => prepared.hasDeterminateRows ? resolvedSize === "fullwidth" ? DEFAULT_BAR_SIZE : resolvedSize : 0,
999
+ minWidth: (prepared) => prepared.hasDeterminateRows ? resolvedSize === "fullwidth" ? 4 : resolvedSize : 0,
1000
+ render: ({ task }, ctx) => /* @__PURE__ */ jsx(BarCell, {
1001
+ task,
1002
+ width: ctx.width
1003
+ })
1004
+ };
1005
+ };
927
1006
  const amount = () => ({
928
1007
  prepare: measureAmountLayout,
929
1008
  align: "right",
@@ -940,20 +1019,26 @@ const elapsed = () => ({
940
1019
  now: ctx.now
941
1020
  })
942
1021
  });
943
- const eta = () => ({
1022
+ const elapsedEta = () => ({
944
1023
  align: "right",
945
1024
  flexShrink: 0,
946
- render: ({ task }, ctx) => /* @__PURE__ */ jsx(EtaCell, {
1025
+ minWidth: 11,
1026
+ render: ({ task }, ctx) => /* @__PURE__ */ jsx(ElapsedEtaCell, {
947
1027
  task,
948
1028
  now: ctx.now
949
1029
  })
950
1030
  });
1031
+ const eta = () => ({
1032
+ align: "right",
1033
+ flexShrink: 0,
1034
+ minWidth: 8,
1035
+ render: ({ task }) => /* @__PURE__ */ jsx(EtaCell, { task })
1036
+ });
951
1037
  const defaults = () => [
952
1038
  description(),
953
1039
  bar(),
954
1040
  amount(),
955
- elapsed(),
956
- eta()
1041
+ elapsedEta()
957
1042
  ];
958
1043
  const resolveColumnSizeValue = (value, prepared) => {
959
1044
  if (typeof value === "function") return value(prepared);
@@ -961,7 +1046,7 @@ const resolveColumnSizeValue = (value, prepared) => {
961
1046
  };
962
1047
 
963
1048
  //#endregion
964
- //#region src/renderer/column-resolver.ts
1049
+ //#region src/services/renderer/column-resolver.ts
965
1050
  const NO_PREPARE = Symbol("no-prepare");
966
1051
  const getColumnsForRow = (row, columns) => columns.get(row.task.id) ?? defaults();
967
1052
  const toCellInfo = (row) => row;
@@ -1015,13 +1100,13 @@ const resolveColumns = (rows, columns) => {
1015
1100
  };
1016
1101
 
1017
1102
  //#endregion
1018
- //#region src/renderer/public-api.tsx
1103
+ //#region src/services/renderer/public-api.tsx
1019
1104
  const justifyContentForAlign = (align) => {
1020
1105
  if (align === "right") return "flex-end";
1021
1106
  if (align === "center") return "center";
1022
1107
  return "flex-start";
1023
1108
  };
1024
- const renderNode = (node) => {
1109
+ const RenderedNode = ({ node }) => {
1025
1110
  if (typeof node === "string" || typeof node === "number") return /* @__PURE__ */ jsx(Text, {
1026
1111
  wrap: "truncate-end",
1027
1112
  children: node
@@ -1045,7 +1130,7 @@ const ColumnPosition = ({ position }) => {
1045
1130
  const column = entry?.column;
1046
1131
  const cell = row;
1047
1132
  const output = column?.render(cell, {
1048
- width: hasMeasured ? width : void 0,
1133
+ width: hasMeasured ? width : position.flexBasis,
1049
1134
  now,
1050
1135
  spinnerTick,
1051
1136
  prepared: entry?.prepared
@@ -1053,7 +1138,7 @@ const ColumnPosition = ({ position }) => {
1053
1138
  return /* @__PURE__ */ jsx(Box, {
1054
1139
  height: 1,
1055
1140
  justifyContent: justifyContentForAlign(column?.align),
1056
- children: renderNode(output)
1141
+ children: /* @__PURE__ */ jsx(RenderedNode, { node: output })
1057
1142
  }, row.task.id);
1058
1143
  })
1059
1144
  });
@@ -1069,7 +1154,7 @@ const ProgressRenderer = ({ rows, columns }) => {
1069
1154
  };
1070
1155
 
1071
1156
  //#endregion
1072
- //#region src/renderer/store/render-snapshot.ts
1157
+ //#region src/services/store/render-snapshot.ts
1073
1158
  const orderedVisibleTasks = (store) => store.renderOrder.flatMap((row) => {
1074
1159
  const snapshot = store.tasks.get(row.id);
1075
1160
  if (!snapshot || snapshot.transient && snapshot.status !== "running") return [];
@@ -1148,7 +1233,7 @@ const toRenderSnapshot = (store, previousSnapshot) => {
1148
1233
  };
1149
1234
 
1150
1235
  //#endregion
1151
- //#region src/renderer/store/use-progress-render-view.ts
1236
+ //#region src/services/renderer/hooks/use-progress-render-view.ts
1152
1237
  const useRenderSnapshot = (storeSnapshot) => {
1153
1238
  const previousSnapshotRef = useRef(void 0);
1154
1239
  const renderSnapshot = useMemo(() => toRenderSnapshot(storeSnapshot, previousSnapshotRef.current), [storeSnapshot]);
@@ -1168,7 +1253,17 @@ const useProgressRenderView = (store) => {
1168
1253
  };
1169
1254
 
1170
1255
  //#endregion
1171
- //#region src/renderer/renderer-service.tsx
1256
+ //#region src/services/stdio.ts
1257
+ const defaultStdioService = {
1258
+ stdout: process.stdout,
1259
+ stderr: process.stderr
1260
+ };
1261
+ var ProgressStdio = class ProgressStdio extends Context.Service()("stromseng.dev/effective-progress/ProgressStdio") {
1262
+ static layer = Layer.succeed(ProgressStdio, defaultStdioService);
1263
+ };
1264
+
1265
+ //#endregion
1266
+ //#region src/services/renderer/renderer.tsx
1172
1267
  const MAX_FPS = 24;
1173
1268
  const ProgressRoot = ({ store }) => {
1174
1269
  const { renderSnapshot, hasRunningTasks, publication } = useProgressRenderView(store);
@@ -1183,57 +1278,43 @@ const ProgressRoot = ({ store }) => {
1183
1278
  })
1184
1279
  });
1185
1280
  };
1186
- const makeRendererv2InkRendererService = () => {
1187
- return { run: (store, stdio) => {
1188
- const proot = /* @__PURE__ */ jsx(ProgressRoot, { store });
1189
- return Effect.sync(() => render(proot, {
1190
- stdout: stdio.stdout,
1191
- stderr: stdio.stderr,
1192
- patchConsole: true,
1193
- exitOnCtrlC: false,
1194
- debug: false,
1195
- maxFps: MAX_FPS
1196
- })).pipe(Effect.flatMap((instance) => Effect.never.pipe(Effect.ensuring(Effect.gen(function* () {
1197
- store.flush();
1198
- instance.rerender(proot);
1199
- yield* Effect.sync(() => {
1200
- instance.unmount();
1201
- });
1202
- })))));
1203
- } };
1204
- };
1205
-
1206
- //#endregion
1207
- //#region src/services/ink-renderer.tsx
1208
- var InkRenderer = class InkRenderer extends Context.Tag("stromseng.dev/effective-progress/InkRenderer")() {
1209
- static Default = Layer.succeed(InkRenderer, InkRenderer.of(makeRendererv2InkRendererService()));
1210
- };
1211
-
1212
- //#endregion
1213
- //#region src/services/stdio.ts
1214
- const defaultStdioService = {
1215
- stdout: process.stdout,
1216
- stderr: process.stderr
1217
- };
1218
- var ProgressStdio = class ProgressStdio extends Context.Tag("stromseng.dev/effective-progress/ProgressStdio")() {
1219
- static Default = Layer.succeed(ProgressStdio, defaultStdioService);
1281
+ const makeRendererv2InkRendererService = Effect.gen(function* () {
1282
+ const store = yield* ProgressStore;
1283
+ const stdio = yield* ProgressStdio;
1284
+ const proot = /* @__PURE__ */ jsx(ProgressRoot, { store });
1285
+ return { run: Effect.sync(() => render(proot, {
1286
+ stdout: stdio.stdout,
1287
+ stderr: stdio.stderr,
1288
+ patchConsole: true,
1289
+ exitOnCtrlC: false,
1290
+ debug: false,
1291
+ maxFps: MAX_FPS
1292
+ })).pipe(Effect.flatMap((instance) => Effect.never.pipe(Effect.ensuring(Effect.gen(function* () {
1293
+ store.flush();
1294
+ instance.rerender(proot);
1295
+ yield* Effect.sync(() => {
1296
+ instance.unmount();
1297
+ });
1298
+ }))))) };
1299
+ });
1300
+ var Renderer = class Renderer extends Context.Service()("stromseng.dev/effective-progress/Renderer") {
1301
+ static layer = Layer.effect(Renderer, makeRendererv2InkRendererService);
1220
1302
  };
1221
1303
 
1222
1304
  //#endregion
1223
1305
  //#region src/services/progress.ts
1224
1306
  /** Builds the scoped implementation used by `ProgressService.task(...)` without auto-providing services. */
1225
1307
  const makeProgressService = Effect.gen(function* () {
1226
- const stdio = yield* ProgressStdio;
1227
- const inkRenderer = yield* InkRenderer;
1228
- const outerConsole = yield* Effect.console;
1229
- const store = makeProgressRenderStore();
1230
- const currentParentRef = yield* FiberRef.make(Option.none());
1308
+ const inkRenderer = yield* Renderer;
1309
+ const store = yield* ProgressStore;
1231
1310
  const scope = yield* Effect.scope;
1232
- const log = (...args) => args.length === 0 ? Effect.void : outerConsole.log(...args);
1233
- yield* Effect.forkIn(inkRenderer.run(store, stdio), scope);
1311
+ const parentOwner = Symbol();
1312
+ const currentParentId = Effect.map(CurrentParent, (cp) => Option.isSome(cp) && cp.value.owner === parentOwner ? Option.some(cp.value.taskId) : Option.none());
1313
+ const log = (...args) => Effect.log(...args);
1314
+ yield* Effect.forkIn(inkRenderer.run, scope, { startImmediately: true });
1234
1315
  yield* Effect.sleep("0 millis");
1235
1316
  const addTask = (options) => Effect.gen(function* () {
1236
- const resolvedParentId = options.parentId === void 0 ? yield* FiberRef.get(currentParentRef) : Option.some(options.parentId);
1317
+ const resolvedParentId = options.parentId === void 0 ? yield* currentParentId : Option.some(options.parentId);
1237
1318
  return yield* store.addTask({
1238
1319
  ...options,
1239
1320
  parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : void 0
@@ -1267,14 +1348,17 @@ const makeProgressService = Effect.gen(function* () {
1267
1348
  else yield* failTask(taskId);
1268
1349
  });
1269
1350
  const scopedTask = dual(2, (effect, options) => Effect.gen(function* () {
1270
- const inheritedParentId = yield* FiberRef.get(currentParentRef);
1351
+ const inheritedParentId = yield* currentParentId;
1271
1352
  const resolvedParentId = options.parentId === void 0 ? inheritedParentId : Option.some(options.parentId);
1272
1353
  const taskId = yield* addTask({
1273
1354
  ...options,
1274
1355
  parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : void 0,
1275
1356
  transient: options.transient
1276
1357
  });
1277
- return yield* Effect.locally(Effect.provideService(effect, Task, taskId), currentParentRef, Option.some(taskId));
1358
+ return yield* Effect.provideService(Effect.provideService(effect, Task, taskId), CurrentParent, Option.some({
1359
+ owner: parentOwner,
1360
+ taskId
1361
+ }));
1278
1362
  }));
1279
1363
  const service = {
1280
1364
  addTask,
@@ -1312,15 +1396,14 @@ const makeProgressService = Effect.gen(function* () {
1312
1396
  };
1313
1397
  return Progress.of(service);
1314
1398
  });
1315
- var Progress = class Progress extends Context.Tag("stromseng.dev/effective-progress/Progress")() {
1316
- static Default = Layer.unwrapEffect(Effect.gen(function* () {
1317
- const stdioOption = yield* Effect.serviceOption(ProgressStdio);
1318
- const inkRendererOption = yield* Effect.serviceOption(InkRenderer);
1319
- let layer = Layer.scoped(Progress, makeProgressService);
1320
- if (Option.isNone(inkRendererOption)) layer = layer.pipe(Layer.provide(InkRenderer.Default));
1321
- if (Option.isNone(stdioOption)) layer = layer.pipe(Layer.provide(ProgressStdio.Default));
1322
- return layer;
1323
- }));
1399
+ /**
1400
+ * Returns a layer that uses an already-provided service for the given tag when available,
1401
+ * or falls back to the supplied default layer otherwise.
1402
+ */
1403
+ const serviceOptionDefaultLayer = (tag, defaultLayer) => Layer.unwrap(Effect.map(Effect.serviceOption(tag), (option) => Option.getOrElse(Option.map(option, (service) => Layer.succeed(tag, service)), () => defaultLayer)));
1404
+ const CurrentParent = Context.Reference("stromseng.dev/effective-progress/CurrentParent", { defaultValue: Option.none });
1405
+ var Progress = class Progress extends Context.Service()("stromseng.dev/effective-progress/Progress") {
1406
+ static layer = Layer.effect(Progress, makeProgressService).pipe(Layer.provide(serviceOptionDefaultLayer(Renderer, Renderer.layer).pipe(Layer.provideMerge(serviceOptionDefaultLayer(ProgressStdio, ProgressStdio.layer)), Layer.provideMerge(ProgressStore.layer))));
1324
1407
  };
1325
1408
 
1326
1409
  //#endregion
@@ -1338,7 +1421,7 @@ const inferTotal = (iterable) => {
1338
1421
  const provideProgress = (effect) => Effect.gen(function* () {
1339
1422
  const existing = yield* Effect.serviceOption(Progress);
1340
1423
  if (Option.isSome(existing)) return yield* Effect.provideService(effect, Progress, existing.value);
1341
- return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
1424
+ return yield* Effect.scoped(Effect.provide(effect, Progress.layer, { local: true }));
1342
1425
  });
1343
1426
  /**
1344
1427
  * Runs an effect inside a task, creating and providing a `Progress` service automatically when one
@@ -1355,7 +1438,7 @@ const task = dual(2, (effectOrCallback, options) => {
1355
1438
  });
1356
1439
  const wrapEffects = (effects, tap) => Array.isArray(effects) ? effects.map(tap) : Object.fromEntries(Object.entries(effects).map(([k, effect]) => [k, tap(effect)]));
1357
1440
  const countEffects = (effects) => Array.isArray(effects) ? effects.length : Object.keys(effects).length;
1358
- const isCollectAllMode = (mode) => mode === "either" || mode === "validate";
1441
+ const isCollectAllMode = (mode) => mode === "result";
1359
1442
  const allCountDisplay = (mode) => isCollectAllMode(mode) ? "detailed" : "processedOnly";
1360
1443
  const wrapTrackedEffect = (progress, taskId, effect) => Effect.gen(function* () {
1361
1444
  const exit = yield* Effect.exit(effect);
@@ -1363,7 +1446,7 @@ const wrapTrackedEffect = (progress, taskId, effect) => Effect.gen(function* ()
1363
1446
  yield* progress.incrementSucceeded(taskId, 1);
1364
1447
  return exit.value;
1365
1448
  }
1366
- if (Cause.isInterruptedOnly(exit.cause)) return yield* Effect.failCause(exit.cause);
1449
+ if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause);
1367
1450
  yield* progress.incrementFailed(taskId, 1);
1368
1451
  return yield* Effect.failCause(exit.cause);
1369
1452
  });
@@ -1383,10 +1466,8 @@ const all = dual(2, (effects, options) => provideProgress(Effect.gen(function* (
1383
1466
  const taskId = handle.id;
1384
1467
  const exit = yield* Effect.exit(Effect.all(wrapEffects(effects, (effect) => wrapTrackedEffect(progress, taskId, effect)), {
1385
1468
  concurrency: options.concurrency,
1386
- batching: options.batching,
1387
1469
  discard: options.discard,
1388
- mode: options.mode,
1389
- concurrentFinalizers: options.concurrentFinalizers
1470
+ mode: options.mode
1390
1471
  }));
1391
1472
  if (Exit.isSuccess(exit)) yield* progress.completeTask(taskId);
1392
1473
  else if (!isCollectAllMode(options.mode)) yield* progress.failTask(taskId);
@@ -1412,9 +1493,7 @@ const forEach = dual(3, (iterable, f, options) => provideProgress(Effect.gen(fun
1412
1493
  const taskId = handle.id;
1413
1494
  const exit = yield* Effect.exit(Effect.forEach(iterable, (item, index) => wrapTrackedEffect(progress, taskId, f(item, index)), {
1414
1495
  concurrency: options.concurrency,
1415
- batching: options.batching,
1416
- discard: options.discard,
1417
- concurrentFinalizers: options.concurrentFinalizers
1496
+ discard: options.discard
1418
1497
  }));
1419
1498
  if (Exit.isSuccess(exit)) yield* progress.completeTask(taskId);
1420
1499
  else yield* progress.failTask(taskId);
@@ -1431,4 +1510,4 @@ const forEach = dual(3, (iterable, f, options) => provideProgress(Effect.gen(fun
1431
1510
  })));
1432
1511
 
1433
1512
  //#endregion
1434
- export { columns_exports as Columns, Progress, ProgressStdio, ProgressTaskEventSchema, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskCountDisplaySchema, TaskFailedEvent, TaskId, TaskRemovedEvent, TaskSnapshot, TaskSnapshotSchema, TaskStatusSchema, TaskUnitsSchema, TaskUpdatedEvent, all, decodeProgressTaskEvent, forEach, task };
1513
+ export { columns_exports as Columns, Progress, ProgressStdio, ProgressTaskEventSchema, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskCountDisplaySchema, TaskFailedEvent, TaskId, TaskProgressSampleSchema, TaskRemovedEvent, TaskSnapshot, TaskSnapshotSchema, TaskStatusSchema, TaskUnitsSchema, TaskUpdatedEvent, all, decodeProgressTaskEvent, forEach, task };