effective-progress 0.5.0 → 0.5.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,815 @@
1
+ import { Brand, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref, Schema } from "effect";
2
+ import { dual } from "effect/Function";
3
+ import { Writable } from "node:stream";
4
+ import { Box, Text, render } from "ink";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+
7
+ //#region src/ink-renderer/format.ts
8
+ const SPINNER_FRAMES = [
9
+ "⠋",
10
+ "⠙",
11
+ "⠹",
12
+ "⠸",
13
+ "⠼",
14
+ "⠴",
15
+ "⠦",
16
+ "⠧",
17
+ "⠇",
18
+ "⠏"
19
+ ];
20
+ const formatDurationSeconds = (seconds) => {
21
+ const value = Math.max(0, Math.floor(seconds));
22
+ if (value < 60) return `${value}s`;
23
+ if (value < 3600) {
24
+ const mins = Math.floor(value / 60);
25
+ const secs = value % 60;
26
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
27
+ }
28
+ const hours = Math.floor(value / 3600);
29
+ const mins = Math.floor(value % 3600 / 60);
30
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
31
+ };
32
+ const formatElapsed = (task, now) => {
33
+ return formatDurationSeconds(Math.max(0, (task.completedAt ?? now) - task.startedAt) / 1e3);
34
+ };
35
+ const formatEta = (task, now) => {
36
+ if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") return "";
37
+ const { completed, total } = task.units;
38
+ const remaining = total - completed;
39
+ if (completed <= 0 || remaining <= 0) return "";
40
+ const elapsedMillis = Math.max(1, now - task.startedAt);
41
+ return formatDurationSeconds(Math.max(0, Math.floor(elapsedMillis / completed * remaining)) / 1e3);
42
+ };
43
+ const formatAmount = (task, tick) => {
44
+ if (task.units._tag === "DeterminateTaskUnits") {
45
+ const totalText = `${task.units.total}`;
46
+ return `${`${task.units.completed}`.padStart(totalText.length, " ")}/${totalText}`;
47
+ }
48
+ if (task.status === "running") return SPINNER_FRAMES[(task.units.spinnerFrame + tick) % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
49
+ return task.status === "done" ? "✓" : "✗";
50
+ };
51
+
52
+ //#endregion
53
+ //#region src/ink-renderer/tree.ts
54
+ const treeAncestorPrefix = (tree) => tree.ancestorHasNextSibling.slice(1).map((hasNext) => hasNext ? "│ " : " ").join("");
55
+ const renderTreePrefix = (tree) => {
56
+ if (tree.depth <= 0) return "";
57
+ return `${treeAncestorPrefix(tree)}${tree.hasNextSibling ? "├─ " : "└─ "}`;
58
+ };
59
+ const computeTreeInfo = (ordered) => {
60
+ const hasNextSiblingByIndex = Array.from({ length: ordered.length }, () => false);
61
+ for (let i = 0; i < ordered.length; i++) {
62
+ const depth = ordered[i].depth;
63
+ for (let j = i + 1; j < ordered.length; j++) {
64
+ const candidateDepth = ordered[j].depth;
65
+ if (candidateDepth < depth) break;
66
+ if (candidateDepth === depth) {
67
+ hasNextSiblingByIndex[i] = true;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ const ancestorStateByDepth = [];
73
+ return ordered.map((entry, index) => {
74
+ const depth = entry.depth;
75
+ ancestorStateByDepth.length = depth;
76
+ const hasChildren = index + 1 < ordered.length && ordered[index + 1] !== void 0 && ordered[index + 1].depth > depth;
77
+ const tree = {
78
+ depth,
79
+ hasNextSibling: hasNextSiblingByIndex[index] ?? false,
80
+ hasChildren,
81
+ ancestorHasNextSibling: [...ancestorStateByDepth]
82
+ };
83
+ ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
84
+ return {
85
+ ...entry,
86
+ tree
87
+ };
88
+ });
89
+ };
90
+
91
+ //#endregion
92
+ //#region src/ink-renderer/layout.ts
93
+ const DEFAULT_BAR_WIDTH = 20;
94
+ const MIN_DESCRIPTION_WIDTH = 8;
95
+ const MIN_BAR_WIDTH = 8;
96
+ const MIN_ELAPSED_WIDTH = 3;
97
+ const MIN_AMOUNT_WIDTH = 1;
98
+ const BASELINE_ROW_WIDTH = 100;
99
+ const MIN_DESCRIPTION_COLUMNS_FOR_TREE = 24;
100
+ const RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR = Array.from("59m 59s").length;
101
+ const RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR = Array.from("ETA: 59m 59s").length;
102
+ const textWidth = (text) => Array.from(text).length;
103
+ const computeWidths = (rows, now, tick, terminalColumns, includeTree = true) => {
104
+ let hasDeterminate = false;
105
+ let description = MIN_DESCRIPTION_WIDTH;
106
+ let amount = 1;
107
+ let elapsed = RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR;
108
+ let eta = RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR;
109
+ for (const row of rows) {
110
+ const { task, tree } = row;
111
+ const treePrefix = includeTree ? renderTreePrefix(tree) : "";
112
+ description = Math.max(description, textWidth(`${treePrefix}${task.description}`));
113
+ if (task.units._tag === "DeterminateTaskUnits") hasDeterminate = true;
114
+ amount = Math.max(amount, textWidth(formatAmount(task, tick)));
115
+ elapsed = Math.max(elapsed, textWidth(formatElapsed(task, now)));
116
+ if (task.status === "running" && task.units._tag === "DeterminateTaskUnits") {
117
+ const etaValue = formatEta(task, now);
118
+ const etaText = `ETA: ${etaValue.length > 0 ? etaValue : "--"}`;
119
+ eta = Math.max(eta, textWidth(etaText));
120
+ }
121
+ }
122
+ let widths = {
123
+ description,
124
+ bar: hasDeterminate ? DEFAULT_BAR_WIDTH : 0,
125
+ amount,
126
+ elapsed,
127
+ eta
128
+ };
129
+ const visible = (w) => [
130
+ w.description,
131
+ w.bar,
132
+ w.amount,
133
+ w.elapsed,
134
+ w.eta
135
+ ].filter((width) => width > 0);
136
+ const total = (w) => {
137
+ const cols = visible(w);
138
+ return cols.reduce((sum, width) => sum + width, 0) + Math.max(0, cols.length - 1);
139
+ };
140
+ const baselineTarget = Math.max(BASELINE_ROW_WIDTH, total(widths));
141
+ const target = terminalColumns === void 0 ? baselineTarget : Math.max(1, Math.min(Math.max(1, Math.floor(terminalColumns)), baselineTarget));
142
+ if (total(widths) < target) widths.description += target - total(widths);
143
+ else if (total(widths) > target) {
144
+ let overflow = total(widths) - target;
145
+ const reduceBy = (key, min) => {
146
+ if (overflow <= 0) return;
147
+ const current = widths[key];
148
+ if (current <= min) return;
149
+ const reducible = current - min;
150
+ const delta = Math.min(reducible, overflow);
151
+ widths = {
152
+ ...widths,
153
+ [key]: current - delta
154
+ };
155
+ overflow -= delta;
156
+ };
157
+ reduceBy("description", MIN_DESCRIPTION_WIDTH);
158
+ reduceBy("eta", 0);
159
+ reduceBy("bar", MIN_BAR_WIDTH);
160
+ reduceBy("bar", 0);
161
+ reduceBy("elapsed", MIN_ELAPSED_WIDTH);
162
+ reduceBy("amount", MIN_AMOUNT_WIDTH);
163
+ reduceBy("description", 0);
164
+ if (total(widths) < target) widths.description += target - total(widths);
165
+ }
166
+ return {
167
+ row: total(widths),
168
+ description: widths.description,
169
+ bar: widths.bar,
170
+ amount: widths.amount,
171
+ elapsed: widths.elapsed,
172
+ eta: widths.eta
173
+ };
174
+ };
175
+ const computeSharedColumnWidths = (rows, now, tick, terminalColumns) => {
176
+ const withTree = computeWidths(rows, now, tick, terminalColumns, true);
177
+ if (withTree.description >= MIN_DESCRIPTION_COLUMNS_FOR_TREE) return {
178
+ ...withTree,
179
+ showTree: true
180
+ };
181
+ return {
182
+ ...computeWidths(rows, now, tick, terminalColumns, false),
183
+ showTree: false
184
+ };
185
+ };
186
+
187
+ //#endregion
188
+ //#region src/ink-renderer/columns/amount-column.tsx
189
+ const AmountColumn = ({ task, tick }) => {
190
+ const text = formatAmount(task, tick);
191
+ return /* @__PURE__ */ jsx(Text, {
192
+ color: task.status === "failed" ? "red" : task.status === "done" ? "green" : task.units._tag === "DeterminateTaskUnits" ? "whiteBright" : "yellow",
193
+ children: text
194
+ });
195
+ };
196
+
197
+ //#endregion
198
+ //#region src/ink-renderer/columns/bar-column.tsx
199
+ const clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), maximum);
200
+ const BarColumn = ({ task, width }) => {
201
+ if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, {});
202
+ const barWidth = Math.max(1, Math.floor(width));
203
+ const safeTotal = Math.max(1, task.units.total);
204
+ const ratio = task.status === "done" ? 1 : clamp(task.units.completed / safeTotal, 0, 1);
205
+ const filled = Math.round(barWidth * ratio);
206
+ const empty = Math.max(0, barWidth - filled);
207
+ const bar = `${"━".repeat(filled)}${"─".repeat(empty)}`;
208
+ return /* @__PURE__ */ jsx(Text, {
209
+ wrap: "truncate-end",
210
+ color: task.status === "failed" ? "red" : task.status === "done" ? "green" : "blue",
211
+ children: bar
212
+ });
213
+ };
214
+
215
+ //#endregion
216
+ //#region src/ink-renderer/columns/description-column.tsx
217
+ const DescriptionColumn = ({ task, tree, showTree }) => /* @__PURE__ */ jsx(Text, {
218
+ wrap: "truncate-end",
219
+ children: `${showTree ? renderTreePrefix(tree) : ""}${task.description}`
220
+ });
221
+
222
+ //#endregion
223
+ //#region src/ink-renderer/columns/elapsed-column.tsx
224
+ const ElapsedColumn = ({ task, now }) => /* @__PURE__ */ jsx(Text, {
225
+ color: "gray",
226
+ children: formatElapsed(task, now)
227
+ });
228
+
229
+ //#endregion
230
+ //#region src/ink-renderer/columns/eta-column.tsx
231
+ const EtaColumn = ({ task, now }) => {
232
+ if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, {});
233
+ const eta = formatEta(task, now);
234
+ return /* @__PURE__ */ jsx(Text, {
235
+ wrap: "truncate-end",
236
+ color: "gray",
237
+ children: eta.length > 0 ? `ETA: ${eta}` : "ETA: --"
238
+ });
239
+ };
240
+
241
+ //#endregion
242
+ //#region src/ink-renderer/task-row.tsx
243
+ const TaskRow = ({ row, now, tick, isTTY, widths }) => {
244
+ const props = {
245
+ task: row.task,
246
+ tree: row.tree,
247
+ now,
248
+ tick,
249
+ isTTY,
250
+ showTree: widths.showTree
251
+ };
252
+ return /* @__PURE__ */ jsxs(Box, {
253
+ flexDirection: "row",
254
+ minWidth: widths.row,
255
+ children: [
256
+ /* @__PURE__ */ jsx(Box, {
257
+ width: widths.description,
258
+ flexShrink: 1,
259
+ marginRight: 1,
260
+ children: /* @__PURE__ */ jsx(DescriptionColumn, { ...props })
261
+ }),
262
+ widths.bar > 0 ? /* @__PURE__ */ jsx(Box, {
263
+ width: widths.bar,
264
+ flexShrink: 0,
265
+ marginRight: 1,
266
+ children: /* @__PURE__ */ jsx(BarColumn, {
267
+ ...props,
268
+ width: Math.max(1, Math.min(widths.bar, DEFAULT_BAR_WIDTH))
269
+ })
270
+ }) : null,
271
+ /* @__PURE__ */ jsx(Box, {
272
+ width: widths.amount,
273
+ flexShrink: 0,
274
+ marginRight: 1,
275
+ children: /* @__PURE__ */ jsx(AmountColumn, { ...props })
276
+ }),
277
+ /* @__PURE__ */ jsx(Box, {
278
+ width: widths.elapsed,
279
+ flexShrink: 0,
280
+ marginRight: 1,
281
+ children: /* @__PURE__ */ jsx(ElapsedColumn, { ...props })
282
+ }),
283
+ widths.eta > 0 ? /* @__PURE__ */ jsx(Box, {
284
+ width: widths.eta,
285
+ flexShrink: 0,
286
+ children: /* @__PURE__ */ jsx(EtaColumn, { ...props })
287
+ }) : null
288
+ ]
289
+ });
290
+ };
291
+
292
+ //#endregion
293
+ //#region src/ink-renderer/app.tsx
294
+ const ProgressApp = ({ rows, now, tick, isTTY, terminalColumns }) => {
295
+ const widths = computeSharedColumnWidths(rows, now, tick, terminalColumns);
296
+ return /* @__PURE__ */ jsx(Box, {
297
+ flexDirection: "column",
298
+ children: rows.map((row) => /* @__PURE__ */ jsx(TaskRow, {
299
+ row,
300
+ now,
301
+ tick,
302
+ isTTY,
303
+ widths
304
+ }, row.task.id))
305
+ });
306
+ };
307
+
308
+ //#endregion
309
+ //#region src/ink-renderer/model.ts
310
+ const orderedVisibleTasks = (store) => store.renderOrder.flatMap((row) => {
311
+ const snapshot = store.tasks.get(row.id);
312
+ if (!snapshot || snapshot.transient && snapshot.status !== "running") return [];
313
+ return [{
314
+ snapshot,
315
+ depth: row.depth
316
+ }];
317
+ });
318
+ const toTaskRows = (store) => computeTreeInfo(orderedVisibleTasks(store)).map((entry) => ({
319
+ task: entry.snapshot,
320
+ tree: entry.tree
321
+ }));
322
+
323
+ //#endregion
324
+ //#region src/ink-renderer/service.tsx
325
+ const RENDER_INTERVAL_MILLIS = 100;
326
+ const hasRunningSpinners = (tasks) => tasks.some((task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits");
327
+ const createInkWritable = (terminal) => new Writable({ write(chunk, _encoding, callback) {
328
+ try {
329
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : `${chunk}`;
330
+ Effect.runSync(terminal.writeStderr(text));
331
+ callback();
332
+ } catch (error) {
333
+ callback(error);
334
+ }
335
+ } });
336
+ const makeDefaultInkRenderer = () => ({ run: (storeRef, dirtyRef, terminal, isTTY) => Effect.gen(function* () {
337
+ const output = createInkWritable(terminal);
338
+ let instance;
339
+ let tick = 0;
340
+ let rendererActive = false;
341
+ const renderStore = (store, now, terminalColumns) => Effect.sync(() => {
342
+ const app = /* @__PURE__ */ jsx(ProgressApp, {
343
+ rows: toTaskRows(store),
344
+ now,
345
+ tick,
346
+ isTTY,
347
+ terminalColumns
348
+ });
349
+ if (instance === void 0) {
350
+ instance = render(app, {
351
+ stdout: output,
352
+ stderr: output,
353
+ patchConsole: true,
354
+ exitOnCtrlC: false,
355
+ debug: false
356
+ });
357
+ return;
358
+ }
359
+ instance.rerender(app);
360
+ });
361
+ return yield* Effect.gen(function* () {
362
+ rendererActive = true;
363
+ while (true) {
364
+ const dirty = yield* Ref.getAndSet(dirtyRef, false);
365
+ const store = yield* Ref.get(storeRef);
366
+ const tasks = Array.from(store.tasks.values()).filter((task) => !(task.transient && task.status !== "running"));
367
+ if (dirty || hasRunningSpinners(tasks)) yield* renderStore(store, yield* Clock.currentTimeMillis, isTTY ? yield* terminal.stderrColumns : void 0);
368
+ tick += 1;
369
+ yield* Effect.sleep(RENDER_INTERVAL_MILLIS);
370
+ }
371
+ }).pipe(Effect.ensuring(Effect.gen(function* () {
372
+ if (rendererActive) yield* renderStore(yield* Ref.get(storeRef), yield* Clock.currentTimeMillis, isTTY ? yield* terminal.stderrColumns : void 0);
373
+ yield* Effect.sync(() => {
374
+ instance?.unmount();
375
+ });
376
+ })));
377
+ }) });
378
+ var InkRenderer = class InkRenderer extends Context.Tag("stromseng.dev/effective-progress/InkRenderer")() {
379
+ static Default = Layer.succeed(InkRenderer, InkRenderer.of(makeDefaultInkRenderer()));
380
+ };
381
+
382
+ //#endregion
383
+ //#region src/terminal.ts
384
+ const withRawInputCapture = (effect) => Effect.suspend(() => {
385
+ if (!process.stdin.isTTY) return effect;
386
+ const stdin = process.stdin;
387
+ const wasRaw = Boolean(stdin.isRaw);
388
+ const onData = (chunk) => {
389
+ if (chunk.length === 1 && chunk[0] === 3) process.kill(process.pid, "SIGINT");
390
+ };
391
+ return Effect.acquireUseRelease(Effect.sync(() => {
392
+ stdin.resume();
393
+ stdin.setRawMode?.(true);
394
+ stdin.on("data", onData);
395
+ }), () => effect, () => Effect.sync(() => {
396
+ try {
397
+ stdin.off("data", onData);
398
+ stdin.setRawMode?.(wasRaw);
399
+ stdin.pause();
400
+ } catch {}
401
+ }));
402
+ });
403
+ const defaultTerminalService = {
404
+ isTTY: Effect.sync(() => Boolean(process.stderr.isTTY)),
405
+ stderrRows: Effect.sync(() => process.stderr.rows),
406
+ stderrColumns: Effect.sync(() => process.stderr.columns),
407
+ writeStderr: (text) => Effect.sync(() => {
408
+ process.stderr.write(text);
409
+ }),
410
+ withRawInputCapture
411
+ };
412
+ var ProgressTerminal = class ProgressTerminal extends Context.Tag("stromseng.dev/ProgressTerminal")() {
413
+ static Default = Layer.succeed(ProgressTerminal, defaultTerminalService);
414
+ };
415
+
416
+ //#endregion
417
+ //#region src/types.ts
418
+ const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
419
+ const TaskId = Brand.nominal();
420
+ const TaskStatusSchema = Schema.Literal("running", "done", "failed");
421
+ var DeterminateTaskUnits = class extends Schema.TaggedClass()("DeterminateTaskUnits", {
422
+ completed: Schema.Number,
423
+ total: Schema.Number
424
+ }) {};
425
+ var IndeterminateTaskUnits = class extends Schema.TaggedClass()("IndeterminateTaskUnits", { spinnerFrame: Schema.Number }) {};
426
+ const TaskUnitsSchema = Schema.Union(DeterminateTaskUnits, IndeterminateTaskUnits);
427
+ var TaskSnapshot = class extends Schema.TaggedClass()("TaskSnapshot", {
428
+ id: TaskIdSchema,
429
+ parentId: Schema.NullOr(TaskIdSchema),
430
+ description: Schema.String,
431
+ status: TaskStatusSchema,
432
+ transient: Schema.Boolean,
433
+ units: TaskUnitsSchema,
434
+ startedAt: Schema.Number,
435
+ completedAt: Schema.NullOr(Schema.Number)
436
+ }) {};
437
+ var Task = class extends Context.Tag("stromseng.dev/effective-progress/Task")() {};
438
+ var TaskAddedEvent = class extends Schema.TaggedClass()("TaskAdded", {
439
+ taskId: TaskIdSchema,
440
+ parentId: Schema.NullOr(TaskIdSchema),
441
+ description: Schema.String,
442
+ total: Schema.optional(Schema.Number),
443
+ transient: Schema.Boolean
444
+ }) {};
445
+ var TaskUpdatedEvent = class extends Schema.TaggedClass()("TaskUpdated", {
446
+ taskId: TaskIdSchema,
447
+ description: Schema.optional(Schema.String),
448
+ completed: Schema.optional(Schema.Number),
449
+ total: Schema.optional(Schema.Number),
450
+ transient: Schema.optional(Schema.Boolean)
451
+ }) {};
452
+ var TaskAdvancedEvent = class extends Schema.TaggedClass()("TaskAdvanced", {
453
+ taskId: TaskIdSchema,
454
+ amount: Schema.Number
455
+ }) {};
456
+ var TaskCompletedEvent = class extends Schema.TaggedClass()("TaskCompleted", { taskId: TaskIdSchema }) {};
457
+ var TaskFailedEvent = class extends Schema.TaggedClass()("TaskFailed", { taskId: TaskIdSchema }) {};
458
+ var TaskRemovedEvent = class extends Schema.TaggedClass()("TaskRemoved", { taskId: TaskIdSchema }) {};
459
+ const ProgressTaskEventSchema = Schema.Union(TaskAddedEvent, TaskUpdatedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskRemovedEvent);
460
+ const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema);
461
+
462
+ //#endregion
463
+ //#region src/runtime.ts
464
+ const updatedSnapshot = (snapshot, options) => {
465
+ const currentUnits = snapshot.units;
466
+ const units = (() => {
467
+ if (options.total !== void 0) {
468
+ if (options.total <= 0) return new IndeterminateTaskUnits({ spinnerFrame: 0 });
469
+ const completed = options.completed ?? (currentUnits._tag === "DeterminateTaskUnits" ? currentUnits.completed : 0);
470
+ return new DeterminateTaskUnits({
471
+ completed: Math.max(0, completed),
472
+ total: Math.max(0, options.total)
473
+ });
474
+ }
475
+ if (currentUnits._tag === "DeterminateTaskUnits") {
476
+ if (options.completed === void 0) return currentUnits;
477
+ return new DeterminateTaskUnits({
478
+ completed: Math.max(0, options.completed),
479
+ total: currentUnits.total
480
+ });
481
+ }
482
+ return currentUnits;
483
+ })();
484
+ return new TaskSnapshot({
485
+ id: snapshot.id,
486
+ parentId: snapshot.parentId,
487
+ description: options.description ?? snapshot.description,
488
+ status: snapshot.status,
489
+ transient: options.transient ?? snapshot.transient,
490
+ units,
491
+ startedAt: snapshot.startedAt,
492
+ completedAt: snapshot.completedAt
493
+ });
494
+ };
495
+ const withTransient = (snapshot, transient) => new TaskSnapshot({
496
+ id: snapshot.id,
497
+ parentId: snapshot.parentId,
498
+ description: snapshot.description,
499
+ status: snapshot.status,
500
+ transient,
501
+ units: snapshot.units,
502
+ startedAt: snapshot.startedAt,
503
+ completedAt: snapshot.completedAt
504
+ });
505
+ const findInsertionIndex = (renderOrder, parentId) => {
506
+ if (parentId === null) return {
507
+ index: renderOrder.length,
508
+ depth: 0
509
+ };
510
+ const parentIdx = renderOrder.findIndex((row) => row.id === parentId);
511
+ if (parentIdx === -1) return {
512
+ index: renderOrder.length,
513
+ depth: 0
514
+ };
515
+ const parentDepth = renderOrder[parentIdx].depth;
516
+ let i = parentIdx + 1;
517
+ while (i < renderOrder.length && renderOrder[i].depth > parentDepth) i++;
518
+ return {
519
+ index: i,
520
+ depth: parentDepth + 1
521
+ };
522
+ };
523
+ const removeFromRenderOrder = (renderOrder, taskId) => {
524
+ const idx = renderOrder.findIndex((row) => row.id === taskId);
525
+ if (idx === -1) return renderOrder;
526
+ const taskDepth = renderOrder[idx].depth;
527
+ let end = idx + 1;
528
+ while (end < renderOrder.length && renderOrder[end].depth > taskDepth) end++;
529
+ const next = [...renderOrder];
530
+ next.splice(idx, end - idx);
531
+ return next;
532
+ };
533
+ const makeProgressService = Effect.gen(function* () {
534
+ const terminal = yield* ProgressTerminal;
535
+ const inkRenderer = yield* InkRenderer;
536
+ const outerConsole = yield* Effect.console;
537
+ const isTTY = yield* terminal.isTTY;
538
+ const nextTaskIdRef = yield* Ref.make(0);
539
+ const storeRef = yield* Ref.make({
540
+ tasks: /* @__PURE__ */ new Map(),
541
+ renderOrder: []
542
+ });
543
+ const dirtyRef = yield* Ref.make(true);
544
+ const currentParentRef = yield* FiberRef.make(Option.none());
545
+ const scope = yield* Effect.scope;
546
+ const markDirty = Ref.set(dirtyRef, true);
547
+ const log = (...args) => args.length === 0 ? Effect.void : outerConsole.log(...args);
548
+ yield* Effect.forkIn(inkRenderer.run(storeRef, dirtyRef, terminal, isTTY), scope);
549
+ yield* Effect.sleep("0 millis");
550
+ const addTask = (options) => Effect.gen(function* () {
551
+ const resolvedParentId = options.parentId === void 0 ? yield* FiberRef.get(currentParentRef) : Option.some(options.parentId);
552
+ const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
553
+ const units = options.total === void 0 || options.total <= 0 ? new IndeterminateTaskUnits({ spinnerFrame: 0 }) : new DeterminateTaskUnits({
554
+ completed: 0,
555
+ total: Math.max(0, options.total)
556
+ });
557
+ const store = yield* Ref.get(storeRef);
558
+ const parentSnapshot = Option.isSome(resolvedParentId) ? store.tasks.get(resolvedParentId.value) : void 0;
559
+ const now = yield* Clock.currentTimeMillis;
560
+ const parentIdValue = Option.getOrNull(resolvedParentId);
561
+ const snapshot = new TaskSnapshot({
562
+ id: taskId,
563
+ parentId: parentIdValue,
564
+ description: options.description,
565
+ status: "running",
566
+ transient: parentSnapshot?.transient ?? options.transient ?? false,
567
+ units,
568
+ startedAt: now,
569
+ completedAt: null
570
+ });
571
+ yield* Ref.update(storeRef, (s) => {
572
+ const nextTasks = new Map(s.tasks);
573
+ nextTasks.set(taskId, snapshot);
574
+ const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
575
+ const nextOrder = [...s.renderOrder];
576
+ nextOrder.splice(index, 0, {
577
+ id: taskId,
578
+ depth
579
+ });
580
+ return {
581
+ tasks: nextTasks,
582
+ renderOrder: nextOrder
583
+ };
584
+ });
585
+ yield* markDirty;
586
+ return taskId;
587
+ });
588
+ const updateTask = (taskId, options) => Ref.update(storeRef, (store) => {
589
+ const snapshot = store.tasks.get(taskId);
590
+ if (!snapshot) return store;
591
+ const nextTasks = new Map(store.tasks);
592
+ const nextSnapshot = updatedSnapshot(snapshot, options);
593
+ nextTasks.set(taskId, nextSnapshot);
594
+ if (options.transient !== void 0) for (const [candidateId, candidate] of store.tasks.entries()) {
595
+ if (candidateId === taskId) continue;
596
+ let parentId = candidate.parentId;
597
+ let isDescendant = false;
598
+ while (parentId !== null) {
599
+ if (parentId === taskId) {
600
+ isDescendant = true;
601
+ break;
602
+ }
603
+ parentId = store.tasks.get(parentId)?.parentId ?? null;
604
+ }
605
+ if (isDescendant) nextTasks.set(candidateId, withTransient(candidate, nextSnapshot.transient));
606
+ }
607
+ return {
608
+ tasks: nextTasks,
609
+ renderOrder: store.renderOrder
610
+ };
611
+ }).pipe(Effect.zipRight(markDirty));
612
+ const advanceTask = (taskId, amount = 1) => Ref.update(storeRef, (store) => {
613
+ const snapshot = store.tasks.get(taskId);
614
+ if (!snapshot) return store;
615
+ const units = snapshot.units._tag === "DeterminateTaskUnits" ? new DeterminateTaskUnits({
616
+ completed: Math.min(snapshot.units.total, snapshot.units.completed + amount),
617
+ total: snapshot.units.total
618
+ }) : new IndeterminateTaskUnits({ spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount) });
619
+ const nextTasks = new Map(store.tasks);
620
+ nextTasks.set(taskId, new TaskSnapshot({
621
+ id: snapshot.id,
622
+ parentId: snapshot.parentId,
623
+ description: snapshot.description,
624
+ status: snapshot.status,
625
+ transient: snapshot.transient,
626
+ units,
627
+ startedAt: snapshot.startedAt,
628
+ completedAt: snapshot.completedAt
629
+ }));
630
+ return {
631
+ tasks: nextTasks,
632
+ renderOrder: store.renderOrder
633
+ };
634
+ }).pipe(Effect.zipRight(markDirty));
635
+ const completeTask = (taskId) => Effect.gen(function* () {
636
+ const now = yield* Clock.currentTimeMillis;
637
+ yield* Ref.update(storeRef, (store) => {
638
+ const snapshot = store.tasks.get(taskId);
639
+ if (!snapshot) return store;
640
+ const nextTasks = new Map(store.tasks);
641
+ if (snapshot.transient) {
642
+ nextTasks.delete(taskId);
643
+ return {
644
+ tasks: nextTasks,
645
+ renderOrder: removeFromRenderOrder(store.renderOrder, taskId)
646
+ };
647
+ }
648
+ nextTasks.set(taskId, new TaskSnapshot({
649
+ id: snapshot.id,
650
+ parentId: snapshot.parentId,
651
+ description: snapshot.description,
652
+ status: "done",
653
+ transient: snapshot.transient,
654
+ units: snapshot.units._tag === "DeterminateTaskUnits" ? new DeterminateTaskUnits({
655
+ completed: snapshot.units.total,
656
+ total: snapshot.units.total
657
+ }) : snapshot.units,
658
+ startedAt: snapshot.startedAt,
659
+ completedAt: now
660
+ }));
661
+ return {
662
+ tasks: nextTasks,
663
+ renderOrder: store.renderOrder
664
+ };
665
+ });
666
+ yield* markDirty;
667
+ });
668
+ const failTask = (taskId) => Effect.gen(function* () {
669
+ const now = yield* Clock.currentTimeMillis;
670
+ yield* Ref.update(storeRef, (store) => {
671
+ const snapshot = store.tasks.get(taskId);
672
+ if (!snapshot) return store;
673
+ const nextTasks = new Map(store.tasks);
674
+ if (snapshot.transient) {
675
+ nextTasks.delete(taskId);
676
+ return {
677
+ tasks: nextTasks,
678
+ renderOrder: removeFromRenderOrder(store.renderOrder, taskId)
679
+ };
680
+ }
681
+ nextTasks.set(taskId, new TaskSnapshot({
682
+ id: snapshot.id,
683
+ parentId: snapshot.parentId,
684
+ description: snapshot.description,
685
+ status: "failed",
686
+ transient: snapshot.transient,
687
+ units: snapshot.units,
688
+ startedAt: snapshot.startedAt,
689
+ completedAt: now
690
+ }));
691
+ return {
692
+ tasks: nextTasks,
693
+ renderOrder: store.renderOrder
694
+ };
695
+ });
696
+ yield* markDirty;
697
+ });
698
+ const getTask = (taskId) => Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
699
+ const listTasks = Ref.get(storeRef).pipe(Effect.map((store) => Array.from(store.tasks.values())));
700
+ const runTask = dual(2, (effect, options) => Effect.gen(function* () {
701
+ const inheritedParentId = yield* FiberRef.get(currentParentRef);
702
+ const resolvedParentId = options.parentId === void 0 ? inheritedParentId : Option.some(options.parentId);
703
+ const taskId = yield* addTask({
704
+ ...options,
705
+ parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : void 0,
706
+ transient: options.transient
707
+ });
708
+ return yield* Effect.locally(Effect.provideService(effect, Task, taskId), currentParentRef, Option.some(taskId));
709
+ }));
710
+ const service = {
711
+ addTask,
712
+ updateTask,
713
+ advanceTask,
714
+ completeTask,
715
+ failTask,
716
+ log,
717
+ getTask,
718
+ listTasks,
719
+ runTask,
720
+ withTask: dual(2, (effect, options) => runTask(Effect.gen(function* () {
721
+ const taskId = yield* Task;
722
+ const exit = yield* Effect.exit(effect);
723
+ if (Exit.isSuccess(exit)) yield* completeTask(taskId);
724
+ else yield* failTask(taskId);
725
+ return yield* Exit.match(exit, {
726
+ onFailure: Effect.failCause,
727
+ onSuccess: Effect.succeed
728
+ });
729
+ }), options))
730
+ };
731
+ return Progress.of(service);
732
+ });
733
+ var Progress = class Progress extends Context.Tag("stromseng.dev/effective-progress/Progress")() {
734
+ static Default = Layer.unwrapEffect(Effect.gen(function* () {
735
+ const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
736
+ const inkRendererOption = yield* Effect.serviceOption(InkRenderer);
737
+ let layer = Layer.scoped(Progress, makeProgressService);
738
+ if (Option.isNone(inkRendererOption)) layer = layer.pipe(Layer.provide(InkRenderer.Default));
739
+ if (Option.isNone(terminalOption)) layer = layer.pipe(Layer.provide(ProgressTerminal.Default));
740
+ return layer;
741
+ }));
742
+ };
743
+
744
+ //#endregion
745
+ //#region src/utils.ts
746
+ const inferTotal = (iterable) => {
747
+ if (Array.isArray(iterable)) return iterable.length;
748
+ if (typeof iterable === "string") return iterable.length;
749
+ const candidate = iterable;
750
+ if (typeof candidate.length === "number") return candidate.length;
751
+ if (typeof candidate.size === "number") return candidate.size;
752
+ };
753
+
754
+ //#endregion
755
+ //#region src/api.ts
756
+ const provideProgress = (effect) => Effect.gen(function* () {
757
+ const existing = yield* Effect.serviceOption(Progress);
758
+ if (Option.isSome(existing)) return yield* Effect.provideService(effect, Progress, existing.value);
759
+ return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
760
+ });
761
+ const task = dual(2, (effect, options) => {
762
+ return provideProgress(Effect.gen(function* () {
763
+ return yield* (yield* Progress).withTask(effect, options);
764
+ }));
765
+ });
766
+ const wrapEffects = (effects, tap) => Array.isArray(effects) ? effects.map(tap) : Object.fromEntries(Object.entries(effects).map(([k, effect]) => [k, tap(effect)]));
767
+ const countEffects = (effects) => Array.isArray(effects) ? effects.length : Object.keys(effects).length;
768
+ const all = dual(2, (effects, options) => provideProgress(Effect.gen(function* () {
769
+ const progress = yield* Progress;
770
+ return yield* progress.runTask(Effect.gen(function* () {
771
+ const taskId = yield* Task;
772
+ const exit = yield* Effect.exit(Effect.all(wrapEffects(effects, (effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))), {
773
+ concurrency: options.concurrency,
774
+ batching: options.batching,
775
+ discard: options.discard,
776
+ mode: options.mode,
777
+ concurrentFinalizers: options.concurrentFinalizers
778
+ }));
779
+ if (Exit.isSuccess(exit)) yield* progress.completeTask(taskId);
780
+ else yield* progress.failTask(taskId);
781
+ return yield* Exit.match(exit, {
782
+ onFailure: Effect.failCause,
783
+ onSuccess: Effect.succeed
784
+ });
785
+ }), {
786
+ description: options.description,
787
+ total: countEffects(effects),
788
+ transient: options.transient
789
+ });
790
+ })));
791
+ const forEach = dual(3, (iterable, f, options) => provideProgress(Effect.gen(function* () {
792
+ const progress = yield* Progress;
793
+ return yield* progress.runTask(Effect.gen(function* () {
794
+ const taskId = yield* Task;
795
+ const exit = yield* Effect.exit(Effect.forEach(iterable, (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)), {
796
+ concurrency: options.concurrency,
797
+ batching: options.batching,
798
+ discard: options.discard,
799
+ concurrentFinalizers: options.concurrentFinalizers
800
+ }));
801
+ if (Exit.isSuccess(exit)) yield* progress.completeTask(taskId);
802
+ else yield* progress.failTask(taskId);
803
+ return yield* Exit.match(exit, {
804
+ onFailure: Effect.failCause,
805
+ onSuccess: Effect.succeed
806
+ });
807
+ }), {
808
+ description: options.description,
809
+ total: options.total ?? inferTotal(iterable),
810
+ transient: options.transient
811
+ });
812
+ })));
813
+
814
+ //#endregion
815
+ export { DeterminateTaskUnits, IndeterminateTaskUnits, Progress, ProgressTaskEventSchema, ProgressTerminal, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskId, TaskRemovedEvent, TaskSnapshot, TaskStatusSchema, TaskUnitsSchema, TaskUpdatedEvent, all, decodeProgressTaskEvent, forEach, task };