effective-progress 0.5.4 → 0.6.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,454 @@
1
- import { Brand, Cause, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref, Schema } from "effect";
1
+ import { Brand, Cause, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Schema } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import { Box, Text, render } from "ink";
4
+ import { useEffect, useState, useSyncExternalStore } from "react";
5
+ import stringWidth from "string-width";
4
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
7
 
8
+ //#region src/types.ts
9
+ const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
10
+ const TaskId = Brand.nominal();
11
+ const TaskStatusSchema = Schema.Literal("running", "done", "failed");
12
+ const TaskCountDisplaySchema = Schema.Literal("processedOnly", "detailed");
13
+ var DeterminateTaskUnits = class extends Schema.TaggedClass()("DeterminateTaskUnits", {
14
+ succeeded: Schema.Number,
15
+ failed: Schema.Number,
16
+ processed: Schema.Number,
17
+ total: Schema.Number
18
+ }) {};
19
+ var IndeterminateTaskUnits = class extends Schema.TaggedClass()("IndeterminateTaskUnits", {}) {};
20
+ const TaskUnitsSchema = Schema.Union(DeterminateTaskUnits, IndeterminateTaskUnits);
21
+ var TaskSnapshot = class extends Schema.TaggedClass()("TaskSnapshot", {
22
+ id: TaskIdSchema,
23
+ parentId: Schema.NullOr(TaskIdSchema),
24
+ description: Schema.String,
25
+ status: TaskStatusSchema,
26
+ countDisplay: TaskCountDisplaySchema,
27
+ transient: Schema.Boolean,
28
+ units: TaskUnitsSchema,
29
+ startedAt: Schema.Number,
30
+ completedAt: Schema.NullOr(Schema.Number)
31
+ }) {};
32
+ var Task = class extends Context.Tag("stromseng.dev/effective-progress/Task")() {};
33
+ var TaskAddedEvent = class extends Schema.TaggedClass()("TaskAdded", {
34
+ taskId: TaskIdSchema,
35
+ parentId: Schema.NullOr(TaskIdSchema),
36
+ description: Schema.String,
37
+ total: Schema.optional(Schema.Number),
38
+ transient: Schema.Boolean,
39
+ countDisplay: TaskCountDisplaySchema
40
+ }) {};
41
+ var TaskUpdatedEvent = class extends Schema.TaggedClass()("TaskUpdated", {
42
+ taskId: TaskIdSchema,
43
+ description: Schema.optional(Schema.String),
44
+ succeeded: Schema.optional(Schema.Number),
45
+ failed: Schema.optional(Schema.Number),
46
+ processed: Schema.optional(Schema.Number),
47
+ total: Schema.optional(Schema.Number),
48
+ transient: Schema.optional(Schema.Boolean),
49
+ countDisplay: Schema.optional(TaskCountDisplaySchema)
50
+ }) {};
51
+ var TaskAdvancedEvent = class extends Schema.TaggedClass()("TaskAdvanced", {
52
+ taskId: TaskIdSchema,
53
+ amount: Schema.Number,
54
+ kind: Schema.Literal("succeeded", "failed")
55
+ }) {};
56
+ var TaskCompletedEvent = class extends Schema.TaggedClass()("TaskCompleted", { taskId: TaskIdSchema }) {};
57
+ var TaskFailedEvent = class extends Schema.TaggedClass()("TaskFailed", { taskId: TaskIdSchema }) {};
58
+ var TaskRemovedEvent = class extends Schema.TaggedClass()("TaskRemoved", { taskId: TaskIdSchema }) {};
59
+ const ProgressTaskEventSchema = Schema.Union(TaskAddedEvent, TaskUpdatedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskRemovedEvent);
60
+ const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema);
61
+
62
+ //#endregion
63
+ //#region src/ink-renderer/snapshot/render-snapshot.ts
64
+ const orderedVisibleTasks = (store) => store.renderOrder.flatMap((row) => {
65
+ const snapshot = store.tasks.get(row.id);
66
+ if (!snapshot || snapshot.transient && snapshot.status !== "running") return [];
67
+ return [{
68
+ snapshot,
69
+ depth: row.depth
70
+ }];
71
+ });
72
+ const computeTreeInfo = (ordered) => {
73
+ const hasNextSiblingByIndex = Array.from({ length: ordered.length }, () => false);
74
+ for (let i = 0; i < ordered.length; i++) {
75
+ const depth = ordered[i].depth;
76
+ for (let j = i + 1; j < ordered.length; j++) {
77
+ const candidateDepth = ordered[j].depth;
78
+ if (candidateDepth < depth) break;
79
+ if (candidateDepth === depth) {
80
+ hasNextSiblingByIndex[i] = true;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ const ancestorStateByDepth = [];
86
+ return ordered.map((entry, index) => {
87
+ const depth = entry.depth;
88
+ ancestorStateByDepth.length = depth;
89
+ const hasChildren = index + 1 < ordered.length && ordered[index + 1] !== void 0 && ordered[index + 1].depth > depth;
90
+ const tree = {
91
+ depth,
92
+ hasNextSibling: hasNextSiblingByIndex[index] ?? false,
93
+ hasChildren,
94
+ ancestorHasNextSibling: [...ancestorStateByDepth]
95
+ };
96
+ ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
97
+ return {
98
+ ...entry,
99
+ tree
100
+ };
101
+ });
102
+ };
103
+ const toRenderSnapshot = (store) => {
104
+ const visibleTasks = orderedVisibleTasks(store);
105
+ const hasRunningTasks = visibleTasks.some((entry) => entry.snapshot.status === "running");
106
+ return {
107
+ rows: computeTreeInfo(visibleTasks).map((entry) => ({
108
+ task: entry.snapshot,
109
+ tree: entry.tree
110
+ })),
111
+ hasRunningTasks
112
+ };
113
+ };
114
+
115
+ //#endregion
116
+ //#region src/ink-renderer/store.ts
117
+ const normalizeDeterminateCounts = (counts) => {
118
+ const total = Math.max(0, counts.total);
119
+ const failed = Math.min(total, Math.max(0, counts.failed));
120
+ const succeeded = Math.min(total - failed, Math.max(0, counts.succeeded));
121
+ return new DeterminateTaskUnits({
122
+ succeeded,
123
+ failed,
124
+ processed: succeeded + failed,
125
+ total
126
+ });
127
+ };
128
+ const updateDeterminateCounts = (units, options) => normalizeDeterminateCounts({
129
+ succeeded: options.succeeded ?? units.succeeded,
130
+ failed: options.failed ?? units.failed,
131
+ total: options.total ?? units.total
132
+ });
133
+ const updatedSnapshot = (snapshot, options) => {
134
+ const currentUnits = snapshot.units;
135
+ const units = (() => {
136
+ if (options.total !== void 0) {
137
+ if (options.total <= 0) return new IndeterminateTaskUnits({});
138
+ if (currentUnits._tag === "DeterminateTaskUnits") return updateDeterminateCounts(currentUnits, options);
139
+ return normalizeDeterminateCounts({
140
+ succeeded: options.succeeded ?? 0,
141
+ failed: options.failed ?? 0,
142
+ total: options.total
143
+ });
144
+ }
145
+ if (currentUnits._tag === "DeterminateTaskUnits") {
146
+ if (options.succeeded === void 0 && options.failed === void 0) return currentUnits;
147
+ return updateDeterminateCounts(currentUnits, options);
148
+ }
149
+ return currentUnits;
150
+ })();
151
+ return new TaskSnapshot({
152
+ id: snapshot.id,
153
+ parentId: snapshot.parentId,
154
+ description: options.description ?? snapshot.description,
155
+ status: snapshot.status,
156
+ countDisplay: options.countDisplay ?? snapshot.countDisplay,
157
+ transient: options.transient ?? snapshot.transient,
158
+ units,
159
+ startedAt: snapshot.startedAt,
160
+ completedAt: snapshot.completedAt
161
+ });
162
+ };
163
+ const withTransient = (snapshot, transient) => new TaskSnapshot({
164
+ id: snapshot.id,
165
+ parentId: snapshot.parentId,
166
+ description: snapshot.description,
167
+ status: snapshot.status,
168
+ countDisplay: snapshot.countDisplay,
169
+ transient,
170
+ units: snapshot.units,
171
+ startedAt: snapshot.startedAt,
172
+ completedAt: snapshot.completedAt
173
+ });
174
+ const findInsertionIndex = (renderOrder, parentId) => {
175
+ if (parentId === null) return {
176
+ index: renderOrder.length,
177
+ depth: 0
178
+ };
179
+ const parentIdx = renderOrder.findIndex((row) => row.id === parentId);
180
+ if (parentIdx === -1) return {
181
+ index: renderOrder.length,
182
+ depth: 0
183
+ };
184
+ const parentDepth = renderOrder[parentIdx].depth;
185
+ let i = parentIdx + 1;
186
+ while (i < renderOrder.length && renderOrder[i].depth > parentDepth) i++;
187
+ return {
188
+ index: i,
189
+ depth: parentDepth + 1
190
+ };
191
+ };
192
+ const removeFromRenderOrder = (renderOrder, taskId) => {
193
+ const idx = renderOrder.findIndex((row) => row.id === taskId);
194
+ if (idx === -1) return renderOrder;
195
+ const taskDepth = renderOrder[idx].depth;
196
+ let end = idx + 1;
197
+ while (end < renderOrder.length && renderOrder[end].depth > taskDepth) end++;
198
+ const next = [...renderOrder];
199
+ next.splice(idx, end - idx);
200
+ return next;
201
+ };
202
+ const SNAPSHOT_PUBLISH_INTERVAL_MILLIS = 50;
203
+ const makeProgressRenderStore = () => {
204
+ let nextTaskId = 0;
205
+ let state = {
206
+ tasks: /* @__PURE__ */ new Map(),
207
+ renderOrder: []
208
+ };
209
+ let publishedSnapshot = toRenderSnapshot(state);
210
+ let hasPendingPublish = false;
211
+ let lastPublishAt = 0;
212
+ let publishTimeout;
213
+ const listeners = /* @__PURE__ */ new Set();
214
+ const notifyListeners = () => {
215
+ for (const listener of listeners) listener();
216
+ };
217
+ const publishNow = () => {
218
+ hasPendingPublish = false;
219
+ lastPublishAt = Date.now();
220
+ publishedSnapshot = toRenderSnapshot(state);
221
+ notifyListeners();
222
+ };
223
+ const clearScheduledPublish = () => {
224
+ if (publishTimeout === void 0) return;
225
+ clearTimeout(publishTimeout);
226
+ publishTimeout = void 0;
227
+ };
228
+ const schedulePublish = () => {
229
+ if (!hasPendingPublish) return;
230
+ const now = Date.now();
231
+ const waitMillis = Math.max(0, SNAPSHOT_PUBLISH_INTERVAL_MILLIS - (now - lastPublishAt));
232
+ if (waitMillis === 0) {
233
+ clearScheduledPublish();
234
+ publishNow();
235
+ return;
236
+ }
237
+ if (publishTimeout !== void 0) return;
238
+ publishTimeout = setTimeout(() => {
239
+ publishTimeout = void 0;
240
+ if (!hasPendingPublish) return;
241
+ publishNow();
242
+ }, waitMillis);
243
+ };
244
+ const publish = (nextState) => {
245
+ if (nextState === state) return;
246
+ state = nextState;
247
+ hasPendingPublish = true;
248
+ schedulePublish();
249
+ };
250
+ const updateState = (transform) => {
251
+ publish(transform(state));
252
+ };
253
+ return {
254
+ getSnapshot: () => publishedSnapshot,
255
+ subscribe: (listener) => {
256
+ listeners.add(listener);
257
+ return () => {
258
+ listeners.delete(listener);
259
+ };
260
+ },
261
+ flush: () => {
262
+ if (!hasPendingPublish) return;
263
+ clearScheduledPublish();
264
+ publishNow();
265
+ },
266
+ addTask: (options) => Effect.gen(function* () {
267
+ const taskId = TaskId(++nextTaskId);
268
+ const units = options.total === void 0 || options.total <= 0 ? new IndeterminateTaskUnits({}) : normalizeDeterminateCounts({
269
+ succeeded: 0,
270
+ failed: 0,
271
+ total: options.total
272
+ });
273
+ const parentSnapshot = options.parentId === void 0 ? void 0 : state.tasks.get(options.parentId);
274
+ const now = yield* Clock.currentTimeMillis;
275
+ const parentId = options.parentId ?? null;
276
+ const countDisplay = options.countDisplay ?? parentSnapshot?.countDisplay ?? "detailed";
277
+ const task = new TaskSnapshot({
278
+ id: taskId,
279
+ parentId,
280
+ description: options.description,
281
+ status: "running",
282
+ countDisplay,
283
+ transient: (parentSnapshot?.transient ?? false) || (options.transient ?? false),
284
+ units,
285
+ startedAt: now,
286
+ completedAt: null
287
+ });
288
+ updateState((current) => {
289
+ const nextTasks = new Map(current.tasks);
290
+ nextTasks.set(taskId, task);
291
+ const { index, depth } = findInsertionIndex(current.renderOrder, parentId);
292
+ const nextRenderOrder = [...current.renderOrder];
293
+ nextRenderOrder.splice(index, 0, {
294
+ id: taskId,
295
+ depth
296
+ });
297
+ return {
298
+ tasks: nextTasks,
299
+ renderOrder: nextRenderOrder
300
+ };
301
+ });
302
+ return taskId;
303
+ }),
304
+ updateTask: (taskId, options) => Effect.sync(() => {
305
+ updateState((current) => {
306
+ const currentTask = current.tasks.get(taskId);
307
+ if (!currentTask) return current;
308
+ const nextTasks = new Map(current.tasks);
309
+ const nextTask = updatedSnapshot(currentTask, options);
310
+ nextTasks.set(taskId, nextTask);
311
+ if (options.transient !== void 0) for (const [candidateId, candidate] of current.tasks.entries()) {
312
+ if (candidateId === taskId) continue;
313
+ let parentId = candidate.parentId;
314
+ let isDescendant = false;
315
+ while (parentId !== null) {
316
+ if (parentId === taskId) {
317
+ isDescendant = true;
318
+ break;
319
+ }
320
+ parentId = current.tasks.get(parentId)?.parentId ?? null;
321
+ }
322
+ if (isDescendant) nextTasks.set(candidateId, withTransient(candidate, nextTask.transient));
323
+ }
324
+ return {
325
+ tasks: nextTasks,
326
+ renderOrder: current.renderOrder
327
+ };
328
+ });
329
+ }),
330
+ addSuccess: (taskId, amount = 1) => Effect.sync(() => {
331
+ updateState((current) => {
332
+ const currentTask = current.tasks.get(taskId);
333
+ if (!currentTask || currentTask.units._tag !== "DeterminateTaskUnits") return current;
334
+ const nextTasks = new Map(current.tasks);
335
+ nextTasks.set(taskId, new TaskSnapshot({
336
+ id: currentTask.id,
337
+ parentId: currentTask.parentId,
338
+ description: currentTask.description,
339
+ status: currentTask.status,
340
+ countDisplay: currentTask.countDisplay,
341
+ transient: currentTask.transient,
342
+ units: normalizeDeterminateCounts({
343
+ succeeded: currentTask.units.succeeded + amount,
344
+ failed: currentTask.units.failed,
345
+ total: currentTask.units.total
346
+ }),
347
+ startedAt: currentTask.startedAt,
348
+ completedAt: currentTask.completedAt
349
+ }));
350
+ return {
351
+ tasks: nextTasks,
352
+ renderOrder: current.renderOrder
353
+ };
354
+ });
355
+ }),
356
+ addFailure: (taskId, amount = 1) => Effect.sync(() => {
357
+ updateState((current) => {
358
+ const currentTask = current.tasks.get(taskId);
359
+ if (!currentTask || currentTask.units._tag !== "DeterminateTaskUnits") return current;
360
+ const nextTasks = new Map(current.tasks);
361
+ nextTasks.set(taskId, new TaskSnapshot({
362
+ id: currentTask.id,
363
+ parentId: currentTask.parentId,
364
+ description: currentTask.description,
365
+ status: currentTask.status,
366
+ countDisplay: currentTask.countDisplay,
367
+ transient: currentTask.transient,
368
+ units: normalizeDeterminateCounts({
369
+ succeeded: currentTask.units.succeeded,
370
+ failed: currentTask.units.failed + amount,
371
+ total: currentTask.units.total
372
+ }),
373
+ startedAt: currentTask.startedAt,
374
+ completedAt: currentTask.completedAt
375
+ }));
376
+ return {
377
+ tasks: nextTasks,
378
+ renderOrder: current.renderOrder
379
+ };
380
+ });
381
+ }),
382
+ completeTask: (taskId) => Effect.gen(function* () {
383
+ const now = yield* Clock.currentTimeMillis;
384
+ updateState((current) => {
385
+ const currentTask = current.tasks.get(taskId);
386
+ if (!currentTask) return current;
387
+ const nextTasks = new Map(current.tasks);
388
+ if (currentTask.transient) {
389
+ nextTasks.delete(taskId);
390
+ return {
391
+ tasks: nextTasks,
392
+ renderOrder: removeFromRenderOrder(current.renderOrder, taskId)
393
+ };
394
+ }
395
+ nextTasks.set(taskId, new TaskSnapshot({
396
+ id: currentTask.id,
397
+ parentId: currentTask.parentId,
398
+ description: currentTask.description,
399
+ status: "done",
400
+ countDisplay: currentTask.countDisplay,
401
+ transient: currentTask.transient,
402
+ units: currentTask.units._tag === "DeterminateTaskUnits" ? normalizeDeterminateCounts({
403
+ succeeded: currentTask.units.total - currentTask.units.failed,
404
+ failed: currentTask.units.failed,
405
+ total: currentTask.units.total
406
+ }) : currentTask.units,
407
+ startedAt: currentTask.startedAt,
408
+ completedAt: now
409
+ }));
410
+ return {
411
+ tasks: nextTasks,
412
+ renderOrder: current.renderOrder
413
+ };
414
+ });
415
+ }),
416
+ failTask: (taskId) => Effect.gen(function* () {
417
+ const now = yield* Clock.currentTimeMillis;
418
+ updateState((current) => {
419
+ const currentTask = current.tasks.get(taskId);
420
+ if (!currentTask) return current;
421
+ const nextTasks = new Map(current.tasks);
422
+ if (currentTask.transient) {
423
+ nextTasks.delete(taskId);
424
+ return {
425
+ tasks: nextTasks,
426
+ renderOrder: removeFromRenderOrder(current.renderOrder, taskId)
427
+ };
428
+ }
429
+ nextTasks.set(taskId, new TaskSnapshot({
430
+ id: currentTask.id,
431
+ parentId: currentTask.parentId,
432
+ description: currentTask.description,
433
+ status: "failed",
434
+ countDisplay: currentTask.countDisplay,
435
+ transient: currentTask.transient,
436
+ units: currentTask.units,
437
+ startedAt: currentTask.startedAt,
438
+ completedAt: now
439
+ }));
440
+ return {
441
+ tasks: nextTasks,
442
+ renderOrder: current.renderOrder
443
+ };
444
+ });
445
+ }),
446
+ getTask: (taskId) => Effect.sync(() => Option.fromNullable(state.tasks.get(taskId))),
447
+ listTasks: Effect.sync(() => Array.from(state.tasks.values()))
448
+ };
449
+ };
450
+
451
+ //#endregion
6
452
  //#region src/ink-renderer/format.ts
7
453
  const SPINNER_FRAMES = [
8
454
  "⠋",
@@ -94,167 +540,38 @@ const formatAmount = (task, _tick) => {
94
540
  };
95
541
 
96
542
  //#endregion
97
- //#region src/ink-renderer/tree.ts
98
- const treeAncestorPrefix = (tree) => tree.ancestorHasNextSibling.slice(1).map((hasNext) => hasNext ? "" : " ").join("");
99
- const renderTreePrefix = (tree) => {
100
- if (tree.depth <= 0) return "";
101
- return `${treeAncestorPrefix(tree)}${tree.hasNextSibling ? "├─ " : "└─ "}`;
102
- };
103
- const computeTreeInfo = (ordered) => {
104
- const hasNextSiblingByIndex = Array.from({ length: ordered.length }, () => false);
105
- for (let i = 0; i < ordered.length; i++) {
106
- const depth = ordered[i].depth;
107
- for (let j = i + 1; j < ordered.length; j++) {
108
- const candidateDepth = ordered[j].depth;
109
- if (candidateDepth < depth) break;
110
- if (candidateDepth === depth) {
111
- hasNextSiblingByIndex[i] = true;
112
- break;
113
- }
114
- }
115
- }
116
- const ancestorStateByDepth = [];
117
- return ordered.map((entry, index) => {
118
- const depth = entry.depth;
119
- ancestorStateByDepth.length = depth;
120
- const hasChildren = index + 1 < ordered.length && ordered[index + 1] !== void 0 && ordered[index + 1].depth > depth;
121
- const tree = {
122
- depth,
123
- hasNextSibling: hasNextSiblingByIndex[index] ?? false,
124
- hasChildren,
125
- ancestorHasNextSibling: [...ancestorStateByDepth]
126
- };
127
- ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
128
- return {
129
- ...entry,
130
- tree
131
- };
132
- });
133
- };
134
-
135
- //#endregion
136
- //#region src/ink-renderer/layout.ts
137
- const DEFAULT_BAR_WIDTH = 30;
138
- const MIN_DESCRIPTION_WIDTH = 8;
139
- const MIN_BAR_WIDTH = 8;
140
- const MIN_ELAPSED_WIDTH = 2;
141
- const MIN_AMOUNT_WIDTH = 0;
142
- const BASELINE_ROW_WIDTH = 100;
143
- const MIN_DESCRIPTION_COLUMNS_FOR_TREE = 24;
144
- const RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR = Array.from("59m 59s").length;
145
- const RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR = Array.from("ETA: 59m 59s").length;
146
- const textWidth = (text) => Array.from(text).length;
147
- const computeWidths = (rows, now, tick, terminalColumns, includeTree = true) => {
148
- let hasDeterminate = false;
149
- let hasDetailedDeterminate = false;
150
- let description = MIN_DESCRIPTION_WIDTH;
151
- let amount = 0;
152
- let amountSucceeded = 0;
153
- let amountFailed = 0;
154
- let amountProcessed = 0;
155
- let amountTotal = 0;
156
- let elapsedContentWidth = MIN_ELAPSED_WIDTH;
157
- let etaContentWidth = 0;
158
- for (const row of rows) {
159
- const { task, tree } = row;
160
- const treePrefix = includeTree ? renderTreePrefix(tree) : "";
161
- description = Math.max(description, textWidth(`${treePrefix}${task.description}`) + 2);
162
- if (task.units._tag === "DeterminateTaskUnits") {
163
- hasDeterminate = true;
164
- const totalDigits = textWidth(`${task.units.total}`);
165
- if (task.countDisplay === "detailed") {
166
- hasDetailedDeterminate = true;
167
- amountSucceeded = Math.max(amountSucceeded, totalDigits);
168
- amountFailed = Math.max(amountFailed, totalDigits);
169
- }
170
- amountProcessed = Math.max(amountProcessed, totalDigits);
171
- amountTotal = Math.max(amountTotal, totalDigits);
172
- } else amount = Math.max(amount, textWidth(formatAmount(task, tick)));
173
- elapsedContentWidth = Math.max(elapsedContentWidth, textWidth(formatElapsed(task, now)));
174
- if (task.status === "running" && task.units._tag === "DeterminateTaskUnits") {
175
- const etaValue = formatEta(task, now);
176
- const etaText = `ETA: ${etaValue.length > 0 ? etaValue : "--"}`;
177
- etaContentWidth = Math.max(etaContentWidth, textWidth(etaText));
178
- }
179
- }
180
- const structuredAmount = hasDeterminate ? amountProcessed + 1 + amountTotal + (hasDetailedDeterminate ? amountSucceeded + 1 + amountFailed + 1 : 0) : 0;
181
- if (hasDeterminate) amount = structuredAmount;
182
- let widths = {
183
- description,
184
- bar: hasDeterminate ? DEFAULT_BAR_WIDTH : 0,
185
- amount,
186
- amountSucceeded,
187
- amountFailed,
188
- amountProcessed,
189
- amountTotal,
190
- elapsed: hasDeterminate ? Math.max(elapsedContentWidth, RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR) : elapsedContentWidth,
191
- eta: etaContentWidth > 0 ? Math.max(etaContentWidth, RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR) : 0
192
- };
193
- const visible = (w) => [
194
- w.description,
195
- w.bar,
196
- w.amount,
197
- w.elapsed,
198
- w.eta
199
- ].filter((width) => width > 0);
200
- const total = (w) => {
201
- const cols = visible(w);
202
- return cols.reduce((sum, width) => sum + width, 0) + Math.max(0, cols.length - 1);
203
- };
204
- const baselineTarget = hasDeterminate ? Math.max(BASELINE_ROW_WIDTH, total(widths)) : total(widths);
205
- const target = terminalColumns === void 0 ? baselineTarget : Math.max(1, Math.min(Math.max(1, Math.floor(terminalColumns)), baselineTarget));
206
- if (total(widths) < target) widths.description += target - total(widths);
207
- else if (total(widths) > target) {
208
- let overflow = total(widths) - target;
209
- const reduceBy = (key, min) => {
210
- if (overflow <= 0) return;
211
- const current = widths[key];
212
- if (current <= min) return;
213
- const reducible = current - min;
214
- const delta = Math.min(reducible, overflow);
215
- widths = {
216
- ...widths,
217
- [key]: current - delta
218
- };
219
- overflow -= delta;
220
- };
221
- reduceBy("eta", etaContentWidth);
222
- reduceBy("elapsed", elapsedContentWidth);
223
- reduceBy("bar", MIN_BAR_WIDTH);
224
- reduceBy("eta", 0);
225
- reduceBy("bar", 0);
226
- reduceBy("elapsed", MIN_ELAPSED_WIDTH);
227
- reduceBy("description", MIN_DESCRIPTION_WIDTH);
228
- reduceBy("amount", MIN_AMOUNT_WIDTH);
229
- reduceBy("description", 0);
230
- if (total(widths) < target) widths.description += target - total(widths);
231
- }
232
- const rowWidth = total(widths);
233
- const useStructuredAmount = hasDeterminate && widths.amount >= structuredAmount;
234
- return {
235
- row: rowWidth,
236
- description: widths.description,
237
- bar: widths.bar,
238
- amount: widths.amount,
239
- amountSucceeded: useStructuredAmount ? widths.amountSucceeded : 0,
240
- amountFailed: useStructuredAmount ? widths.amountFailed : 0,
241
- amountProcessed: useStructuredAmount ? widths.amountProcessed : 0,
242
- amountTotal: useStructuredAmount ? widths.amountTotal : 0,
243
- elapsed: widths.elapsed,
244
- eta: widths.eta
245
- };
246
- };
247
- const computeSharedColumnWidths = (rows, now, tick, terminalColumns) => {
248
- const withTree = computeWidths(rows, now, tick, terminalColumns, true);
249
- if (withTree.description >= MIN_DESCRIPTION_COLUMNS_FOR_TREE) return {
250
- ...withTree,
251
- showTree: true
252
- };
253
- return {
254
- ...computeWidths(rows, now, tick, terminalColumns, false),
255
- showTree: false
256
- };
543
+ //#region src/ink-renderer/columns/determinate.ts
544
+ const isDeterminate = (task) => task.units._tag === "DeterminateTaskUnits";
545
+ const hasDeterminateRows = (rows) => rows.some((row) => isDeterminate(row.task));
546
+
547
+ //#endregion
548
+ //#region src/ink-renderer/columns/spec.ts
549
+ const WIDTH_CACHE_LIMIT = 4096;
550
+ const widthCache = /* @__PURE__ */ new Map();
551
+ const textWidth = (text) => {
552
+ const cached = widthCache.get(text);
553
+ if (cached !== void 0) return cached;
554
+ const width = stringWidth(text);
555
+ if (widthCache.size >= WIDTH_CACHE_LIMIT) widthCache.clear();
556
+ widthCache.set(text, width);
557
+ return width;
257
558
  };
559
+ const resolveColumnSpec = (spec, resistance) => ({
560
+ id: spec.id,
561
+ grow: spec.grow,
562
+ canHide: spec.canHide,
563
+ variants: spec.variants.map((variant) => ({
564
+ id: variant.id,
565
+ minWidth: variant.minWidth,
566
+ idealWidth: variant.idealWidth,
567
+ maxWidth: variant.maxWidth,
568
+ shrinkResistance: resistance.shrink(spec.id, variant.id),
569
+ demoteResistance: resistance.demote(spec.id, variant.id),
570
+ hideResistance: resistance.hide(spec.id, variant.id),
571
+ renderCell: variant.renderCell
572
+ }))
573
+ });
574
+ const resolveColumnSpecs = (specs, resistance) => specs.flatMap((spec) => spec.variants.length > 0 ? [resolveColumnSpec(spec, resistance)] : []);
258
575
 
259
576
  //#endregion
260
577
  //#region src/ink-renderer/columns/amount-column.tsx
@@ -285,8 +602,7 @@ const ProcessedCountColumn = ({ task, width }) => {
285
602
  if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
286
603
  return /* @__PURE__ */ jsx(Text, { children: padLeft(`${task.units.processed}`, width) });
287
604
  };
288
- const AmountSeparatorColumn = ({ task, showStructured, tick }) => {
289
- if (!showStructured) return null;
605
+ const AmountSeparatorColumn = ({ task, tick }) => {
290
606
  if (task.units._tag === "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: "/" });
291
607
  const symbol = formatAmount(task, tick);
292
608
  if (task.status === "failed") return /* @__PURE__ */ jsx(Text, {
@@ -304,9 +620,8 @@ const TotalCountColumn = ({ task, width }) => {
304
620
  if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
305
621
  return /* @__PURE__ */ jsx(Text, { children: padRight(`${task.units.total}`, width) });
306
622
  };
307
- const AmountColumn = ({ task, tick, amountSucceededWidth, amountFailedWidth, amountProcessedWidth, amountTotalWidth }) => {
308
- const showStructured = amountProcessedWidth > 0 && amountTotalWidth > 0;
309
- if (!showStructured) {
623
+ const AmountColumn = ({ task, tick, layout }) => {
624
+ if (layout.kind === "text") {
310
625
  const text = formatAmount(task, tick);
311
626
  if (task.status === "failed") return /* @__PURE__ */ jsx(Text, {
312
627
  wrap: "truncate-end",
@@ -323,36 +638,140 @@ const AmountColumn = ({ task, tick, amountSucceededWidth, amountFailedWidth, amo
323
638
  children: text
324
639
  });
325
640
  }
641
+ if (layout.kind === "processed") return /* @__PURE__ */ jsxs(Box, {
642
+ flexDirection: "row",
643
+ children: [
644
+ /* @__PURE__ */ jsx(ProcessedCountColumn, {
645
+ task,
646
+ width: layout.processedWidth
647
+ }),
648
+ /* @__PURE__ */ jsx(AmountSeparatorColumn, {
649
+ task,
650
+ tick
651
+ }),
652
+ /* @__PURE__ */ jsx(TotalCountColumn, {
653
+ task,
654
+ width: layout.totalWidth
655
+ })
656
+ ]
657
+ });
326
658
  return /* @__PURE__ */ jsxs(Box, {
327
659
  flexDirection: "row",
328
660
  children: [
329
- amountSucceededWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SucceededCountColumn, {
661
+ layout.succeededWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SucceededCountColumn, {
330
662
  task,
331
- width: amountSucceededWidth
663
+ width: layout.succeededWidth
332
664
  }), /* @__PURE__ */ jsx(Text, { children: ` ` })] }) : null,
333
- amountFailedWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FailedCountColumn, {
665
+ layout.failedWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FailedCountColumn, {
334
666
  task,
335
- width: amountFailedWidth
667
+ width: layout.failedWidth
336
668
  }), /* @__PURE__ */ jsx(Text, { children: ` ` })] }) : null,
337
669
  /* @__PURE__ */ jsx(ProcessedCountColumn, {
338
670
  task,
339
- width: amountProcessedWidth
671
+ width: layout.processedWidth
340
672
  }),
341
673
  /* @__PURE__ */ jsx(AmountSeparatorColumn, {
342
674
  task,
343
- showStructured,
344
675
  tick
345
676
  }),
346
677
  /* @__PURE__ */ jsx(TotalCountColumn, {
347
678
  task,
348
- width: amountTotalWidth
679
+ width: layout.totalWidth
349
680
  })
350
681
  ]
351
682
  });
352
683
  };
684
+ const computeAmountMetrics = (rows, tick) => {
685
+ let hasDeterminate = false;
686
+ let hasDetailed = false;
687
+ let totalDigits = 0;
688
+ let simpleTextWidth = 0;
689
+ for (const row of rows) {
690
+ const { task } = row;
691
+ if (isDeterminate(task)) {
692
+ hasDeterminate = true;
693
+ totalDigits = Math.max(totalDigits, textWidth(`${task.units.total}`));
694
+ if (task.countDisplay === "detailed") hasDetailed = true;
695
+ continue;
696
+ }
697
+ simpleTextWidth = Math.max(simpleTextWidth, textWidth(formatAmount(task, tick)));
698
+ }
699
+ return {
700
+ hasDeterminate,
701
+ hasDetailed,
702
+ totalDigits: Math.max(1, totalDigits),
703
+ simpleTextWidth
704
+ };
705
+ };
706
+ const detailedAmountLayout = (metrics) => ({
707
+ kind: "detailed",
708
+ succeededWidth: metrics.hasDetailed ? metrics.totalDigits : 0,
709
+ failedWidth: metrics.hasDetailed ? metrics.totalDigits : 0,
710
+ processedWidth: metrics.totalDigits,
711
+ totalWidth: metrics.totalDigits
712
+ });
713
+ const processedAmountLayout = (metrics) => ({
714
+ kind: "processed",
715
+ processedWidth: metrics.totalDigits,
716
+ totalWidth: metrics.totalDigits
717
+ });
718
+ const detailedAmountWidth = (metrics) => metrics.totalDigits + 1 + metrics.totalDigits + (metrics.hasDetailed ? metrics.totalDigits + 1 + metrics.totalDigits + 1 : 0);
719
+ const processedAmountWidth = (metrics) => metrics.totalDigits + 1 + metrics.totalDigits;
720
+ const createAmountColumnSpec = (context) => {
721
+ const metrics = computeAmountMetrics(context.rows, context.tick);
722
+ if (!metrics.hasDeterminate && metrics.simpleTextWidth <= 0) return;
723
+ const detailedLayout = detailedAmountLayout(metrics);
724
+ const processedLayout = processedAmountLayout(metrics);
725
+ const detailedWidth = detailedAmountWidth(metrics);
726
+ const processedWidth = processedAmountWidth(metrics);
727
+ return {
728
+ id: "amount",
729
+ grow: 0,
730
+ canHide: true,
731
+ variants: metrics.hasDeterminate && metrics.hasDetailed ? [{
732
+ id: "detailed",
733
+ minWidth: detailedWidth,
734
+ idealWidth: detailedWidth,
735
+ renderCell: (row) => /* @__PURE__ */ jsx(AmountColumn, {
736
+ task: row.task,
737
+ tick: context.tick,
738
+ layout: detailedLayout
739
+ })
740
+ }, {
741
+ id: "processed",
742
+ minWidth: processedWidth,
743
+ idealWidth: processedWidth,
744
+ renderCell: (row) => /* @__PURE__ */ jsx(AmountColumn, {
745
+ task: row.task,
746
+ tick: context.tick,
747
+ layout: processedLayout
748
+ })
749
+ }] : metrics.hasDeterminate ? [{
750
+ id: "processed",
751
+ minWidth: processedWidth,
752
+ idealWidth: processedWidth,
753
+ renderCell: (row) => /* @__PURE__ */ jsx(AmountColumn, {
754
+ task: row.task,
755
+ tick: context.tick,
756
+ layout: processedLayout
757
+ })
758
+ }] : [{
759
+ id: "text",
760
+ minWidth: 0,
761
+ idealWidth: metrics.simpleTextWidth,
762
+ renderCell: (row) => /* @__PURE__ */ jsx(AmountColumn, {
763
+ task: row.task,
764
+ tick: context.tick,
765
+ layout: { kind: "text" }
766
+ })
767
+ }]
768
+ };
769
+ };
353
770
 
354
771
  //#endregion
355
772
  //#region src/ink-renderer/columns/bar-column.tsx
773
+ const DEFAULT_BAR_WIDTH = 30;
774
+ const MIN_BAR_WIDTH = 8;
356
775
  const segmentLengths = (width, total, succeeded, failed) => {
357
776
  if (total <= 0) return {
358
777
  succeeded: 0,
@@ -390,6 +809,49 @@ const BarColumn = ({ task, width }) => {
390
809
  ]
391
810
  });
392
811
  };
812
+ const createBarColumnSpec = (context, isTTY) => {
813
+ if (!hasDeterminateRows(context.rows)) return;
814
+ return {
815
+ id: "bar",
816
+ grow: 0,
817
+ canHide: true,
818
+ variants: [{
819
+ id: "full",
820
+ minWidth: MIN_BAR_WIDTH,
821
+ idealWidth: DEFAULT_BAR_WIDTH,
822
+ maxWidth: DEFAULT_BAR_WIDTH,
823
+ renderCell: (row, width) => /* @__PURE__ */ jsx(BarColumn, {
824
+ task: row.task,
825
+ tree: row.tree,
826
+ now: context.now,
827
+ tick: context.tick,
828
+ isTTY,
829
+ width: Math.max(1, Math.min(width, DEFAULT_BAR_WIDTH))
830
+ })
831
+ }, {
832
+ id: "compact",
833
+ minWidth: 1,
834
+ idealWidth: MIN_BAR_WIDTH,
835
+ maxWidth: MIN_BAR_WIDTH,
836
+ renderCell: (row, width) => /* @__PURE__ */ jsx(BarColumn, {
837
+ task: row.task,
838
+ tree: row.tree,
839
+ now: context.now,
840
+ tick: context.tick,
841
+ isTTY,
842
+ width: Math.max(1, width)
843
+ })
844
+ }]
845
+ };
846
+ };
847
+
848
+ //#endregion
849
+ //#region src/ink-renderer/view/tree-prefix.ts
850
+ const treeAncestorPrefix = (tree) => tree.ancestorHasNextSibling.slice(1).map((hasNext) => hasNext ? "│ " : " ").join("");
851
+ const renderTreePrefix = (tree) => {
852
+ if (tree.depth <= 0) return "";
853
+ return `${treeAncestorPrefix(tree)}${tree.hasNextSibling ? "├─ " : "└─ "}`;
854
+ };
393
855
 
394
856
  //#endregion
395
857
  //#region src/ink-renderer/columns/description-column.tsx
@@ -408,6 +870,51 @@ const DescriptionColumn = ({ task, tree, showTree, tick }) => {
408
870
  ]
409
871
  });
410
872
  };
873
+ const MIN_DESCRIPTION_WIDTH = 8;
874
+ const MIN_DESCRIPTION_WITH_TREE_WIDTH = 20;
875
+ const DESCRIPTION_PADDING_GROWTH_LIMIT = 20;
876
+ const maxDescriptionWidth = (rows, showTree) => rows.reduce((max, row) => {
877
+ const treePrefix = showTree ? renderTreePrefix(row.tree) : "";
878
+ return Math.max(max, textWidth(`${treePrefix}${row.task.description}`) + 2);
879
+ }, MIN_DESCRIPTION_WIDTH);
880
+ const createDescriptionColumnSpec = (context, isTTY) => {
881
+ const treeIdeal = maxDescriptionWidth(context.rows, true);
882
+ const plainIdeal = maxDescriptionWidth(context.rows, false);
883
+ const treeVariantIdeal = Math.max(MIN_DESCRIPTION_WITH_TREE_WIDTH, treeIdeal);
884
+ const plainVariantIdeal = Math.max(MIN_DESCRIPTION_WIDTH, plainIdeal);
885
+ return {
886
+ id: "description",
887
+ grow: 1,
888
+ canHide: false,
889
+ variants: [{
890
+ id: "tree",
891
+ minWidth: MIN_DESCRIPTION_WITH_TREE_WIDTH,
892
+ idealWidth: treeVariantIdeal,
893
+ maxWidth: Math.max(DESCRIPTION_PADDING_GROWTH_LIMIT, treeVariantIdeal),
894
+ renderCell: (row) => /* @__PURE__ */ jsx(DescriptionColumn, {
895
+ task: row.task,
896
+ tree: row.tree,
897
+ now: context.now,
898
+ tick: context.tick,
899
+ isTTY,
900
+ showTree: true
901
+ })
902
+ }, {
903
+ id: "plain",
904
+ minWidth: MIN_DESCRIPTION_WIDTH,
905
+ idealWidth: plainVariantIdeal,
906
+ maxWidth: Math.max(DESCRIPTION_PADDING_GROWTH_LIMIT, plainVariantIdeal),
907
+ renderCell: (row) => /* @__PURE__ */ jsx(DescriptionColumn, {
908
+ task: row.task,
909
+ tree: row.tree,
910
+ now: context.now,
911
+ tick: context.tick,
912
+ isTTY,
913
+ showTree: false
914
+ })
915
+ }]
916
+ };
917
+ };
411
918
 
412
919
  //#endregion
413
920
  //#region src/ink-renderer/columns/elapsed-column.tsx
@@ -416,156 +923,520 @@ const ElapsedColumn = ({ task, now }) => /* @__PURE__ */ jsx(Text, {
416
923
  color: "gray",
417
924
  children: formatElapsed(task, now)
418
925
  });
926
+ const MIN_ELAPSED_WIDTH = 2;
927
+ const RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR = Array.from("59m 59s").length;
928
+ const maxElapsedWidth = (rows, now) => rows.reduce((max, row) => Math.max(max, textWidth(formatElapsed(row.task, now))), MIN_ELAPSED_WIDTH);
929
+ const createElapsedColumnSpec = (context, isTTY) => {
930
+ const elapsedContentWidth = maxElapsedWidth(context.rows, context.now);
931
+ return {
932
+ id: "elapsed",
933
+ grow: 0,
934
+ canHide: false,
935
+ variants: [{
936
+ id: "stable",
937
+ minWidth: elapsedContentWidth,
938
+ idealWidth: hasDeterminateRows(context.rows) ? Math.max(elapsedContentWidth, RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR) : elapsedContentWidth,
939
+ renderCell: (row) => /* @__PURE__ */ jsx(ElapsedColumn, {
940
+ task: row.task,
941
+ tree: row.tree,
942
+ now: context.now,
943
+ tick: context.tick,
944
+ isTTY
945
+ })
946
+ }, {
947
+ id: "compact",
948
+ minWidth: MIN_ELAPSED_WIDTH,
949
+ idealWidth: elapsedContentWidth,
950
+ renderCell: (row) => /* @__PURE__ */ jsx(ElapsedColumn, {
951
+ task: row.task,
952
+ tree: row.tree,
953
+ now: context.now,
954
+ tick: context.tick,
955
+ isTTY
956
+ })
957
+ }]
958
+ };
959
+ };
419
960
 
420
961
  //#endregion
421
962
  //#region src/ink-renderer/columns/eta-column.tsx
422
- const EtaColumn = ({ task, now }) => {
423
- if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, {});
963
+ const primaryUnit = (duration) => duration.split(" ")[0] ?? duration;
964
+ const etaDurationText = (task, now) => {
965
+ if (task.status !== "running" || !isDeterminate(task)) return;
424
966
  const eta = formatEta(task, now);
967
+ return eta.length > 0 ? eta : "--";
968
+ };
969
+ const EtaColumn = ({ task, now, mode }) => {
970
+ const duration = etaDurationText(task, now);
971
+ if (duration === void 0) return /* @__PURE__ */ jsx(Text, {});
425
972
  return /* @__PURE__ */ jsx(Text, {
426
973
  wrap: "truncate-end",
427
974
  color: "gray",
428
- children: eta.length > 0 ? `ETA: ${eta}` : "ETA: --"
975
+ children: mode === "prefixed" ? `ETA: ${duration}` : mode === "primary" ? primaryUnit(duration) : duration
429
976
  });
430
977
  };
978
+ const RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR = Array.from("ETA: 59m 59s").length;
979
+ const computeEtaMetrics = (rows, now) => {
980
+ let hasEta = false;
981
+ let prefixedWidth = 0;
982
+ let durationWidth = 0;
983
+ let primaryUnitWidth = 0;
984
+ for (const row of rows) {
985
+ const duration = etaDurationText(row.task, now);
986
+ if (duration === void 0) continue;
987
+ hasEta = true;
988
+ const prefixed = `ETA: ${duration}`;
989
+ prefixedWidth = Math.max(prefixedWidth, textWidth(prefixed));
990
+ durationWidth = Math.max(durationWidth, textWidth(duration));
991
+ primaryUnitWidth = Math.max(primaryUnitWidth, textWidth(primaryUnit(duration)));
992
+ }
993
+ return {
994
+ hasEta,
995
+ prefixedWidth,
996
+ durationWidth: Math.max(2, durationWidth),
997
+ primaryUnitWidth: Math.max(2, primaryUnitWidth)
998
+ };
999
+ };
1000
+ const createEtaColumnSpec = (context, isTTY) => {
1001
+ const metrics = computeEtaMetrics(context.rows, context.now);
1002
+ if (!metrics.hasEta) return;
1003
+ return {
1004
+ id: "eta",
1005
+ grow: 0,
1006
+ canHide: true,
1007
+ variants: [
1008
+ {
1009
+ id: "prefixed",
1010
+ minWidth: metrics.prefixedWidth,
1011
+ idealWidth: Math.max(metrics.prefixedWidth, RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR),
1012
+ renderCell: (row) => /* @__PURE__ */ jsx(EtaColumn, {
1013
+ task: row.task,
1014
+ tree: row.tree,
1015
+ now: context.now,
1016
+ tick: context.tick,
1017
+ isTTY,
1018
+ mode: "prefixed"
1019
+ })
1020
+ },
1021
+ {
1022
+ id: "duration",
1023
+ minWidth: metrics.durationWidth,
1024
+ idealWidth: metrics.durationWidth,
1025
+ renderCell: (row) => /* @__PURE__ */ jsx(EtaColumn, {
1026
+ task: row.task,
1027
+ tree: row.tree,
1028
+ now: context.now,
1029
+ tick: context.tick,
1030
+ isTTY,
1031
+ mode: "duration"
1032
+ })
1033
+ },
1034
+ {
1035
+ id: "primary",
1036
+ minWidth: metrics.primaryUnitWidth,
1037
+ idealWidth: metrics.primaryUnitWidth,
1038
+ renderCell: (row) => /* @__PURE__ */ jsx(EtaColumn, {
1039
+ task: row.task,
1040
+ tree: row.tree,
1041
+ now: context.now,
1042
+ tick: context.tick,
1043
+ isTTY,
1044
+ mode: "primary"
1045
+ })
1046
+ }
1047
+ ]
1048
+ };
1049
+ };
431
1050
 
432
1051
  //#endregion
433
- //#region src/ink-renderer/task-row.tsx
434
- const TaskRow = ({ row, now, tick, isTTY, widths }) => {
435
- const props = {
436
- task: row.task,
437
- tree: row.tree,
1052
+ //#region src/ink-renderer/columns/planner.ts
1053
+ const clampInt = (value, min, max) => Math.max(min, Math.min(max, Math.floor(value)));
1054
+ const activeColumns = (columns) => columns.filter((column) => !column.hidden);
1055
+ const visibleColumns = (columns) => columns.filter((column) => !column.hidden && column.width > 0);
1056
+ const visibleGapWidth = (columns) => Math.max(0, visibleColumns(columns).length - 1);
1057
+ const totalWidth = (columns) => visibleColumns(columns).reduce((sum, column) => sum + column.width, 0) + visibleGapWidth(columns);
1058
+ const currentVariant = (column) => column.variants[column.variantIndex];
1059
+ const variantMaxWidth = (column) => {
1060
+ const variant = currentVariant(column);
1061
+ return variant.maxWidth === void 0 ? Number.POSITIVE_INFINITY : Math.max(variant.minWidth, variant.maxWidth);
1062
+ };
1063
+ const reduceOverflowByShrink = (columns, overflow) => {
1064
+ let remaining = overflow;
1065
+ const candidates = activeColumns(columns).filter((column) => column.width > currentVariant(column).minWidth).sort((a, b) => currentVariant(a).shrinkResistance - currentVariant(b).shrinkResistance);
1066
+ for (const column of candidates) {
1067
+ if (remaining <= 0) break;
1068
+ const variant = currentVariant(column);
1069
+ const reducible = column.width - variant.minWidth;
1070
+ if (reducible <= 0) continue;
1071
+ const delta = Math.min(reducible, remaining);
1072
+ column.width -= delta;
1073
+ remaining -= delta;
1074
+ }
1075
+ return remaining;
1076
+ };
1077
+ const nextDemoteCandidate = (columns) => activeColumns(columns).filter((column) => column.variantIndex + 1 < column.variants.length).sort((a, b) => currentVariant(a).demoteResistance - currentVariant(b).demoteResistance)[0];
1078
+ const applyDemote = (column) => {
1079
+ const nextVariant = column.variants[column.variantIndex + 1];
1080
+ column.variantIndex += 1;
1081
+ const nextMaxWidth = nextVariant.maxWidth === void 0 ? Number.POSITIVE_INFINITY : Math.max(nextVariant.minWidth, nextVariant.maxWidth);
1082
+ column.width = Math.max(nextVariant.minWidth, Math.min(column.width, nextVariant.idealWidth, nextMaxWidth));
1083
+ };
1084
+ const nextHideCandidate = (columns) => activeColumns(columns).filter((column) => column.spec.canHide).sort((a, b) => currentVariant(a).hideResistance - currentVariant(b).hideResistance)[0];
1085
+ const applyHide = (column) => {
1086
+ column.hidden = true;
1087
+ column.width = 0;
1088
+ };
1089
+ const distributeGrowth = (columns, extra) => {
1090
+ if (extra <= 0) return;
1091
+ let remaining = extra;
1092
+ while (remaining > 0) {
1093
+ const candidates = activeColumns(columns).filter((column) => column.spec.grow > 0 && column.width < variantMaxWidth(column));
1094
+ if (candidates.length === 0) break;
1095
+ const growSum = candidates.reduce((sum, column) => sum + Math.max(0, column.spec.grow), 0);
1096
+ if (growSum <= 0) break;
1097
+ let distributed = 0;
1098
+ for (const column of candidates) {
1099
+ const weight = Math.max(0, column.spec.grow);
1100
+ if (weight <= 0) continue;
1101
+ const headroom = Math.max(0, variantMaxWidth(column) - column.width);
1102
+ if (headroom <= 0) continue;
1103
+ const share = Math.min(headroom, Math.floor(remaining * weight / growSum));
1104
+ if (share <= 0) continue;
1105
+ column.width += share;
1106
+ distributed += share;
1107
+ }
1108
+ if (distributed === 0) {
1109
+ for (const column of candidates) {
1110
+ if (remaining <= 0) break;
1111
+ if (Math.max(0, variantMaxWidth(column) - column.width) <= 0) continue;
1112
+ column.width += 1;
1113
+ distributed += 1;
1114
+ remaining -= 1;
1115
+ }
1116
+ if (distributed === 0) break;
1117
+ continue;
1118
+ }
1119
+ remaining -= distributed;
1120
+ }
1121
+ };
1122
+ const planColumns = ({ context, baselineWidth, columns }) => {
1123
+ const mutable = columns.flatMap((column) => {
1124
+ if (column.variants.length === 0) return [];
1125
+ const first = column.variants[0];
1126
+ return [{
1127
+ spec: column,
1128
+ variants: column.variants,
1129
+ variantIndex: 0,
1130
+ width: Math.max(first.minWidth, Math.min(first.idealWidth, first.maxWidth === void 0 ? Number.POSITIVE_INFINITY : Math.max(first.minWidth, first.maxWidth))),
1131
+ hidden: false
1132
+ }];
1133
+ });
1134
+ if (mutable.length === 0) return {
1135
+ rowWidth: 0,
1136
+ columns: []
1137
+ };
1138
+ const naturalWidth = totalWidth(mutable);
1139
+ const clampedTerminal = context.terminalColumns === void 0 ? void 0 : clampInt(context.terminalColumns, 1, Number.POSITIVE_INFINITY);
1140
+ const baselineTarget = Math.max(1, Math.max(baselineWidth, naturalWidth));
1141
+ const target = clampedTerminal === void 0 ? baselineTarget : Math.min(baselineTarget, clampedTerminal);
1142
+ if (naturalWidth < target) distributeGrowth(mutable, target - naturalWidth);
1143
+ else if (naturalWidth > target) {
1144
+ let overflow = naturalWidth - target;
1145
+ let progressed = true;
1146
+ while (overflow > 0 && progressed) {
1147
+ const before = overflow;
1148
+ overflow = reduceOverflowByShrink(mutable, overflow);
1149
+ if (overflow <= 0) break;
1150
+ const demoteCandidate = nextDemoteCandidate(mutable);
1151
+ const hideCandidate = nextHideCandidate(mutable);
1152
+ if (demoteCandidate === void 0 && hideCandidate === void 0) {
1153
+ progressed = before !== overflow;
1154
+ continue;
1155
+ }
1156
+ if ((demoteCandidate === void 0 ? Number.POSITIVE_INFINITY : currentVariant(demoteCandidate).demoteResistance) <= (hideCandidate === void 0 ? Number.POSITIVE_INFINITY : currentVariant(hideCandidate).hideResistance) && demoteCandidate !== void 0) applyDemote(demoteCandidate);
1157
+ else if (hideCandidate !== void 0) applyHide(hideCandidate);
1158
+ overflow = Math.max(0, totalWidth(mutable) - target);
1159
+ progressed = true;
1160
+ }
1161
+ const compactedWidth = totalWidth(mutable);
1162
+ if (compactedWidth < target) distributeGrowth(mutable, target - compactedWidth);
1163
+ }
1164
+ const visible = visibleColumns(mutable);
1165
+ return {
1166
+ rowWidth: totalWidth(mutable),
1167
+ columns: visible.map((column) => {
1168
+ const variant = currentVariant(column);
1169
+ return {
1170
+ id: column.spec.id,
1171
+ variantId: variant.id,
1172
+ width: column.width,
1173
+ renderCell: variant.renderCell
1174
+ };
1175
+ })
1176
+ };
1177
+ };
1178
+
1179
+ //#endregion
1180
+ //#region src/ink-renderer/columns/layout.tsx
1181
+ /**
1182
+ * Frame planning algorithm (per render tick):
1183
+ *
1184
+ * 1. Ask each column module to produce a logical column spec (variants + measured widths).
1185
+ * 2. Apply shared resistance rules (shrink / demote / hide) to those variants.
1186
+ * 3. Feed resolved columns into `planColumns`.
1187
+ * 4. `planColumns` shrinks, demotes, and hides until the row fits target width.
1188
+ * 5. Render rows with the selected variant and width for each visible column.
1189
+ */
1190
+ const BASELINE_ROW_WIDTH = 150;
1191
+ const DEFAULT_RESISTANCE = 1e3;
1192
+ const variantOrderKey = ({ columnId, variantId }) => `${columnId}:${variantId}`;
1193
+ const SHRINK_ORDER = [
1194
+ {
1195
+ columnId: "eta",
1196
+ variantId: "prefixed"
1197
+ },
1198
+ {
1199
+ columnId: "elapsed",
1200
+ variantId: "stable"
1201
+ },
1202
+ {
1203
+ columnId: "eta",
1204
+ variantId: "duration"
1205
+ },
1206
+ {
1207
+ columnId: "eta",
1208
+ variantId: "primary"
1209
+ },
1210
+ {
1211
+ columnId: "bar",
1212
+ variantId: "compact"
1213
+ },
1214
+ {
1215
+ columnId: "bar",
1216
+ variantId: "full"
1217
+ },
1218
+ {
1219
+ columnId: "amount",
1220
+ variantId: "text"
1221
+ },
1222
+ {
1223
+ columnId: "elapsed",
1224
+ variantId: "compact"
1225
+ },
1226
+ {
1227
+ columnId: "description",
1228
+ variantId: "tree"
1229
+ },
1230
+ {
1231
+ columnId: "amount",
1232
+ variantId: "detailed"
1233
+ },
1234
+ {
1235
+ columnId: "amount",
1236
+ variantId: "processed"
1237
+ },
1238
+ {
1239
+ columnId: "description",
1240
+ variantId: "plain"
1241
+ }
1242
+ ];
1243
+ const DEMOTE_ORDER = [
1244
+ {
1245
+ columnId: "description",
1246
+ variantId: "tree"
1247
+ },
1248
+ {
1249
+ columnId: "eta",
1250
+ variantId: "prefixed"
1251
+ },
1252
+ {
1253
+ columnId: "eta",
1254
+ variantId: "duration"
1255
+ },
1256
+ {
1257
+ columnId: "elapsed",
1258
+ variantId: "stable"
1259
+ },
1260
+ {
1261
+ columnId: "bar",
1262
+ variantId: "full"
1263
+ },
1264
+ {
1265
+ columnId: "amount",
1266
+ variantId: "detailed"
1267
+ }
1268
+ ];
1269
+ const HIDE_ORDER = [
1270
+ {
1271
+ columnId: "eta",
1272
+ variantId: "primary"
1273
+ },
1274
+ {
1275
+ columnId: "eta",
1276
+ variantId: "prefixed"
1277
+ },
1278
+ {
1279
+ columnId: "eta",
1280
+ variantId: "duration"
1281
+ },
1282
+ {
1283
+ columnId: "bar",
1284
+ variantId: "full"
1285
+ },
1286
+ {
1287
+ columnId: "bar",
1288
+ variantId: "compact"
1289
+ },
1290
+ {
1291
+ columnId: "amount",
1292
+ variantId: "text"
1293
+ },
1294
+ {
1295
+ columnId: "amount",
1296
+ variantId: "detailed"
1297
+ },
1298
+ {
1299
+ columnId: "amount",
1300
+ variantId: "processed"
1301
+ }
1302
+ ];
1303
+ const toResistanceLookup = (order) => new Map(order.map((item, index) => [variantOrderKey(item), index + 1]));
1304
+ const shrinkResistanceLookup = toResistanceLookup(SHRINK_ORDER);
1305
+ const demoteResistanceLookup = toResistanceLookup(DEMOTE_ORDER);
1306
+ const hideResistanceLookup = toResistanceLookup(HIDE_ORDER);
1307
+ const resistanceFor = (lookup, columnId, variantId) => lookup.get(`${columnId}:${variantId}`) ?? DEFAULT_RESISTANCE;
1308
+ const buildColumns = (context, isTTY) => {
1309
+ return resolveColumnSpecs([
1310
+ createDescriptionColumnSpec(context, isTTY),
1311
+ createBarColumnSpec(context, isTTY),
1312
+ createAmountColumnSpec(context),
1313
+ createElapsedColumnSpec(context, isTTY),
1314
+ createEtaColumnSpec(context, isTTY)
1315
+ ].filter((spec) => spec !== void 0), {
1316
+ shrink: (columnId, variantId) => resistanceFor(shrinkResistanceLookup, columnId, variantId),
1317
+ demote: (columnId, variantId) => resistanceFor(demoteResistanceLookup, columnId, variantId),
1318
+ hide: (columnId, variantId) => resistanceFor(hideResistanceLookup, columnId, variantId)
1319
+ });
1320
+ };
1321
+ const computeFrameLayout = (rows, now, tick, terminalColumns, isTTY) => {
1322
+ const context = {
1323
+ rows,
438
1324
  now,
439
1325
  tick,
440
- isTTY,
441
- showTree: widths.showTree,
442
- amountSucceededWidth: widths.amountSucceeded,
443
- amountFailedWidth: widths.amountFailed,
444
- amountProcessedWidth: widths.amountProcessed,
445
- amountTotalWidth: widths.amountTotal
1326
+ terminalColumns
446
1327
  };
447
- return /* @__PURE__ */ jsxs(Box, {
1328
+ return planColumns({
1329
+ context,
1330
+ columns: buildColumns(context, isTTY),
1331
+ baselineWidth: hasDeterminateRows(rows) ? BASELINE_ROW_WIDTH : 1
1332
+ });
1333
+ };
1334
+
1335
+ //#endregion
1336
+ //#region src/ink-renderer/view/task-row.tsx
1337
+ const TaskRow = ({ row, layout }) => {
1338
+ return /* @__PURE__ */ jsx(Box, {
448
1339
  flexDirection: "row",
449
- minWidth: widths.row,
450
- children: [
451
- /* @__PURE__ */ jsx(Box, {
452
- width: widths.description,
453
- flexShrink: 1,
454
- marginRight: 1,
455
- children: /* @__PURE__ */ jsx(DescriptionColumn, { ...props })
456
- }),
457
- widths.bar > 0 ? /* @__PURE__ */ jsx(Box, {
458
- width: widths.bar,
459
- flexShrink: 0,
460
- marginRight: 1,
461
- children: /* @__PURE__ */ jsx(BarColumn, {
462
- ...props,
463
- width: Math.max(1, Math.min(widths.bar, DEFAULT_BAR_WIDTH))
464
- })
465
- }) : null,
466
- widths.amount > 0 ? /* @__PURE__ */ jsx(Box, {
467
- width: widths.amount,
468
- flexShrink: 0,
469
- marginRight: 1,
470
- children: /* @__PURE__ */ jsx(AmountColumn, { ...props })
471
- }) : null,
472
- /* @__PURE__ */ jsx(Box, {
473
- width: widths.elapsed,
474
- flexShrink: 0,
475
- marginRight: 1,
476
- children: /* @__PURE__ */ jsx(ElapsedColumn, { ...props })
477
- }),
478
- widths.eta > 0 ? /* @__PURE__ */ jsx(Box, {
479
- width: widths.eta,
480
- flexShrink: 0,
481
- children: /* @__PURE__ */ jsx(EtaColumn, { ...props })
482
- }) : null
483
- ]
1340
+ minWidth: layout.rowWidth,
1341
+ children: layout.columns.map((column, index) => /* @__PURE__ */ jsx(Box, {
1342
+ width: column.width,
1343
+ flexShrink: column.id === "description" ? 1 : 0,
1344
+ marginRight: index < layout.columns.length - 1 ? 1 : 0,
1345
+ children: column.renderCell(row, column.width)
1346
+ }, column.id))
484
1347
  });
485
1348
  };
486
1349
 
487
1350
  //#endregion
488
- //#region src/ink-renderer/app.tsx
489
- const ProgressApp = ({ rows, now, tick, isTTY, terminalColumns }) => {
490
- const widths = computeSharedColumnWidths(rows, now, tick, terminalColumns);
1351
+ //#region src/ink-renderer/view/progress-view.tsx
1352
+ const ProgressView = ({ rows, now, tick, isTTY, terminalColumns }) => {
1353
+ const layout = computeFrameLayout(rows, now, tick, terminalColumns, isTTY);
491
1354
  return /* @__PURE__ */ jsx(Box, {
492
1355
  flexDirection: "column",
493
1356
  children: rows.map((row) => /* @__PURE__ */ jsx(TaskRow, {
494
1357
  row,
495
- now,
496
- tick,
497
- isTTY,
498
- widths
1358
+ layout
499
1359
  }, row.task.id))
500
1360
  });
501
1361
  };
502
1362
 
503
1363
  //#endregion
504
- //#region src/ink-renderer/model.ts
505
- const orderedVisibleTasks = (store) => store.renderOrder.flatMap((row) => {
506
- const snapshot = store.tasks.get(row.id);
507
- if (!snapshot || snapshot.transient && snapshot.status !== "running") return [];
508
- return [{
509
- snapshot,
510
- depth: row.depth
511
- }];
512
- });
513
- const toTaskRows = (store) => computeTreeInfo(orderedVisibleTasks(store)).map((entry) => ({
514
- task: entry.snapshot,
515
- tree: entry.tree
516
- }));
1364
+ //#region src/ink-renderer/view/hooks/use-now-clock.ts
1365
+ const useNowClock = (active, intervalMillis) => {
1366
+ const [now, setNow] = useState(() => Date.now());
1367
+ useEffect(() => {
1368
+ if (!active) return;
1369
+ setNow(Date.now());
1370
+ const interval = setInterval(() => {
1371
+ setNow(Date.now());
1372
+ }, intervalMillis);
1373
+ return () => {
1374
+ clearInterval(interval);
1375
+ };
1376
+ }, [active, intervalMillis]);
1377
+ return now;
1378
+ };
517
1379
 
518
1380
  //#endregion
519
- //#region src/ink-renderer/service.tsx
520
- const RENDER_INTERVAL_MILLIS = 100;
521
- const hasRunningSpinners = (tasks) => tasks.some((task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits");
522
- const makeDefaultInkRenderer = () => ({ run: (storeRef, dirtyRef, stdio, isTTY) => Effect.gen(function* () {
523
- let instance;
524
- let tick = 0;
525
- let rendererActive = false;
526
- const renderStore = (store, now, terminalColumns) => Effect.sync(() => {
527
- const app = /* @__PURE__ */ jsx(ProgressApp, {
528
- rows: toTaskRows(store),
529
- now,
530
- tick,
531
- isTTY,
532
- terminalColumns
533
- });
534
- if (instance === void 0) {
535
- instance = render(app, {
536
- stdout: stdio.stdout,
537
- stderr: stdio.stderr,
538
- patchConsole: true,
539
- exitOnCtrlC: false,
540
- debug: false
541
- });
542
- return;
543
- }
544
- instance.rerender(app);
1381
+ //#region src/ink-renderer/view/hooks/use-spinner-clock.ts
1382
+ const useSpinnerClock = (active, intervalMillis) => {
1383
+ const [tick, setTick] = useState(0);
1384
+ useEffect(() => {
1385
+ if (!active) return;
1386
+ const interval = setInterval(() => {
1387
+ setTick((current) => current + 1);
1388
+ }, intervalMillis);
1389
+ return () => {
1390
+ clearInterval(interval);
1391
+ };
1392
+ }, [active, intervalMillis]);
1393
+ return tick;
1394
+ };
1395
+
1396
+ //#endregion
1397
+ //#region src/ink-renderer/view/render-root.tsx
1398
+ const SPINNER_INTERVAL_MILLIS = 100;
1399
+ const NOW_INTERVAL_MILLIS = 1e3;
1400
+ const ProgressRoot = ({ store, isTTY, getTerminalColumns }) => {
1401
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
1402
+ const tick = useSpinnerClock(snapshot.hasRunningTasks, SPINNER_INTERVAL_MILLIS);
1403
+ const now = useNowClock(snapshot.hasRunningTasks, NOW_INTERVAL_MILLIS);
1404
+ return /* @__PURE__ */ jsx(ProgressView, {
1405
+ rows: snapshot.rows,
1406
+ now,
1407
+ tick,
1408
+ isTTY,
1409
+ terminalColumns: getTerminalColumns()
545
1410
  });
546
- return yield* Effect.gen(function* () {
547
- rendererActive = true;
548
- while (true) {
549
- const dirty = yield* Ref.getAndSet(dirtyRef, false);
550
- const store = yield* Ref.get(storeRef);
551
- const tasks = Array.from(store.tasks.values()).filter((task) => !(task.transient && task.status !== "running"));
552
- if (dirty || hasRunningSpinners(tasks)) yield* renderStore(store, yield* Clock.currentTimeMillis, isTTY ? stdio.stderr.columns : void 0);
553
- tick += 1;
554
- yield* Effect.sleep(RENDER_INTERVAL_MILLIS);
555
- }
556
- }).pipe(Effect.ensuring(Effect.gen(function* () {
557
- if (rendererActive) yield* renderStore(yield* Ref.get(storeRef), yield* Clock.currentTimeMillis, isTTY ? stdio.stderr.columns : void 0);
558
- yield* Effect.sync(() => {
559
- instance?.unmount();
560
- });
561
- })));
562
- }) });
1411
+ };
1412
+
1413
+ //#endregion
1414
+ //#region src/services/ink-renderer.tsx
1415
+ const MAX_FPS = 12;
1416
+ const makeDefaultInkRenderer = () => ({ run: (store, stdio, isTTY) => Effect.sync(() => render(/* @__PURE__ */ jsx(ProgressRoot, {
1417
+ store,
1418
+ isTTY,
1419
+ getTerminalColumns: () => isTTY ? stdio.stderr.columns : void 0
1420
+ }), {
1421
+ stdout: stdio.stdout,
1422
+ stderr: stdio.stderr,
1423
+ patchConsole: true,
1424
+ exitOnCtrlC: false,
1425
+ debug: false,
1426
+ maxFps: MAX_FPS
1427
+ })).pipe(Effect.flatMap((instance) => Effect.never.pipe(Effect.ensuring(Effect.gen(function* () {
1428
+ store.flush();
1429
+ yield* Effect.sleep("0 millis");
1430
+ yield* Effect.sync(() => {
1431
+ instance.unmount();
1432
+ });
1433
+ }))))) });
563
1434
  var InkRenderer = class InkRenderer extends Context.Tag("stromseng.dev/effective-progress/InkRenderer")() {
564
1435
  static Default = Layer.succeed(InkRenderer, InkRenderer.of(makeDefaultInkRenderer()));
565
1436
  };
566
1437
 
567
1438
  //#endregion
568
- //#region src/stdio.ts
1439
+ //#region src/services/stdio.ts
569
1440
  const defaultStdioService = {
570
1441
  stdout: process.stdout,
571
1442
  stderr: process.stderr
@@ -575,348 +1446,32 @@ var ProgressStdio = class ProgressStdio extends Context.Tag("stromseng.dev/effec
575
1446
  };
576
1447
 
577
1448
  //#endregion
578
- //#region src/types.ts
579
- const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
580
- const TaskId = Brand.nominal();
581
- const TaskStatusSchema = Schema.Literal("running", "done", "failed");
582
- const TaskCountDisplaySchema = Schema.Literal("processedOnly", "detailed");
583
- var DeterminateTaskUnits = class extends Schema.TaggedClass()("DeterminateTaskUnits", {
584
- succeeded: Schema.Number,
585
- failed: Schema.Number,
586
- processed: Schema.Number,
587
- total: Schema.Number
588
- }) {};
589
- var IndeterminateTaskUnits = class extends Schema.TaggedClass()("IndeterminateTaskUnits", { spinnerFrame: Schema.Number }) {};
590
- const TaskUnitsSchema = Schema.Union(DeterminateTaskUnits, IndeterminateTaskUnits);
591
- var TaskSnapshot = class extends Schema.TaggedClass()("TaskSnapshot", {
592
- id: TaskIdSchema,
593
- parentId: Schema.NullOr(TaskIdSchema),
594
- description: Schema.String,
595
- status: TaskStatusSchema,
596
- countDisplay: TaskCountDisplaySchema,
597
- transient: Schema.Boolean,
598
- units: TaskUnitsSchema,
599
- startedAt: Schema.Number,
600
- completedAt: Schema.NullOr(Schema.Number)
601
- }) {};
602
- var Task = class extends Context.Tag("stromseng.dev/effective-progress/Task")() {};
603
- var TaskAddedEvent = class extends Schema.TaggedClass()("TaskAdded", {
604
- taskId: TaskIdSchema,
605
- parentId: Schema.NullOr(TaskIdSchema),
606
- description: Schema.String,
607
- total: Schema.optional(Schema.Number),
608
- transient: Schema.Boolean,
609
- countDisplay: TaskCountDisplaySchema
610
- }) {};
611
- var TaskUpdatedEvent = class extends Schema.TaggedClass()("TaskUpdated", {
612
- taskId: TaskIdSchema,
613
- description: Schema.optional(Schema.String),
614
- succeeded: Schema.optional(Schema.Number),
615
- failed: Schema.optional(Schema.Number),
616
- processed: Schema.optional(Schema.Number),
617
- total: Schema.optional(Schema.Number),
618
- transient: Schema.optional(Schema.Boolean),
619
- countDisplay: Schema.optional(TaskCountDisplaySchema)
620
- }) {};
621
- var TaskAdvancedEvent = class extends Schema.TaggedClass()("TaskAdvanced", {
622
- taskId: TaskIdSchema,
623
- amount: Schema.Number,
624
- kind: Schema.Literal("succeeded", "failed")
625
- }) {};
626
- var TaskCompletedEvent = class extends Schema.TaggedClass()("TaskCompleted", { taskId: TaskIdSchema }) {};
627
- var TaskFailedEvent = class extends Schema.TaggedClass()("TaskFailed", { taskId: TaskIdSchema }) {};
628
- var TaskRemovedEvent = class extends Schema.TaggedClass()("TaskRemoved", { taskId: TaskIdSchema }) {};
629
- const ProgressTaskEventSchema = Schema.Union(TaskAddedEvent, TaskUpdatedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskRemovedEvent);
630
- const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema);
631
-
632
- //#endregion
633
- //#region src/runtime.ts
634
- const normalizeDeterminateCounts = (counts) => {
635
- const total = Math.max(0, counts.total);
636
- const failed = Math.min(total, Math.max(0, counts.failed));
637
- const succeeded = Math.min(total - failed, Math.max(0, counts.succeeded));
638
- return new DeterminateTaskUnits({
639
- succeeded,
640
- failed,
641
- processed: succeeded + failed,
642
- total
643
- });
644
- };
645
- const updateDeterminateCounts = (units, options) => normalizeDeterminateCounts({
646
- succeeded: options.succeeded ?? units.succeeded,
647
- failed: options.failed ?? units.failed,
648
- total: options.total ?? units.total
649
- });
650
- const updatedSnapshot = (snapshot, options) => {
651
- const currentUnits = snapshot.units;
652
- const units = (() => {
653
- if (options.total !== void 0) {
654
- if (options.total <= 0) return new IndeterminateTaskUnits({ spinnerFrame: 0 });
655
- if (currentUnits._tag === "DeterminateTaskUnits") return updateDeterminateCounts(currentUnits, options);
656
- return normalizeDeterminateCounts({
657
- succeeded: options.succeeded ?? 0,
658
- failed: options.failed ?? 0,
659
- total: options.total
660
- });
661
- }
662
- if (currentUnits._tag === "DeterminateTaskUnits") {
663
- if (options.succeeded === void 0 && options.failed === void 0) return currentUnits;
664
- return updateDeterminateCounts(currentUnits, options);
665
- }
666
- return currentUnits;
667
- })();
668
- return new TaskSnapshot({
669
- id: snapshot.id,
670
- parentId: snapshot.parentId,
671
- description: options.description ?? snapshot.description,
672
- status: snapshot.status,
673
- countDisplay: options.countDisplay ?? snapshot.countDisplay,
674
- transient: options.transient ?? snapshot.transient,
675
- units,
676
- startedAt: snapshot.startedAt,
677
- completedAt: snapshot.completedAt
678
- });
679
- };
680
- const withTransient = (snapshot, transient) => new TaskSnapshot({
681
- id: snapshot.id,
682
- parentId: snapshot.parentId,
683
- description: snapshot.description,
684
- status: snapshot.status,
685
- countDisplay: snapshot.countDisplay,
686
- transient,
687
- units: snapshot.units,
688
- startedAt: snapshot.startedAt,
689
- completedAt: snapshot.completedAt
690
- });
691
- const findInsertionIndex = (renderOrder, parentId) => {
692
- if (parentId === null) return {
693
- index: renderOrder.length,
694
- depth: 0
695
- };
696
- const parentIdx = renderOrder.findIndex((row) => row.id === parentId);
697
- if (parentIdx === -1) return {
698
- index: renderOrder.length,
699
- depth: 0
700
- };
701
- const parentDepth = renderOrder[parentIdx].depth;
702
- let i = parentIdx + 1;
703
- while (i < renderOrder.length && renderOrder[i].depth > parentDepth) i++;
704
- return {
705
- index: i,
706
- depth: parentDepth + 1
707
- };
708
- };
709
- const removeFromRenderOrder = (renderOrder, taskId) => {
710
- const idx = renderOrder.findIndex((row) => row.id === taskId);
711
- if (idx === -1) return renderOrder;
712
- const taskDepth = renderOrder[idx].depth;
713
- let end = idx + 1;
714
- while (end < renderOrder.length && renderOrder[end].depth > taskDepth) end++;
715
- const next = [...renderOrder];
716
- next.splice(idx, end - idx);
717
- return next;
718
- };
1449
+ //#region src/services/progress.ts
719
1450
  const makeProgressService = Effect.gen(function* () {
720
1451
  const stdio = yield* ProgressStdio;
721
1452
  const inkRenderer = yield* InkRenderer;
722
1453
  const outerConsole = yield* Effect.console;
723
1454
  const isTTY = Boolean(stdio.stderr.isTTY);
724
- const nextTaskIdRef = yield* Ref.make(0);
725
- const storeRef = yield* Ref.make({
726
- tasks: /* @__PURE__ */ new Map(),
727
- renderOrder: []
728
- });
729
- const dirtyRef = yield* Ref.make(true);
1455
+ const store = makeProgressRenderStore();
730
1456
  const currentParentRef = yield* FiberRef.make(Option.none());
731
1457
  const scope = yield* Effect.scope;
732
- const markDirty = Ref.set(dirtyRef, true);
733
1458
  const log = (...args) => args.length === 0 ? Effect.void : outerConsole.log(...args);
734
- yield* Effect.forkIn(inkRenderer.run(storeRef, dirtyRef, stdio, isTTY), scope);
1459
+ yield* Effect.forkIn(inkRenderer.run(store, stdio, isTTY), scope);
735
1460
  yield* Effect.sleep("0 millis");
736
1461
  const addTask = (options) => Effect.gen(function* () {
737
1462
  const resolvedParentId = options.parentId === void 0 ? yield* FiberRef.get(currentParentRef) : Option.some(options.parentId);
738
- const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
739
- const units = options.total === void 0 || options.total <= 0 ? new IndeterminateTaskUnits({ spinnerFrame: 0 }) : normalizeDeterminateCounts({
740
- succeeded: 0,
741
- failed: 0,
742
- total: options.total
743
- });
744
- const store = yield* Ref.get(storeRef);
745
- const parentSnapshot = Option.isSome(resolvedParentId) ? store.tasks.get(resolvedParentId.value) : void 0;
746
- const now = yield* Clock.currentTimeMillis;
747
- const parentIdValue = Option.getOrNull(resolvedParentId);
748
- const countDisplay = options.countDisplay ?? parentSnapshot?.countDisplay ?? "detailed";
749
- const snapshot = new TaskSnapshot({
750
- id: taskId,
751
- parentId: parentIdValue,
752
- description: options.description,
753
- status: "running",
754
- countDisplay,
755
- transient: (parentSnapshot?.transient ?? false) || (options.transient ?? false),
756
- units,
757
- startedAt: now,
758
- completedAt: null
759
- });
760
- yield* Ref.update(storeRef, (s) => {
761
- const nextTasks = new Map(s.tasks);
762
- nextTasks.set(taskId, snapshot);
763
- const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
764
- const nextOrder = [...s.renderOrder];
765
- nextOrder.splice(index, 0, {
766
- id: taskId,
767
- depth
768
- });
769
- return {
770
- tasks: nextTasks,
771
- renderOrder: nextOrder
772
- };
773
- });
774
- yield* markDirty;
775
- return taskId;
776
- });
777
- const updateTask = (taskId, options) => Ref.update(storeRef, (store) => {
778
- const snapshot = store.tasks.get(taskId);
779
- if (!snapshot) return store;
780
- const nextTasks = new Map(store.tasks);
781
- const nextSnapshot = updatedSnapshot(snapshot, options);
782
- nextTasks.set(taskId, nextSnapshot);
783
- if (options.transient !== void 0) for (const [candidateId, candidate] of store.tasks.entries()) {
784
- if (candidateId === taskId) continue;
785
- let parentId = candidate.parentId;
786
- let isDescendant = false;
787
- while (parentId !== null) {
788
- if (parentId === taskId) {
789
- isDescendant = true;
790
- break;
791
- }
792
- parentId = store.tasks.get(parentId)?.parentId ?? null;
793
- }
794
- if (isDescendant) nextTasks.set(candidateId, withTransient(candidate, nextSnapshot.transient));
795
- }
796
- return {
797
- tasks: nextTasks,
798
- renderOrder: store.renderOrder
799
- };
800
- }).pipe(Effect.zipRight(markDirty));
801
- const advanceTask = (taskId, amount = 1) => Ref.update(storeRef, (store) => {
802
- const snapshot = store.tasks.get(taskId);
803
- if (!snapshot) return store;
804
- const units = snapshot.units._tag === "DeterminateTaskUnits" ? normalizeDeterminateCounts({
805
- succeeded: snapshot.units.succeeded + amount,
806
- failed: snapshot.units.failed,
807
- total: snapshot.units.total
808
- }) : new IndeterminateTaskUnits({ spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount) });
809
- const nextTasks = new Map(store.tasks);
810
- nextTasks.set(taskId, new TaskSnapshot({
811
- id: snapshot.id,
812
- parentId: snapshot.parentId,
813
- description: snapshot.description,
814
- status: snapshot.status,
815
- countDisplay: snapshot.countDisplay,
816
- transient: snapshot.transient,
817
- units,
818
- startedAt: snapshot.startedAt,
819
- completedAt: snapshot.completedAt
820
- }));
821
- return {
822
- tasks: nextTasks,
823
- renderOrder: store.renderOrder
824
- };
825
- }).pipe(Effect.zipRight(markDirty));
826
- const advanceTaskFailed = (taskId, amount = 1) => Ref.update(storeRef, (store) => {
827
- const snapshot = store.tasks.get(taskId);
828
- if (!snapshot) return store;
829
- if (snapshot.units._tag !== "DeterminateTaskUnits") return store;
830
- const units = normalizeDeterminateCounts({
831
- succeeded: snapshot.units.succeeded,
832
- failed: snapshot.units.failed + amount,
833
- total: snapshot.units.total
834
- });
835
- const nextTasks = new Map(store.tasks);
836
- nextTasks.set(taskId, new TaskSnapshot({
837
- id: snapshot.id,
838
- parentId: snapshot.parentId,
839
- description: snapshot.description,
840
- status: snapshot.status,
841
- countDisplay: snapshot.countDisplay,
842
- transient: snapshot.transient,
843
- units,
844
- startedAt: snapshot.startedAt,
845
- completedAt: snapshot.completedAt
846
- }));
847
- return {
848
- tasks: nextTasks,
849
- renderOrder: store.renderOrder
850
- };
851
- }).pipe(Effect.zipRight(markDirty));
852
- const completeTask = (taskId) => Effect.gen(function* () {
853
- const now = yield* Clock.currentTimeMillis;
854
- yield* Ref.update(storeRef, (store) => {
855
- const snapshot = store.tasks.get(taskId);
856
- if (!snapshot) return store;
857
- const nextTasks = new Map(store.tasks);
858
- if (snapshot.transient) {
859
- nextTasks.delete(taskId);
860
- return {
861
- tasks: nextTasks,
862
- renderOrder: removeFromRenderOrder(store.renderOrder, taskId)
863
- };
864
- }
865
- nextTasks.set(taskId, new TaskSnapshot({
866
- id: snapshot.id,
867
- parentId: snapshot.parentId,
868
- description: snapshot.description,
869
- status: "done",
870
- countDisplay: snapshot.countDisplay,
871
- transient: snapshot.transient,
872
- units: snapshot.units._tag === "DeterminateTaskUnits" ? normalizeDeterminateCounts({
873
- succeeded: snapshot.units.total - snapshot.units.failed,
874
- failed: snapshot.units.failed,
875
- total: snapshot.units.total
876
- }) : snapshot.units,
877
- startedAt: snapshot.startedAt,
878
- completedAt: now
879
- }));
880
- return {
881
- tasks: nextTasks,
882
- renderOrder: store.renderOrder
883
- };
884
- });
885
- yield* markDirty;
886
- });
887
- const failTask = (taskId) => Effect.gen(function* () {
888
- const now = yield* Clock.currentTimeMillis;
889
- yield* Ref.update(storeRef, (store) => {
890
- const snapshot = store.tasks.get(taskId);
891
- if (!snapshot) return store;
892
- const nextTasks = new Map(store.tasks);
893
- if (snapshot.transient) {
894
- nextTasks.delete(taskId);
895
- return {
896
- tasks: nextTasks,
897
- renderOrder: removeFromRenderOrder(store.renderOrder, taskId)
898
- };
899
- }
900
- nextTasks.set(taskId, new TaskSnapshot({
901
- id: snapshot.id,
902
- parentId: snapshot.parentId,
903
- description: snapshot.description,
904
- status: "failed",
905
- countDisplay: snapshot.countDisplay,
906
- transient: snapshot.transient,
907
- units: snapshot.units,
908
- startedAt: snapshot.startedAt,
909
- completedAt: now
910
- }));
911
- return {
912
- tasks: nextTasks,
913
- renderOrder: store.renderOrder
914
- };
1463
+ return yield* store.addTask({
1464
+ ...options,
1465
+ parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : void 0
915
1466
  });
916
- yield* markDirty;
917
1467
  });
918
- const getTask = (taskId) => Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
919
- const listTasks = Ref.get(storeRef).pipe(Effect.map((store) => Array.from(store.tasks.values())));
1468
+ const updateTask = store.updateTask;
1469
+ const advanceTask = store.addSuccess;
1470
+ const advanceTaskFailed = store.addFailure;
1471
+ const completeTask = store.completeTask;
1472
+ const failTask = store.failTask;
1473
+ const getTask = store.getTask;
1474
+ const listTasks = store.listTasks;
920
1475
  const runTask = dual(2, (effect, options) => Effect.gen(function* () {
921
1476
  const inheritedParentId = yield* FiberRef.get(currentParentRef);
922
1477
  const resolvedParentId = options.parentId === void 0 ? inheritedParentId : Option.some(options.parentId);