effective-progress 0.5.2 → 0.5.4

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  <img alt="Showcase output" src="docs/images/showcase.gif" width="600" />
13
13
 
14
- `effective-progress` is an [Effect](https://effect.website/)-first terminal progress-bar library with:
14
+ `effective-progress` is an [Effect](https://effect.website/)-native CLI progress bar library with:
15
15
 
16
16
  - multiple nested tree-like progress bars
17
17
  - spinner support for “we have no idea how long this takes” work
@@ -79,10 +79,17 @@ Effect.runPromise(program);
79
79
 
80
80
  <img alt="Nested example output" src="docs/images/nesting.gif" width="600" />
81
81
 
82
+ ### Effect.all modes
83
+
84
+ We support the `either`/`validate` modes of `Effect.all` and render the amount of sucesses/failures.
85
+
86
+ <img alt="Mixed outcomes modes output" src="docs/images/mixedOutcomes.gif" width="600" />
87
+
82
88
  ### Other examples
83
89
 
84
90
  - `examples/simpleExample.ts` - low-boilerplate real-world flow
85
91
  - `examples/advancedExample.ts` - full API usage and manual task control
92
+ - `examples/mixedOutcomes.ts` - fail-fast vs `either`/`validate` with mixed success/failure counters
86
93
  - `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
87
94
  - `examples/performance.ts` - stress-style run with high log volume and deeply nested progress updates
88
95
 
@@ -98,11 +105,20 @@ Effect.runPromise(program);
98
105
 
99
106
  - Rendering is powered by [Ink](https://github.com/vadimdemedes/ink).
100
107
  - Built-in columns are: description, bar, amount/spinner, elapsed, and ETA.
108
+ - Determinate bars are segmented by outcome: succeeded (green), failed (red), and remaining (neutral).
109
+ - Determinate amount text shows counters without prefixes: `<succeeded> <failed> <processed>/<total>`.
101
110
  - Column widths are shared per frame (widest visible cell wins), so rows stay aligned.
102
111
  - Elapsed and ETA reserve stable widths to reduce jitter while tasks transition states.
103
112
  - Layout uses a 100-column baseline and grows when content requires more space.
104
113
  - On narrow terminals, layout compacts to fit available width and tree prefixes are suppressed when description space is too tight.
105
114
 
115
+ ### Mixed outcomes and `mode`
116
+
117
+ - `Progress.all` in default mode (`mode: "default"`) remains fail-fast.
118
+ - In fail-fast runs, unresolved units remain unprocessed.
119
+ - `mode: "either"` and `mode: "validate"` run all effects and keep mixed outcomes in the task counters.
120
+ - Mixed outcomes can still finalize as `done` when all units are accounted for.
121
+
106
122
  ## Manual task control
107
123
 
108
124
  For manual usage, `task` still provides the current `Task` context, while logs continue through your outer `Console`:
@@ -110,11 +126,16 @@ For manual usage, `task` still provides the current `Task` context, while logs c
110
126
  ```ts
111
127
  const program = Progress.task(
112
128
  Effect.gen(function* () {
129
+ const progress = yield* Progress.Progress;
113
130
  const currentTask = yield* Progress.Task;
114
131
  yield* Console.log("This log is handled by the outer Console", { taskId: currentTask });
132
+
133
+ // Manual determinate updates:
134
+ yield* progress.advanceTask(currentTask, 3);
135
+ yield* progress.advanceTaskFailed(currentTask, 1);
115
136
  yield* Effect.sleep("1 second");
116
137
  }),
117
- { description: "Manual task" },
138
+ { description: "Manual task", total: 10 },
118
139
  );
119
140
  ```
120
141
 
@@ -122,35 +143,6 @@ const program = Progress.task(
122
143
 
123
144
  Custom column APIs are not part of the first Ink release. The renderer ships with built-in columns only, and old renderer config APIs (`RendererConfig`, `ProgressBarConfig`, custom column definitions) are intentionally removed in this iteration.
124
145
 
125
- ## Terminal service and mocking
126
-
127
- `effective-progress` now exposes a `ProgressTerminal` service that controls terminal detection and I/O:
128
-
129
- - `isTTY`
130
- - `stderrRows`
131
- - `stderrColumns`
132
- - `writeStderr(text)`
133
- - `withRawInputCapture(effect)`
134
-
135
- You can provide a mock if you want to alter the behavior of terminal detection or if you want to capture the output for testing:
136
-
137
- ```ts
138
- import { Effect } from "effect";
139
- import * as Progress from "effective-progress";
140
-
141
- const mockTerminal: Progress.ProgressTerminalService = {
142
- isTTY: Effect.succeed(true),
143
- stderrRows: Effect.succeed(40),
144
- stderrColumns: Effect.succeed(120),
145
- writeStderr: (_text) => Effect.void,
146
- withRawInputCapture: (effect) => effect,
147
- };
148
-
149
- const program = Progress.task(Effect.sleep("100 millis"), { description: "work" }).pipe(
150
- Effect.provideService(Progress.ProgressTerminal, mockTerminal),
151
- );
152
- ```
153
-
154
146
  ## Notes
155
147
 
156
148
  - As Effect 4.0 is around the corner with some changes to logging, there may be some adjustments needed to align with the new Effect APIs.
package/dist/index.d.mts CHANGED
@@ -8,23 +8,30 @@ type TaskId = typeof TaskIdSchema.Type;
8
8
  declare const TaskId: Brand.Brand.Constructor<number & Brand.Brand<"TaskId">>;
9
9
  declare const TaskStatusSchema: Schema.Literal<["running", "done", "failed"]>;
10
10
  type TaskStatus = typeof TaskStatusSchema.Type;
11
+ declare const TaskCountDisplaySchema: Schema.Literal<["processedOnly", "detailed"]>;
12
+ type TaskCountDisplay = typeof TaskCountDisplaySchema.Type;
11
13
  interface AddTaskOptions {
12
14
  readonly description: string;
13
15
  readonly total?: number;
14
16
  readonly transient?: boolean;
15
17
  readonly parentId?: TaskId;
18
+ readonly countDisplay?: TaskCountDisplay;
16
19
  }
17
20
  interface UpdateTaskOptions {
18
21
  readonly description?: string;
19
- readonly completed?: number;
22
+ readonly succeeded?: number;
23
+ readonly failed?: number;
20
24
  readonly total?: number;
21
25
  readonly transient?: boolean;
26
+ readonly countDisplay?: TaskCountDisplay;
22
27
  }
23
28
  type TrackOptions = Exclude<AddTaskOptions, "parentId">;
24
29
  declare const DeterminateTaskUnits_base: Schema.TaggedClass<DeterminateTaskUnits, "DeterminateTaskUnits", {
25
30
  readonly _tag: Schema.tag<"DeterminateTaskUnits">;
26
31
  } & {
27
- completed: typeof Schema.Number;
32
+ succeeded: typeof Schema.Number;
33
+ failed: typeof Schema.Number;
34
+ processed: typeof Schema.Number;
28
35
  total: typeof Schema.Number;
29
36
  }>;
30
37
  declare class DeterminateTaskUnits extends DeterminateTaskUnits_base {}
@@ -43,6 +50,7 @@ declare const TaskSnapshot_base: Schema.TaggedClass<TaskSnapshot, "TaskSnapshot"
43
50
  parentId: Schema.NullOr<Schema.brand<typeof Schema.Number, "TaskId">>;
44
51
  description: typeof Schema.String;
45
52
  status: Schema.Literal<["running", "done", "failed"]>;
53
+ countDisplay: Schema.Literal<["processedOnly", "detailed"]>;
46
54
  transient: typeof Schema.Boolean;
47
55
  units: Schema.Union<[typeof DeterminateTaskUnits, typeof IndeterminateTaskUnits]>;
48
56
  startedAt: typeof Schema.Number;
@@ -61,6 +69,7 @@ interface ProgressService {
61
69
  readonly addTask: (options: AddTaskOptions) => Effect.Effect<TaskId>;
62
70
  readonly updateTask: (taskId: TaskId, options: UpdateTaskOptions) => Effect.Effect<void>;
63
71
  readonly advanceTask: (taskId: TaskId, amount?: number) => Effect.Effect<void>;
72
+ readonly advanceTaskFailed: (taskId: TaskId, amount?: number) => Effect.Effect<void>;
64
73
  readonly completeTask: (taskId: TaskId) => Effect.Effect<void>;
65
74
  readonly failTask: (taskId: TaskId) => Effect.Effect<void>;
66
75
  readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void>;
@@ -85,6 +94,7 @@ declare const TaskAddedEvent_base: Schema.TaggedClass<TaskAddedEvent, "TaskAdded
85
94
  description: typeof Schema.String;
86
95
  total: Schema.optional<typeof Schema.Number>;
87
96
  transient: typeof Schema.Boolean;
97
+ countDisplay: Schema.Literal<["processedOnly", "detailed"]>;
88
98
  }>;
89
99
  declare class TaskAddedEvent extends TaskAddedEvent_base {}
90
100
  declare const TaskUpdatedEvent_base: Schema.TaggedClass<TaskUpdatedEvent, "TaskUpdated", {
@@ -92,9 +102,12 @@ declare const TaskUpdatedEvent_base: Schema.TaggedClass<TaskUpdatedEvent, "TaskU
92
102
  } & {
93
103
  taskId: Schema.brand<typeof Schema.Number, "TaskId">;
94
104
  description: Schema.optional<typeof Schema.String>;
95
- completed: Schema.optional<typeof Schema.Number>;
105
+ succeeded: Schema.optional<typeof Schema.Number>;
106
+ failed: Schema.optional<typeof Schema.Number>;
107
+ processed: Schema.optional<typeof Schema.Number>;
96
108
  total: Schema.optional<typeof Schema.Number>;
97
109
  transient: Schema.optional<typeof Schema.Boolean>;
110
+ countDisplay: Schema.optional<Schema.Literal<["processedOnly", "detailed"]>>;
98
111
  }>;
99
112
  declare class TaskUpdatedEvent extends TaskUpdatedEvent_base {}
100
113
  declare const TaskAdvancedEvent_base: Schema.TaggedClass<TaskAdvancedEvent, "TaskAdvanced", {
@@ -102,6 +115,7 @@ declare const TaskAdvancedEvent_base: Schema.TaggedClass<TaskAdvancedEvent, "Tas
102
115
  } & {
103
116
  taskId: Schema.brand<typeof Schema.Number, "TaskId">;
104
117
  amount: typeof Schema.Number;
118
+ kind: Schema.Literal<["succeeded", "failed"]>;
105
119
  }>;
106
120
  declare class TaskAdvancedEvent extends TaskAdvancedEvent_base {}
107
121
  declare const TaskCompletedEvent_base: Schema.TaggedClass<TaskCompletedEvent, "TaskCompleted", {
@@ -142,12 +156,12 @@ interface EffectAllExecutionOptions extends EffectExecutionOptions {
142
156
  readonly discard?: boolean;
143
157
  readonly mode?: "default" | "validate" | "either";
144
158
  }
145
- type AllOptions = Omit<TrackOptions, "total"> & EffectAllExecutionOptions;
159
+ type AllOptions = Omit<TrackOptions, "total" | "countDisplay"> & EffectAllExecutionOptions;
146
160
  type AllReturn<Arg extends ReadonlyArray<Effect.Effect<any, any, any>> | Record<string, Effect.Effect<any, any, any>>, O extends EffectAllExecutionOptions> = [[Arg] extends [ReadonlyArray<Effect.Effect<any, any, any>>] ? Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>> : [Arg] extends [Record<string, Effect.Effect<any, any, any>>] ? Effect.All.ReturnObject<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>> : never] extends [Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress | Task>> : never;
147
161
  interface ForEachExecutionOptions extends EffectExecutionOptions {
148
162
  readonly discard?: false | undefined;
149
163
  }
150
- type ForEachOptions = TrackOptions & ForEachExecutionOptions;
164
+ type ForEachOptions = Omit<TrackOptions, "countDisplay"> & ForEachExecutionOptions;
151
165
  type TaskOptions = AddTaskOptions;
152
166
  declare const task: {
153
167
  <A, E, R>(effect: Effect.Effect<A, E, R>, options: TaskOptions): Effect.Effect<A, E, Exclude<R, Progress | Task>>;
@@ -155,25 +169,22 @@ declare const task: {
155
169
  };
156
170
  type AllArg = ReadonlyArray<Effect.Effect<any, any, any>> | Record<string, Effect.Effect<any, any, any>>;
157
171
  declare const all: {
158
- <const Arg extends AllArg, O extends EffectAllExecutionOptions>(effects: Arg, options: Omit<TrackOptions, "total"> & O): AllReturn<Arg, O>;
159
- <O extends EffectAllExecutionOptions>(options: Omit<TrackOptions, "total"> & O): <const Arg extends AllArg>(effects: Arg) => AllReturn<Arg, O>;
172
+ <const Arg extends AllArg, O extends EffectAllExecutionOptions>(effects: Arg, options: Omit<TrackOptions, "total" | "countDisplay"> & O): AllReturn<Arg, O>;
173
+ <O extends EffectAllExecutionOptions>(options: Omit<TrackOptions, "total" | "countDisplay"> & O): <const Arg extends AllArg>(effects: Arg) => AllReturn<Arg, O>;
160
174
  };
161
175
  declare const forEach: {
162
176
  <A, B, E, R>(iterable: Iterable<A>, f: (item: A, index: number) => Effect.Effect<B, E, R>, options: ForEachOptions): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
163
177
  <A, B, E, R>(f: (item: A, index: number) => Effect.Effect<B, E, R>, options: ForEachOptions): (iterable: Iterable<A>) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
164
178
  };
165
179
  //#endregion
166
- //#region src/terminal.d.ts
167
- interface ProgressTerminalService {
168
- readonly isTTY: Effect.Effect<boolean>;
169
- readonly stderrRows: Effect.Effect<number | undefined>;
170
- readonly stderrColumns: Effect.Effect<number | undefined>;
171
- readonly writeStderr: (text: string) => Effect.Effect<void>;
172
- readonly withRawInputCapture: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
180
+ //#region src/stdio.d.ts
181
+ interface ProgressStdioService {
182
+ readonly stdout: NodeJS.WriteStream;
183
+ readonly stderr: NodeJS.WriteStream;
173
184
  }
174
- declare const ProgressTerminal_base: Context.TagClass<ProgressTerminal, "stromseng.dev/ProgressTerminal", ProgressTerminalService>;
175
- declare class ProgressTerminal extends ProgressTerminal_base {
176
- static readonly Default: Layer.Layer<ProgressTerminal, never, never>;
185
+ declare const ProgressStdio_base: Context.TagClass<ProgressStdio, "stromseng.dev/effective-progress/ProgressStdio", ProgressStdioService>;
186
+ declare class ProgressStdio extends ProgressStdio_base {
187
+ static readonly Default: Layer.Layer<ProgressStdio, never, never>;
177
188
  }
178
189
  //#endregion
179
- export { AddTaskOptions, AllOptions, AllReturn, DeterminateTaskUnits, EffectAllExecutionOptions, EffectExecutionOptions, ForEachExecutionOptions, ForEachOptions, IndeterminateTaskUnits, Progress, ProgressService, ProgressTaskEvent, ProgressTaskEventSchema, ProgressTerminal, ProgressTerminalService, RenderRow, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskId, TaskOptions, TaskRemovedEvent, TaskSnapshot, TaskStatus, TaskStatusSchema, TaskStore, TaskUnits, TaskUnitsSchema, TaskUpdatedEvent, TrackOptions, UpdateTaskOptions, all, decodeProgressTaskEvent, forEach, task };
190
+ export { AddTaskOptions, AllOptions, AllReturn, DeterminateTaskUnits, EffectAllExecutionOptions, EffectExecutionOptions, ForEachExecutionOptions, ForEachOptions, IndeterminateTaskUnits, Progress, ProgressService, ProgressStdio, ProgressStdioService, ProgressTaskEvent, ProgressTaskEventSchema, RenderRow, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskCountDisplay, TaskCountDisplaySchema, TaskFailedEvent, TaskId, TaskOptions, TaskRemovedEvent, TaskSnapshot, TaskStatus, TaskStatusSchema, TaskStore, TaskUnits, TaskUnitsSchema, TaskUpdatedEvent, TrackOptions, UpdateTaskOptions, all, decodeProgressTaskEvent, forEach, task };
package/dist/index.mjs CHANGED
@@ -1,8 +1,7 @@
1
- import { Brand, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref, Schema } from "effect";
1
+ import { Brand, Cause, Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref, Schema } from "effect";
2
2
  import { dual } from "effect/Function";
3
- import { Writable } from "node:stream";
4
3
  import { Box, Text, render } from "ink";
5
- import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
5
 
7
6
  //#region src/ink-renderer/format.ts
8
7
  const SPINNER_FRAMES = [
@@ -34,19 +33,64 @@ const formatElapsed = (task, now) => {
34
33
  };
35
34
  const formatEta = (task, now) => {
36
35
  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 "";
36
+ const { processed, total } = task.units;
37
+ const remaining = total - processed;
38
+ if (processed <= 0 || remaining <= 0) return "";
40
39
  const elapsedMillis = Math.max(1, now - task.startedAt);
41
- return formatDurationSeconds(Math.max(0, Math.floor(elapsedMillis / completed * remaining)) / 1e3);
40
+ return formatDurationSeconds(Math.max(0, Math.floor(elapsedMillis / processed * remaining)) / 1e3);
42
41
  };
43
- const formatAmount = (task, tick) => {
42
+ const getTaskIndicator = (task, tick) => {
43
+ if (task.status === "running") return {
44
+ symbol: SPINNER_FRAMES[tick % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0],
45
+ color: "yellow"
46
+ };
47
+ if (task.status === "failed") return {
48
+ symbol: "✗",
49
+ color: "red"
50
+ };
51
+ if (task.units._tag !== "DeterminateTaskUnits") return {
52
+ symbol: "✓",
53
+ color: "green"
54
+ };
55
+ const { succeeded, failed, processed, total } = task.units;
56
+ if (failed === 0 && processed === total) return {
57
+ symbol: "✓",
58
+ color: "green"
59
+ };
60
+ if (failed > 0 && succeeded > 0) return {
61
+ symbol: "~",
62
+ color: "yellow"
63
+ };
64
+ if (failed > 0 && succeeded === 0) return {
65
+ symbol: "✗",
66
+ color: "red"
67
+ };
68
+ return {
69
+ symbol: "✓",
70
+ color: "green"
71
+ };
72
+ };
73
+ const formatDeterminateAmountParts = (task) => {
74
+ if (task.units._tag !== "DeterminateTaskUnits") return;
75
+ const totalText = `${task.units.total}`;
76
+ const width = totalText.length;
77
+ const processedText = `${task.units.processed}`;
78
+ return {
79
+ succeeded: task.countDisplay === "detailed" ? `${task.units.succeeded}`.padStart(width, " ") : "",
80
+ failed: task.countDisplay === "detailed" ? `${task.units.failed}`.padStart(width, " ") : "",
81
+ processed: processedText,
82
+ total: totalText
83
+ };
84
+ };
85
+ const formatAmount = (task, _tick) => {
44
86
  if (task.units._tag === "DeterminateTaskUnits") {
45
- const totalText = `${task.units.total}`;
46
- return `${`${task.units.completed}`.padStart(totalText.length, " ")}/${totalText}`;
87
+ const parts = formatDeterminateAmountParts(task);
88
+ if (parts === void 0) return "";
89
+ if (task.countDisplay === "detailed") return `${parts.succeeded} ${parts.failed} ${parts.processed}/${parts.total}`;
90
+ return `${parts.processed}/${parts.total}`;
47
91
  }
48
- if (task.status === "running") return SPINNER_FRAMES[(task.units.spinnerFrame + tick) % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
49
- return task.status === "done" ? "" : "";
92
+ if (task.status === "running" && task.units._tag === "IndeterminateTaskUnits") return "";
93
+ return task.status === "failed" ? "" : "";
50
94
  };
51
95
 
52
96
  //#endregion
@@ -90,11 +134,11 @@ const computeTreeInfo = (ordered) => {
90
134
 
91
135
  //#endregion
92
136
  //#region src/ink-renderer/layout.ts
93
- const DEFAULT_BAR_WIDTH = 20;
137
+ const DEFAULT_BAR_WIDTH = 30;
94
138
  const MIN_DESCRIPTION_WIDTH = 8;
95
139
  const MIN_BAR_WIDTH = 8;
96
- const MIN_ELAPSED_WIDTH = 3;
97
- const MIN_AMOUNT_WIDTH = 1;
140
+ const MIN_ELAPSED_WIDTH = 2;
141
+ const MIN_AMOUNT_WIDTH = 0;
98
142
  const BASELINE_ROW_WIDTH = 100;
99
143
  const MIN_DESCRIPTION_COLUMNS_FOR_TREE = 24;
100
144
  const RESERVED_ELAPSED_WIDTH_UP_TO_ONE_HOUR = Array.from("59m 59s").length;
@@ -102,29 +146,49 @@ const RESERVED_ETA_WIDTH_UP_TO_ONE_HOUR = Array.from("ETA: 59m 59s").length;
102
146
  const textWidth = (text) => Array.from(text).length;
103
147
  const computeWidths = (rows, now, tick, terminalColumns, includeTree = true) => {
104
148
  let hasDeterminate = false;
149
+ let hasDetailedDeterminate = false;
105
150
  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;
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;
109
158
  for (const row of rows) {
110
159
  const { task, tree } = row;
111
160
  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)));
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)));
116
174
  if (task.status === "running" && task.units._tag === "DeterminateTaskUnits") {
117
175
  const etaValue = formatEta(task, now);
118
176
  const etaText = `ETA: ${etaValue.length > 0 ? etaValue : "--"}`;
119
- eta = Math.max(eta, textWidth(etaText));
177
+ etaContentWidth = Math.max(etaContentWidth, textWidth(etaText));
120
178
  }
121
179
  }
180
+ const structuredAmount = hasDeterminate ? amountProcessed + 1 + amountTotal + (hasDetailedDeterminate ? amountSucceeded + 1 + amountFailed + 1 : 0) : 0;
181
+ if (hasDeterminate) amount = structuredAmount;
122
182
  let widths = {
123
183
  description,
124
184
  bar: hasDeterminate ? DEFAULT_BAR_WIDTH : 0,
125
185
  amount,
126
- elapsed,
127
- eta
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
128
192
  };
129
193
  const visible = (w) => [
130
194
  w.description,
@@ -137,7 +201,7 @@ const computeWidths = (rows, now, tick, terminalColumns, includeTree = true) =>
137
201
  const cols = visible(w);
138
202
  return cols.reduce((sum, width) => sum + width, 0) + Math.max(0, cols.length - 1);
139
203
  };
140
- const baselineTarget = Math.max(BASELINE_ROW_WIDTH, total(widths));
204
+ const baselineTarget = hasDeterminate ? Math.max(BASELINE_ROW_WIDTH, total(widths)) : total(widths);
141
205
  const target = terminalColumns === void 0 ? baselineTarget : Math.max(1, Math.min(Math.max(1, Math.floor(terminalColumns)), baselineTarget));
142
206
  if (total(widths) < target) widths.description += target - total(widths);
143
207
  else if (total(widths) > target) {
@@ -154,20 +218,28 @@ const computeWidths = (rows, now, tick, terminalColumns, includeTree = true) =>
154
218
  };
155
219
  overflow -= delta;
156
220
  };
157
- reduceBy("description", MIN_DESCRIPTION_WIDTH);
158
- reduceBy("eta", 0);
221
+ reduceBy("eta", etaContentWidth);
222
+ reduceBy("elapsed", elapsedContentWidth);
159
223
  reduceBy("bar", MIN_BAR_WIDTH);
224
+ reduceBy("eta", 0);
160
225
  reduceBy("bar", 0);
161
226
  reduceBy("elapsed", MIN_ELAPSED_WIDTH);
227
+ reduceBy("description", MIN_DESCRIPTION_WIDTH);
162
228
  reduceBy("amount", MIN_AMOUNT_WIDTH);
163
229
  reduceBy("description", 0);
164
230
  if (total(widths) < target) widths.description += target - total(widths);
165
231
  }
232
+ const rowWidth = total(widths);
233
+ const useStructuredAmount = hasDeterminate && widths.amount >= structuredAmount;
166
234
  return {
167
- row: total(widths),
235
+ row: rowWidth,
168
236
  description: widths.description,
169
237
  bar: widths.bar,
170
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,
171
243
  elapsed: widths.elapsed,
172
244
  eta: widths.eta
173
245
  };
@@ -186,42 +258,161 @@ const computeSharedColumnWidths = (rows, now, tick, terminalColumns) => {
186
258
 
187
259
  //#endregion
188
260
  //#region src/ink-renderer/columns/amount-column.tsx
189
- const AmountColumn = ({ task, tick }) => {
190
- const text = formatAmount(task, tick);
261
+ const padLeft = (value, width) => value.padStart(Math.max(0, width), " ");
262
+ const padRight = (value, width) => value.padEnd(Math.max(0, width), " ");
263
+ const blank = (width) => " ".repeat(Math.max(0, width));
264
+ const shouldShowDetailedCounts = (task) => task.units._tag === "DeterminateTaskUnits" && task.countDisplay === "detailed";
265
+ const SucceededCountColumn = ({ task, width }) => {
266
+ if (width <= 0) return null;
267
+ if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
268
+ if (!shouldShowDetailedCounts(task)) return /* @__PURE__ */ jsx(Text, { children: blank(width) });
191
269
  return /* @__PURE__ */ jsx(Text, {
192
- color: task.status === "failed" ? "red" : task.status === "done" ? "green" : task.units._tag === "DeterminateTaskUnits" ? "whiteBright" : "yellow",
193
- children: text
270
+ color: "green",
271
+ children: padLeft(`${task.units.succeeded}`, width)
272
+ });
273
+ };
274
+ const FailedCountColumn = ({ task, width }) => {
275
+ if (width <= 0) return null;
276
+ if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
277
+ if (!shouldShowDetailedCounts(task)) return /* @__PURE__ */ jsx(Text, { children: blank(width) });
278
+ return /* @__PURE__ */ jsx(Text, {
279
+ color: "red",
280
+ children: padLeft(`${task.units.failed}`, width)
281
+ });
282
+ };
283
+ const ProcessedCountColumn = ({ task, width }) => {
284
+ if (width <= 0) return null;
285
+ if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
286
+ return /* @__PURE__ */ jsx(Text, { children: padLeft(`${task.units.processed}`, width) });
287
+ };
288
+ const AmountSeparatorColumn = ({ task, showStructured, tick }) => {
289
+ if (!showStructured) return null;
290
+ if (task.units._tag === "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: "/" });
291
+ const symbol = formatAmount(task, tick);
292
+ if (task.status === "failed") return /* @__PURE__ */ jsx(Text, {
293
+ color: "red",
294
+ children: symbol
295
+ });
296
+ if (task.status === "running") return /* @__PURE__ */ jsx(Text, {
297
+ color: "yellow",
298
+ children: symbol
299
+ });
300
+ return /* @__PURE__ */ jsx(Text, { children: symbol });
301
+ };
302
+ const TotalCountColumn = ({ task, width }) => {
303
+ if (width <= 0) return null;
304
+ if (task.units._tag !== "DeterminateTaskUnits") return /* @__PURE__ */ jsx(Text, { children: blank(width) });
305
+ return /* @__PURE__ */ jsx(Text, { children: padRight(`${task.units.total}`, width) });
306
+ };
307
+ const AmountColumn = ({ task, tick, amountSucceededWidth, amountFailedWidth, amountProcessedWidth, amountTotalWidth }) => {
308
+ const showStructured = amountProcessedWidth > 0 && amountTotalWidth > 0;
309
+ if (!showStructured) {
310
+ const text = formatAmount(task, tick);
311
+ if (task.status === "failed") return /* @__PURE__ */ jsx(Text, {
312
+ wrap: "truncate-end",
313
+ color: "red",
314
+ children: text
315
+ });
316
+ if (task.status === "running") return /* @__PURE__ */ jsx(Text, {
317
+ wrap: "truncate-end",
318
+ color: "yellow",
319
+ children: text
320
+ });
321
+ return /* @__PURE__ */ jsx(Text, {
322
+ wrap: "truncate-end",
323
+ children: text
324
+ });
325
+ }
326
+ return /* @__PURE__ */ jsxs(Box, {
327
+ flexDirection: "row",
328
+ children: [
329
+ amountSucceededWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SucceededCountColumn, {
330
+ task,
331
+ width: amountSucceededWidth
332
+ }), /* @__PURE__ */ jsx(Text, { children: ` ` })] }) : null,
333
+ amountFailedWidth > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FailedCountColumn, {
334
+ task,
335
+ width: amountFailedWidth
336
+ }), /* @__PURE__ */ jsx(Text, { children: ` ` })] }) : null,
337
+ /* @__PURE__ */ jsx(ProcessedCountColumn, {
338
+ task,
339
+ width: amountProcessedWidth
340
+ }),
341
+ /* @__PURE__ */ jsx(AmountSeparatorColumn, {
342
+ task,
343
+ showStructured,
344
+ tick
345
+ }),
346
+ /* @__PURE__ */ jsx(TotalCountColumn, {
347
+ task,
348
+ width: amountTotalWidth
349
+ })
350
+ ]
194
351
  });
195
352
  };
196
353
 
197
354
  //#endregion
198
355
  //#region src/ink-renderer/columns/bar-column.tsx
199
- const clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), maximum);
356
+ const segmentLengths = (width, total, succeeded, failed) => {
357
+ if (total <= 0) return {
358
+ succeeded: 0,
359
+ failed: 0,
360
+ remaining: width
361
+ };
362
+ const succeededEnd = Math.round(succeeded / total * width);
363
+ const failedEnd = Math.round((succeeded + failed) / total * width);
364
+ const succeededLength = Math.max(0, Math.min(width, succeededEnd));
365
+ const failedLength = Math.max(0, Math.min(width, failedEnd) - succeededLength);
366
+ return {
367
+ succeeded: succeededLength,
368
+ failed: failedLength,
369
+ remaining: Math.max(0, width - succeededLength - failedLength)
370
+ };
371
+ };
200
372
  const BarColumn = ({ task, width }) => {
201
373
  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, {
374
+ const lengths = segmentLengths(Math.max(1, Math.floor(width)), task.units.total, task.units.succeeded, task.units.failed);
375
+ return /* @__PURE__ */ jsxs(Text, {
209
376
  wrap: "truncate-end",
210
- color: task.status === "failed" ? "red" : task.status === "done" ? "green" : "blue",
211
- children: bar
377
+ children: [
378
+ /* @__PURE__ */ jsx(Text, {
379
+ color: "green",
380
+ children: "━".repeat(lengths.succeeded)
381
+ }),
382
+ /* @__PURE__ */ jsx(Text, {
383
+ color: "red",
384
+ children: "━".repeat(lengths.failed)
385
+ }),
386
+ /* @__PURE__ */ jsx(Text, {
387
+ color: "gray",
388
+ children: "─".repeat(lengths.remaining)
389
+ })
390
+ ]
212
391
  });
213
392
  };
214
393
 
215
394
  //#endregion
216
395
  //#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
- });
396
+ const DescriptionColumn = ({ task, tree, showTree, tick }) => {
397
+ const treePrefix = showTree ? renderTreePrefix(tree) : "";
398
+ const indicator = getTaskIndicator(task, tick);
399
+ return /* @__PURE__ */ jsxs(Text, {
400
+ wrap: "truncate-end",
401
+ children: [
402
+ treePrefix,
403
+ /* @__PURE__ */ jsx(Text, {
404
+ color: indicator.color,
405
+ children: indicator.symbol
406
+ }),
407
+ ` ${task.description}`
408
+ ]
409
+ });
410
+ };
221
411
 
222
412
  //#endregion
223
413
  //#region src/ink-renderer/columns/elapsed-column.tsx
224
414
  const ElapsedColumn = ({ task, now }) => /* @__PURE__ */ jsx(Text, {
415
+ wrap: "truncate-end",
225
416
  color: "gray",
226
417
  children: formatElapsed(task, now)
227
418
  });
@@ -247,7 +438,11 @@ const TaskRow = ({ row, now, tick, isTTY, widths }) => {
247
438
  now,
248
439
  tick,
249
440
  isTTY,
250
- showTree: widths.showTree
441
+ showTree: widths.showTree,
442
+ amountSucceededWidth: widths.amountSucceeded,
443
+ amountFailedWidth: widths.amountFailed,
444
+ amountProcessedWidth: widths.amountProcessed,
445
+ amountTotalWidth: widths.amountTotal
251
446
  };
252
447
  return /* @__PURE__ */ jsxs(Box, {
253
448
  flexDirection: "row",
@@ -268,12 +463,12 @@ const TaskRow = ({ row, now, tick, isTTY, widths }) => {
268
463
  width: Math.max(1, Math.min(widths.bar, DEFAULT_BAR_WIDTH))
269
464
  })
270
465
  }) : null,
271
- /* @__PURE__ */ jsx(Box, {
466
+ widths.amount > 0 ? /* @__PURE__ */ jsx(Box, {
272
467
  width: widths.amount,
273
468
  flexShrink: 0,
274
469
  marginRight: 1,
275
470
  children: /* @__PURE__ */ jsx(AmountColumn, { ...props })
276
- }),
471
+ }) : null,
277
472
  /* @__PURE__ */ jsx(Box, {
278
473
  width: widths.elapsed,
279
474
  flexShrink: 0,
@@ -324,17 +519,7 @@ const toTaskRows = (store) => computeTreeInfo(orderedVisibleTasks(store)).map((e
324
519
  //#region src/ink-renderer/service.tsx
325
520
  const RENDER_INTERVAL_MILLIS = 100;
326
521
  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);
522
+ const makeDefaultInkRenderer = () => ({ run: (storeRef, dirtyRef, stdio, isTTY) => Effect.gen(function* () {
338
523
  let instance;
339
524
  let tick = 0;
340
525
  let rendererActive = false;
@@ -348,8 +533,8 @@ const makeDefaultInkRenderer = () => ({ run: (storeRef, dirtyRef, terminal, isTT
348
533
  });
349
534
  if (instance === void 0) {
350
535
  instance = render(app, {
351
- stdout: output,
352
- stderr: output,
536
+ stdout: stdio.stdout,
537
+ stderr: stdio.stderr,
353
538
  patchConsole: true,
354
539
  exitOnCtrlC: false,
355
540
  debug: false
@@ -364,12 +549,12 @@ const makeDefaultInkRenderer = () => ({ run: (storeRef, dirtyRef, terminal, isTT
364
549
  const dirty = yield* Ref.getAndSet(dirtyRef, false);
365
550
  const store = yield* Ref.get(storeRef);
366
551
  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);
552
+ if (dirty || hasRunningSpinners(tasks)) yield* renderStore(store, yield* Clock.currentTimeMillis, isTTY ? stdio.stderr.columns : void 0);
368
553
  tick += 1;
369
554
  yield* Effect.sleep(RENDER_INTERVAL_MILLIS);
370
555
  }
371
556
  }).pipe(Effect.ensuring(Effect.gen(function* () {
372
- if (rendererActive) yield* renderStore(yield* Ref.get(storeRef), yield* Clock.currentTimeMillis, isTTY ? yield* terminal.stderrColumns : void 0);
557
+ if (rendererActive) yield* renderStore(yield* Ref.get(storeRef), yield* Clock.currentTimeMillis, isTTY ? stdio.stderr.columns : void 0);
373
558
  yield* Effect.sync(() => {
374
559
  instance?.unmount();
375
560
  });
@@ -380,37 +565,13 @@ var InkRenderer = class InkRenderer extends Context.Tag("stromseng.dev/effective
380
565
  };
381
566
 
382
567
  //#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
568
+ //#region src/stdio.ts
569
+ const defaultStdioService = {
570
+ stdout: process.stdout,
571
+ stderr: process.stderr
411
572
  };
412
- var ProgressTerminal = class ProgressTerminal extends Context.Tag("stromseng.dev/ProgressTerminal")() {
413
- static Default = Layer.succeed(ProgressTerminal, defaultTerminalService);
573
+ var ProgressStdio = class ProgressStdio extends Context.Tag("stromseng.dev/effective-progress/ProgressStdio")() {
574
+ static Default = Layer.succeed(ProgressStdio, defaultStdioService);
414
575
  };
415
576
 
416
577
  //#endregion
@@ -418,8 +579,11 @@ var ProgressTerminal = class ProgressTerminal extends Context.Tag("stromseng.dev
418
579
  const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
419
580
  const TaskId = Brand.nominal();
420
581
  const TaskStatusSchema = Schema.Literal("running", "done", "failed");
582
+ const TaskCountDisplaySchema = Schema.Literal("processedOnly", "detailed");
421
583
  var DeterminateTaskUnits = class extends Schema.TaggedClass()("DeterminateTaskUnits", {
422
- completed: Schema.Number,
584
+ succeeded: Schema.Number,
585
+ failed: Schema.Number,
586
+ processed: Schema.Number,
423
587
  total: Schema.Number
424
588
  }) {};
425
589
  var IndeterminateTaskUnits = class extends Schema.TaggedClass()("IndeterminateTaskUnits", { spinnerFrame: Schema.Number }) {};
@@ -429,6 +593,7 @@ var TaskSnapshot = class extends Schema.TaggedClass()("TaskSnapshot", {
429
593
  parentId: Schema.NullOr(TaskIdSchema),
430
594
  description: Schema.String,
431
595
  status: TaskStatusSchema,
596
+ countDisplay: TaskCountDisplaySchema,
432
597
  transient: Schema.Boolean,
433
598
  units: TaskUnitsSchema,
434
599
  startedAt: Schema.Number,
@@ -440,18 +605,23 @@ var TaskAddedEvent = class extends Schema.TaggedClass()("TaskAdded", {
440
605
  parentId: Schema.NullOr(TaskIdSchema),
441
606
  description: Schema.String,
442
607
  total: Schema.optional(Schema.Number),
443
- transient: Schema.Boolean
608
+ transient: Schema.Boolean,
609
+ countDisplay: TaskCountDisplaySchema
444
610
  }) {};
445
611
  var TaskUpdatedEvent = class extends Schema.TaggedClass()("TaskUpdated", {
446
612
  taskId: TaskIdSchema,
447
613
  description: Schema.optional(Schema.String),
448
- completed: Schema.optional(Schema.Number),
614
+ succeeded: Schema.optional(Schema.Number),
615
+ failed: Schema.optional(Schema.Number),
616
+ processed: Schema.optional(Schema.Number),
449
617
  total: Schema.optional(Schema.Number),
450
- transient: Schema.optional(Schema.Boolean)
618
+ transient: Schema.optional(Schema.Boolean),
619
+ countDisplay: Schema.optional(TaskCountDisplaySchema)
451
620
  }) {};
452
621
  var TaskAdvancedEvent = class extends Schema.TaggedClass()("TaskAdvanced", {
453
622
  taskId: TaskIdSchema,
454
- amount: Schema.Number
623
+ amount: Schema.Number,
624
+ kind: Schema.Literal("succeeded", "failed")
455
625
  }) {};
456
626
  var TaskCompletedEvent = class extends Schema.TaggedClass()("TaskCompleted", { taskId: TaskIdSchema }) {};
457
627
  var TaskFailedEvent = class extends Schema.TaggedClass()("TaskFailed", { taskId: TaskIdSchema }) {};
@@ -461,23 +631,37 @@ const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema
461
631
 
462
632
  //#endregion
463
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
+ });
464
650
  const updatedSnapshot = (snapshot, options) => {
465
651
  const currentUnits = snapshot.units;
466
652
  const units = (() => {
467
653
  if (options.total !== void 0) {
468
654
  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)
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
473
660
  });
474
661
  }
475
662
  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
- });
663
+ if (options.succeeded === void 0 && options.failed === void 0) return currentUnits;
664
+ return updateDeterminateCounts(currentUnits, options);
481
665
  }
482
666
  return currentUnits;
483
667
  })();
@@ -486,6 +670,7 @@ const updatedSnapshot = (snapshot, options) => {
486
670
  parentId: snapshot.parentId,
487
671
  description: options.description ?? snapshot.description,
488
672
  status: snapshot.status,
673
+ countDisplay: options.countDisplay ?? snapshot.countDisplay,
489
674
  transient: options.transient ?? snapshot.transient,
490
675
  units,
491
676
  startedAt: snapshot.startedAt,
@@ -497,6 +682,7 @@ const withTransient = (snapshot, transient) => new TaskSnapshot({
497
682
  parentId: snapshot.parentId,
498
683
  description: snapshot.description,
499
684
  status: snapshot.status,
685
+ countDisplay: snapshot.countDisplay,
500
686
  transient,
501
687
  units: snapshot.units,
502
688
  startedAt: snapshot.startedAt,
@@ -531,10 +717,10 @@ const removeFromRenderOrder = (renderOrder, taskId) => {
531
717
  return next;
532
718
  };
533
719
  const makeProgressService = Effect.gen(function* () {
534
- const terminal = yield* ProgressTerminal;
720
+ const stdio = yield* ProgressStdio;
535
721
  const inkRenderer = yield* InkRenderer;
536
722
  const outerConsole = yield* Effect.console;
537
- const isTTY = yield* terminal.isTTY;
723
+ const isTTY = Boolean(stdio.stderr.isTTY);
538
724
  const nextTaskIdRef = yield* Ref.make(0);
539
725
  const storeRef = yield* Ref.make({
540
726
  tasks: /* @__PURE__ */ new Map(),
@@ -545,25 +731,28 @@ const makeProgressService = Effect.gen(function* () {
545
731
  const scope = yield* Effect.scope;
546
732
  const markDirty = Ref.set(dirtyRef, true);
547
733
  const log = (...args) => args.length === 0 ? Effect.void : outerConsole.log(...args);
548
- yield* Effect.forkIn(inkRenderer.run(storeRef, dirtyRef, terminal, isTTY), scope);
734
+ yield* Effect.forkIn(inkRenderer.run(storeRef, dirtyRef, stdio, isTTY), scope);
549
735
  yield* Effect.sleep("0 millis");
550
736
  const addTask = (options) => Effect.gen(function* () {
551
737
  const resolvedParentId = options.parentId === void 0 ? yield* FiberRef.get(currentParentRef) : Option.some(options.parentId);
552
738
  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)
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
556
743
  });
557
744
  const store = yield* Ref.get(storeRef);
558
745
  const parentSnapshot = Option.isSome(resolvedParentId) ? store.tasks.get(resolvedParentId.value) : void 0;
559
746
  const now = yield* Clock.currentTimeMillis;
560
747
  const parentIdValue = Option.getOrNull(resolvedParentId);
748
+ const countDisplay = options.countDisplay ?? parentSnapshot?.countDisplay ?? "detailed";
561
749
  const snapshot = new TaskSnapshot({
562
750
  id: taskId,
563
751
  parentId: parentIdValue,
564
752
  description: options.description,
565
753
  status: "running",
566
- transient: parentSnapshot?.transient ?? options.transient ?? false,
754
+ countDisplay,
755
+ transient: (parentSnapshot?.transient ?? false) || (options.transient ?? false),
567
756
  units,
568
757
  startedAt: now,
569
758
  completedAt: null
@@ -612,8 +801,9 @@ const makeProgressService = Effect.gen(function* () {
612
801
  const advanceTask = (taskId, amount = 1) => Ref.update(storeRef, (store) => {
613
802
  const snapshot = store.tasks.get(taskId);
614
803
  if (!snapshot) return store;
615
- const units = snapshot.units._tag === "DeterminateTaskUnits" ? new DeterminateTaskUnits({
616
- completed: Math.min(snapshot.units.total, snapshot.units.completed + amount),
804
+ const units = snapshot.units._tag === "DeterminateTaskUnits" ? normalizeDeterminateCounts({
805
+ succeeded: snapshot.units.succeeded + amount,
806
+ failed: snapshot.units.failed,
617
807
  total: snapshot.units.total
618
808
  }) : new IndeterminateTaskUnits({ spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount) });
619
809
  const nextTasks = new Map(store.tasks);
@@ -622,6 +812,33 @@ const makeProgressService = Effect.gen(function* () {
622
812
  parentId: snapshot.parentId,
623
813
  description: snapshot.description,
624
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,
625
842
  transient: snapshot.transient,
626
843
  units,
627
844
  startedAt: snapshot.startedAt,
@@ -650,9 +867,11 @@ const makeProgressService = Effect.gen(function* () {
650
867
  parentId: snapshot.parentId,
651
868
  description: snapshot.description,
652
869
  status: "done",
870
+ countDisplay: snapshot.countDisplay,
653
871
  transient: snapshot.transient,
654
- units: snapshot.units._tag === "DeterminateTaskUnits" ? new DeterminateTaskUnits({
655
- completed: snapshot.units.total,
872
+ units: snapshot.units._tag === "DeterminateTaskUnits" ? normalizeDeterminateCounts({
873
+ succeeded: snapshot.units.total - snapshot.units.failed,
874
+ failed: snapshot.units.failed,
656
875
  total: snapshot.units.total
657
876
  }) : snapshot.units,
658
877
  startedAt: snapshot.startedAt,
@@ -683,6 +902,7 @@ const makeProgressService = Effect.gen(function* () {
683
902
  parentId: snapshot.parentId,
684
903
  description: snapshot.description,
685
904
  status: "failed",
905
+ countDisplay: snapshot.countDisplay,
686
906
  transient: snapshot.transient,
687
907
  units: snapshot.units,
688
908
  startedAt: snapshot.startedAt,
@@ -711,6 +931,7 @@ const makeProgressService = Effect.gen(function* () {
711
931
  addTask,
712
932
  updateTask,
713
933
  advanceTask,
934
+ advanceTaskFailed,
714
935
  completeTask,
715
936
  failTask,
716
937
  log,
@@ -732,11 +953,11 @@ const makeProgressService = Effect.gen(function* () {
732
953
  });
733
954
  var Progress = class Progress extends Context.Tag("stromseng.dev/effective-progress/Progress")() {
734
955
  static Default = Layer.unwrapEffect(Effect.gen(function* () {
735
- const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
956
+ const stdioOption = yield* Effect.serviceOption(ProgressStdio);
736
957
  const inkRendererOption = yield* Effect.serviceOption(InkRenderer);
737
958
  let layer = Layer.scoped(Progress, makeProgressService);
738
959
  if (Option.isNone(inkRendererOption)) layer = layer.pipe(Layer.provide(InkRenderer.Default));
739
- if (Option.isNone(terminalOption)) layer = layer.pipe(Layer.provide(ProgressTerminal.Default));
960
+ if (Option.isNone(stdioOption)) layer = layer.pipe(Layer.provide(ProgressStdio.Default));
740
961
  return layer;
741
962
  }));
742
963
  };
@@ -765,11 +986,29 @@ const task = dual(2, (effect, options) => {
765
986
  });
766
987
  const wrapEffects = (effects, tap) => Array.isArray(effects) ? effects.map(tap) : Object.fromEntries(Object.entries(effects).map(([k, effect]) => [k, tap(effect)]));
767
988
  const countEffects = (effects) => Array.isArray(effects) ? effects.length : Object.keys(effects).length;
989
+ const isCollectAllMode = (mode) => mode === "either" || mode === "validate";
990
+ const allCountDisplay = (mode) => isCollectAllMode(mode) ? "detailed" : "processedOnly";
991
+ const wrapTrackedEffect = (progress, taskId, effect) => Effect.gen(function* () {
992
+ const exit = yield* Effect.exit(effect);
993
+ if (Exit.isSuccess(exit)) {
994
+ yield* progress.advanceTask(taskId, 1);
995
+ return exit.value;
996
+ }
997
+ if (Cause.isInterruptedOnly(exit.cause)) return yield* Effect.failCause(exit.cause);
998
+ yield* progress.advanceTaskFailed(taskId, 1);
999
+ return yield* Effect.failCause(exit.cause);
1000
+ });
1001
+ const isTaskFullyProcessed = (progress, taskId) => Effect.gen(function* () {
1002
+ const taskOption = yield* progress.getTask(taskId);
1003
+ if (Option.isNone(taskOption) || taskOption.value.units._tag !== "DeterminateTaskUnits") return false;
1004
+ const { processed, total } = taskOption.value.units;
1005
+ return processed >= total;
1006
+ });
768
1007
  const all = dual(2, (effects, options) => provideProgress(Effect.gen(function* () {
769
1008
  const progress = yield* Progress;
770
1009
  return yield* progress.runTask(Effect.gen(function* () {
771
1010
  const taskId = yield* Task;
772
- const exit = yield* Effect.exit(Effect.all(wrapEffects(effects, (effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))), {
1011
+ const exit = yield* Effect.exit(Effect.all(wrapEffects(effects, (effect) => wrapTrackedEffect(progress, taskId, effect)), {
773
1012
  concurrency: options.concurrency,
774
1013
  batching: options.batching,
775
1014
  discard: options.discard,
@@ -777,6 +1016,8 @@ const all = dual(2, (effects, options) => provideProgress(Effect.gen(function* (
777
1016
  concurrentFinalizers: options.concurrentFinalizers
778
1017
  }));
779
1018
  if (Exit.isSuccess(exit)) yield* progress.completeTask(taskId);
1019
+ else if (!isCollectAllMode(options.mode)) yield* progress.failTask(taskId);
1020
+ else if (yield* isTaskFullyProcessed(progress, taskId)) yield* progress.completeTask(taskId);
780
1021
  else yield* progress.failTask(taskId);
781
1022
  return yield* Exit.match(exit, {
782
1023
  onFailure: Effect.failCause,
@@ -785,14 +1026,15 @@ const all = dual(2, (effects, options) => provideProgress(Effect.gen(function* (
785
1026
  }), {
786
1027
  description: options.description,
787
1028
  total: countEffects(effects),
788
- transient: options.transient
1029
+ transient: options.transient,
1030
+ countDisplay: allCountDisplay(options.mode)
789
1031
  });
790
1032
  })));
791
1033
  const forEach = dual(3, (iterable, f, options) => provideProgress(Effect.gen(function* () {
792
1034
  const progress = yield* Progress;
793
1035
  return yield* progress.runTask(Effect.gen(function* () {
794
1036
  const taskId = yield* Task;
795
- const exit = yield* Effect.exit(Effect.forEach(iterable, (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)), {
1037
+ const exit = yield* Effect.exit(Effect.forEach(iterable, (item, index) => wrapTrackedEffect(progress, taskId, f(item, index)), {
796
1038
  concurrency: options.concurrency,
797
1039
  batching: options.batching,
798
1040
  discard: options.discard,
@@ -807,9 +1049,10 @@ const forEach = dual(3, (iterable, f, options) => provideProgress(Effect.gen(fun
807
1049
  }), {
808
1050
  description: options.description,
809
1051
  total: options.total ?? inferTotal(iterable),
810
- transient: options.transient
1052
+ transient: options.transient,
1053
+ countDisplay: "processedOnly"
811
1054
  });
812
1055
  })));
813
1056
 
814
1057
  //#endregion
815
- export { DeterminateTaskUnits, IndeterminateTaskUnits, Progress, ProgressTaskEventSchema, ProgressTerminal, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskFailedEvent, TaskId, TaskRemovedEvent, TaskSnapshot, TaskStatusSchema, TaskUnitsSchema, TaskUpdatedEvent, all, decodeProgressTaskEvent, forEach, task };
1058
+ export { DeterminateTaskUnits, IndeterminateTaskUnits, Progress, ProgressStdio, ProgressTaskEventSchema, Task, TaskAddedEvent, TaskAdvancedEvent, TaskCompletedEvent, TaskCountDisplaySchema, TaskFailedEvent, TaskId, TaskRemovedEvent, TaskSnapshot, TaskStatusSchema, TaskUnitsSchema, TaskUpdatedEvent, all, decodeProgressTaskEvent, forEach, task };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Effect-first terminal progress bars with nested multibar support",
5
5
  "homepage": "https://github.com/stromseng/effective-progress#readme",
6
6
  "bugs": {
@@ -43,13 +43,13 @@
43
43
  "format:check": "oxfmt --check ."
44
44
  },
45
45
  "dependencies": {
46
- "@types/react": "^19.2.14",
47
46
  "ink": "^6.8.0",
48
47
  "react": "^19.2.4"
49
48
  },
50
49
  "devDependencies": {
51
50
  "@effect/language-service": "^0.73.1",
52
51
  "@types/bun": "latest",
52
+ "@types/react": "^19.2.14",
53
53
  "effect": "^3.19.17",
54
54
  "oxfmt": "^0.32.0",
55
55
  "oxlint": "^1.47.0",