effective-progress 0.4.2 → 0.5.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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  - multiple nested tree-like progress bars
17
17
  - spinner support for “we have no idea how long this takes” work
18
- - clean log rendering alongside progress output, so you can keep using `Console.log` / `Effect.logInfo` without wrecking the UI
18
+ - keep using `Console.log` / `Effect.logInfo` while progress rendering is active
19
19
  - familiar `.all` and `.forEach` APIs — swap `Effect` for `Progress`, get progress bars basically for free
20
20
  - flicker-free rendering (in theory) by drawing everything in a single terminal frame
21
21
 
@@ -82,111 +82,36 @@ Effect.runPromise(program);
82
82
  ### Other examples
83
83
 
84
84
  - `examples/simpleExample.ts` - low-boilerplate real-world flow
85
- - `examples/advancedExample.ts` - full API usage with custom config and manual task control
85
+ - `examples/advancedExample.ts` - full API usage and manual task control
86
86
  - `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
87
87
  - `examples/performance.ts` - stress-style run with high log volume and deeply nested progress updates
88
88
 
89
89
  ## Configuration
90
90
 
91
- ### Log retention
91
+ ### Console behavior
92
92
 
93
- - `maxLogLines` on `RendererConfig` controls in-memory log retention.
94
- - Omitted or set to `0` means no log history is kept in memory.
95
- - `maxLogLines > 0` keeps only the latest `N` log lines in memory.
93
+ - The Ink renderer runs with `patchConsole: true`, so console output is patched by Ink while the app is mounted.
94
+ - `Progress.task`, `Progress.all`, and `Progress.forEach` write through the currently provided Effect `Console` implementation.
95
+ - Formatting is controlled by the API consumer's logger/console implementation.
96
96
 
97
- ### Configuring renderer and progress bars
97
+ ### Ink renderer behavior
98
98
 
99
- Configure global renderer behavior once, and a global base progress bar style:
100
-
101
- Defaults:
102
-
103
- - columns: `DescriptionColumn`, `BarColumn`, `AmountColumn`, `ElapsedColumn`, `EtaColumn`
104
- - total progress width: `80`
105
- - bar width: `40`
106
-
107
- ```ts
108
- import { Effect } from "effect";
109
- import * as Progress from "effective-progress";
110
-
111
- const configured = program.pipe(
112
- Effect.provideService(Progress.RendererConfig, {
113
- width: 80,
114
- maxLogLines: 12,
115
- nonTtyUpdateStep: 2,
116
- }),
117
- Effect.provideService(Progress.ProgressBarConfig, {
118
- barWidth: 36,
119
- }),
120
- );
121
-
122
- Effect.runPromise(configured);
123
- ```
124
-
125
- You can customize column order/content Rich-style by providing a `columns` array:
126
-
127
- ```ts
128
- const configured = program.pipe(
129
- Effect.provideService(Progress.RendererConfig, {
130
- columns: [
131
- Progress.DescriptionColumn.Default(),
132
- Progress.BarColumn.make({ track: Progress.Track.fr(1) }),
133
- Progress.AmountColumn.Default(),
134
- "•",
135
- Progress.ElapsedColumn.Default(),
136
- "•",
137
- Progress.EtaColumn.Default(),
138
- ],
139
- }),
140
- );
141
- ```
142
-
143
- For full terminal width rendering, set:
144
-
145
- ```ts
146
- Effect.provideService(Progress.RendererConfig, {
147
- width: "fullwidth",
148
- });
149
- ```
150
-
151
- Description-specific caps should be configured on `DescriptionColumn` (for example `DescriptionColumn.make({ maxWidth: 40 })`) rather than globally.
152
-
153
- Per top-level call, you can override render config via helper APIs:
154
-
155
- ```ts
156
- const run = Progress.task(effect, {
157
- description: "work",
158
- render: {
159
- columns: [Progress.DescriptionColumn.Default(), "|", Progress.AmountColumn.Default()],
160
- },
161
- });
162
-
163
- const wrapped = Progress.withRenderConfig(run, {
164
- columns: [Progress.DescriptionColumn.Default()],
165
- });
166
- ```
167
-
168
- Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
169
-
170
- ```ts
171
- yield *
172
- progress.withTask(Effect.sleep("1 second"), {
173
- description: "Worker pipeline",
174
- progressbar: {
175
- barWidth: 20,
176
- spinnerFrames: [".", "o", "O", "0"],
177
- },
178
- });
179
- ```
99
+ - Rendering is powered by [Ink](https://github.com/vadimdemedes/ink).
100
+ - Built-in columns are: description, bar, amount/spinner, elapsed, and ETA.
101
+ - Column widths are shared per frame (widest visible cell wins), so rows stay aligned.
102
+ - Elapsed and ETA reserve stable widths to reduce jitter while tasks transition states.
103
+ - Layout uses a 100-column baseline and grows when content requires more space.
104
+ - On narrow terminals, layout compacts to fit available width and tree prefixes are suppressed when description space is too tight.
180
105
 
181
106
  ## Manual task control
182
107
 
183
- For manual usage, `task` captures logs implicitly and provides the current `Task` context:
108
+ For manual usage, `task` still provides the current `Task` context, while logs continue through your outer `Console`:
184
109
 
185
110
  ```ts
186
111
  const program = Progress.task(
187
112
  Effect.gen(function* () {
188
113
  const currentTask = yield* Progress.Task;
189
- yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
114
+ yield* Console.log("This log is handled by the outer Console", { taskId: currentTask });
190
115
  yield* Effect.sleep("1 second");
191
116
  }),
192
117
  { description: "Manual task" },
@@ -195,22 +120,7 @@ const program = Progress.task(
195
120
 
196
121
  ## Column customization
197
122
 
198
- Built-in columns are exported as classes with `Default()` and `make()` helpers.
199
- You can also pass your own objects/classes implementing `ProgressColumn`:
200
-
201
- ```ts
202
- const CustomColumn: Progress.ProgressColumn = {
203
- id: "custom",
204
- render: () => "extra",
205
- };
206
-
207
- const program = Progress.task(myEffect, {
208
- description: "Work",
209
- render: {
210
- columns: [Progress.DescriptionColumn.Default(), Progress.BarColumn.Default(), CustomColumn],
211
- },
212
- });
213
- ```
123
+ 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.
214
124
 
215
125
  ## Terminal service and mocking
216
126
 
@@ -241,10 +151,6 @@ const program = Progress.task(Effect.sleep("100 millis"), { description: "work"
241
151
  );
242
152
  ```
243
153
 
244
- ## Dependencies & package size
245
-
246
- This library is designed for CLI workflows, where package size is typically a lower-priority concern. Alongside `effect` though, I will strive to only rely on other high quality packages.
247
-
248
154
  ## Notes
249
155
 
250
156
  - 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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": {
@@ -19,6 +19,9 @@
19
19
  ],
20
20
  "type": "module",
21
21
  "module": "index.ts",
22
+ "engines": {
23
+ "node": ">=20.12.0"
24
+ },
22
25
  "publishConfig": {
23
26
  "access": "public"
24
27
  },
@@ -30,18 +33,21 @@
30
33
  "format:check": "oxfmt --check ."
31
34
  },
32
35
  "dependencies": {
33
- "chalk": "^5.6.2",
34
- "effect": "^3.19.17",
36
+ "@types/react": "^19.2.14",
35
37
  "es-toolkit": "^1.44.0",
38
+ "ink": "^6.8.0",
39
+ "react": "^19.2.4",
36
40
  "type-fest": "^5.4.4"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@effect/language-service": "^0.73.1",
40
44
  "@types/bun": "latest",
45
+ "effect": "^3.19.17",
41
46
  "oxfmt": "^0.32.0",
42
- "oxlint": "^1.47.0"
47
+ "oxlint": "^1.47.0",
48
+ "typescript": "^5"
43
49
  },
44
50
  "peerDependencies": {
45
- "typescript": "^5"
51
+ "effect": "^3.19.17"
46
52
  }
47
53
  }
package/src/api.ts CHANGED
@@ -1,49 +1,20 @@
1
1
  import { Effect, Exit, Option } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import type { Concurrency } from "effect/Types";
4
- import type { PartialDeep } from "type-fest";
5
4
  import { Progress } from "./runtime";
6
- import { RendererConfig, Task } from "./types";
7
- import type { AddTaskOptions, RendererConfigShape, TrackOptions } from "./types";
5
+ import { Task } from "./types";
6
+ import type { AddTaskOptions, TrackOptions } from "./types";
8
7
  import { inferTotal } from "./utils";
9
8
 
10
- interface RenderOverrideOptions {
11
- readonly render?: PartialDeep<RendererConfigShape>;
12
- }
13
-
14
- const provideProgress = <A, E, R>(
15
- effect: Effect.Effect<A, E, R>,
16
- renderConfig: PartialDeep<RendererConfigShape> | undefined,
17
- ) =>
9
+ const provideProgress = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
18
10
  Effect.gen(function* () {
19
11
  const existing = yield* Effect.serviceOption(Progress);
20
12
  if (Option.isSome(existing)) {
21
13
  return yield* Effect.provideService(effect, Progress, existing.value);
22
14
  }
23
- if (renderConfig === undefined) {
24
- return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
25
- }
26
-
27
- return yield* Effect.scoped(
28
- effect.pipe(
29
- Effect.provide(Progress.Default),
30
- Effect.provideService(RendererConfig, renderConfig),
31
- ),
32
- );
15
+ return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
33
16
  });
34
17
 
35
- export const withRenderConfig: {
36
- <A, E, R>(
37
- effect: Effect.Effect<A, E, R>,
38
- config: PartialDeep<RendererConfigShape>,
39
- ): Effect.Effect<A, E, Exclude<R, RendererConfig>>;
40
- (
41
- config: PartialDeep<RendererConfigShape>,
42
- ): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, RendererConfig>>;
43
- } = dual(2, <A, E, R>(effect: Effect.Effect<A, E, R>, config: PartialDeep<RendererConfigShape>) =>
44
- Effect.provideService(effect, RendererConfig, config),
45
- );
46
-
47
18
  export interface EffectExecutionOptions {
48
19
  readonly concurrency?: Concurrency;
49
20
  readonly batching?: boolean | "inherit";
@@ -55,9 +26,7 @@ export interface EffectAllExecutionOptions extends EffectExecutionOptions {
55
26
  readonly mode?: "default" | "validate" | "either";
56
27
  }
57
28
 
58
- export type AllOptions = Omit<TrackOptions, "total"> &
59
- EffectAllExecutionOptions &
60
- RenderOverrideOptions;
29
+ export type AllOptions = Omit<TrackOptions, "total"> & EffectAllExecutionOptions;
61
30
  export type AllReturn<
62
31
  Arg extends
63
32
  | ReadonlyArray<Effect.Effect<any, any, any>>
@@ -77,9 +46,9 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
77
46
  readonly discard?: false | undefined;
78
47
  }
79
48
 
80
- export type ForEachOptions = TrackOptions & ForEachExecutionOptions & RenderOverrideOptions;
49
+ export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
81
50
 
82
- export type TaskOptions = AddTaskOptions & RenderOverrideOptions;
51
+ export type TaskOptions = AddTaskOptions;
83
52
 
84
53
  export const task: {
85
54
  <A, E, R>(
@@ -90,14 +59,11 @@ export const task: {
90
59
  options: TaskOptions,
91
60
  ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Progress | Task>>;
92
61
  } = dual(2, <A, E, R>(effect: Effect.Effect<A, E, R>, options: TaskOptions) => {
93
- const { render, ...taskOptions } = options;
94
-
95
62
  return provideProgress(
96
63
  Effect.gen(function* () {
97
64
  const progress = yield* Progress;
98
- return yield* progress.withTask(effect, taskOptions);
65
+ return yield* progress.withTask(effect, options);
99
66
  }),
100
- render,
101
67
  ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>;
102
68
  });
103
69
 
@@ -119,14 +85,14 @@ const countEffects = (effects: AllArg): number =>
119
85
  export const all: {
120
86
  <const Arg extends AllArg, O extends EffectAllExecutionOptions>(
121
87
  effects: Arg,
122
- options: Omit<TrackOptions, "total"> & O & RenderOverrideOptions,
88
+ options: Omit<TrackOptions, "total"> & O,
123
89
  ): AllReturn<Arg, O>;
124
- <O extends EffectAllExecutionOptions & RenderOverrideOptions>(
90
+ <O extends EffectAllExecutionOptions>(
125
91
  options: Omit<TrackOptions, "total"> & O,
126
92
  ): <const Arg extends AllArg>(effects: Arg) => AllReturn<Arg, O>;
127
93
  } = dual(
128
94
  2,
129
- <const Arg extends AllArg, O extends EffectAllExecutionOptions & RenderOverrideOptions>(
95
+ <const Arg extends AllArg, O extends EffectAllExecutionOptions>(
130
96
  effects: Arg,
131
97
  options: Omit<TrackOptions, "total"> & O,
132
98
  ) =>
@@ -166,11 +132,9 @@ export const all: {
166
132
  description: options.description,
167
133
  total: countEffects(effects),
168
134
  transient: options.transient,
169
- progressbar: options.progressbar,
170
135
  },
171
136
  );
172
137
  }),
173
- options.render,
174
138
  ) as AllReturn<Arg, O>,
175
139
  );
176
140
 
@@ -226,10 +190,8 @@ export const forEach: {
226
190
  description: options.description,
227
191
  total: options.total ?? inferTotal(iterable),
228
192
  transient: options.transient,
229
- progressbar: options.progressbar,
230
193
  },
231
194
  );
232
195
  }),
233
- options.render,
234
196
  ) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>,
235
197
  );
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export * from "./api";
2
- export * from "./renderer";
3
2
  export { Progress } from "./runtime";
4
3
  export * from "./terminal";
5
4
  export * from "./types";
@@ -0,0 +1,31 @@
1
+ import { Box } from "ink";
2
+ import { computeSharedColumnWidths } from "./layout";
3
+ import { TaskRow } from "./task-row";
4
+ import type { TaskRowModel } from "./types";
5
+
6
+ export interface ProgressAppProps {
7
+ readonly rows: ReadonlyArray<TaskRowModel>;
8
+ readonly now: number;
9
+ readonly tick: number;
10
+ readonly isTTY: boolean;
11
+ readonly terminalColumns?: number;
12
+ }
13
+
14
+ export const ProgressApp = ({ rows, now, tick, isTTY, terminalColumns }: ProgressAppProps) => {
15
+ const widths = computeSharedColumnWidths(rows, now, tick, terminalColumns);
16
+
17
+ return (
18
+ <Box flexDirection="column">
19
+ {rows.map((row) => (
20
+ <TaskRow
21
+ key={row.task.id as number}
22
+ row={row}
23
+ now={now}
24
+ tick={tick}
25
+ isTTY={isTTY}
26
+ widths={widths}
27
+ />
28
+ ))}
29
+ </Box>
30
+ );
31
+ };
@@ -0,0 +1,17 @@
1
+ import { Text } from "ink";
2
+ import { formatAmount } from "../format";
3
+ import type { ColumnProps } from "./types";
4
+
5
+ export const AmountColumn = ({ task, tick }: ColumnProps) => {
6
+ const text = formatAmount(task, tick);
7
+ const color =
8
+ task.status === "failed"
9
+ ? "red"
10
+ : task.status === "done"
11
+ ? "green"
12
+ : task.units._tag === "DeterminateTaskUnits"
13
+ ? "whiteBright"
14
+ : "yellow";
15
+
16
+ return <Text color={color}>{text}</Text>;
17
+ };
@@ -0,0 +1,31 @@
1
+ import { Text } from "ink";
2
+ import type { ColumnProps } from "./types";
3
+
4
+ const clamp = (value: number, minimum: number, maximum: number): number =>
5
+ Math.min(Math.max(value, minimum), maximum);
6
+
7
+ export interface BarColumnProps extends ColumnProps {
8
+ readonly width: number;
9
+ }
10
+
11
+ export const BarColumn = ({ task, width }: BarColumnProps) => {
12
+ if (task.units._tag !== "DeterminateTaskUnits") {
13
+ return <Text />;
14
+ }
15
+
16
+ const barWidth = Math.max(1, Math.floor(width));
17
+ const safeTotal = Math.max(1, task.units.total);
18
+ const ratio = task.status === "done" ? 1 : clamp(task.units.completed / safeTotal, 0, 1);
19
+ const filled = Math.round(barWidth * ratio);
20
+ const empty = Math.max(0, barWidth - filled);
21
+ const bar = `${"━".repeat(filled)}${"─".repeat(empty)}`;
22
+
23
+ const color =
24
+ task.status === "failed" ? "red" : task.status === "done" ? "green" : "blue";
25
+
26
+ return (
27
+ <Text wrap="truncate-end" color={color}>
28
+ {bar}
29
+ </Text>
30
+ );
31
+ };
@@ -0,0 +1,7 @@
1
+ import { Text } from "ink";
2
+ import { renderTreePrefix } from "../tree";
3
+ import type { ColumnProps } from "./types";
4
+
5
+ export const DescriptionColumn = ({ task, tree, showTree }: ColumnProps) => (
6
+ <Text wrap="truncate-end">{`${showTree ? renderTreePrefix(tree) : ""}${task.description}`}</Text>
7
+ );
@@ -0,0 +1,7 @@
1
+ import { Text } from "ink";
2
+ import { formatElapsed } from "../format";
3
+ import type { ColumnProps } from "./types";
4
+
5
+ export const ElapsedColumn = ({ task, now }: ColumnProps) => (
6
+ <Text color="gray">{formatElapsed(task, now)}</Text>
7
+ );
@@ -0,0 +1,18 @@
1
+ import { Text } from "ink";
2
+ import { formatEta } from "../format";
3
+ import type { ColumnProps } from "./types";
4
+
5
+ export const EtaColumn = ({ task, now }: ColumnProps) => {
6
+ if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") {
7
+ return <Text />;
8
+ }
9
+
10
+ const eta = formatEta(task, now);
11
+ const text = eta.length > 0 ? `ETA: ${eta}` : "ETA: --";
12
+
13
+ return (
14
+ <Text wrap="truncate-end" color="gray">
15
+ {text}
16
+ </Text>
17
+ );
18
+ };
@@ -0,0 +1,6 @@
1
+ export { AmountColumn } from "./amount-column";
2
+ export { BarColumn } from "./bar-column";
3
+ export { DescriptionColumn } from "./description-column";
4
+ export { ElapsedColumn } from "./elapsed-column";
5
+ export { EtaColumn } from "./eta-column";
6
+ export type { ColumnProps } from "./types";
@@ -0,0 +1,11 @@
1
+ import type { TaskSnapshot } from "../../types";
2
+ import type { TaskTreeInfo } from "../types";
3
+
4
+ export interface ColumnProps {
5
+ readonly task: TaskSnapshot;
6
+ readonly tree: TaskTreeInfo;
7
+ readonly now: number;
8
+ readonly tick: number;
9
+ readonly isTTY: boolean;
10
+ readonly showTree: boolean;
11
+ }
@@ -0,0 +1,55 @@
1
+ import type { TaskSnapshot } from "../types";
2
+
3
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
4
+
5
+ export const formatDurationSeconds = (seconds: number): string => {
6
+ const value = Math.max(0, Math.floor(seconds));
7
+ if (value < 60) {
8
+ return `${value}s`;
9
+ }
10
+ if (value < 3600) {
11
+ const mins = Math.floor(value / 60);
12
+ const secs = value % 60;
13
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
14
+ }
15
+
16
+ const hours = Math.floor(value / 3600);
17
+ const mins = Math.floor((value % 3600) / 60);
18
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
19
+ };
20
+
21
+ export const formatElapsed = (task: TaskSnapshot, now: number): string => {
22
+ const elapsedMillis = Math.max(0, (task.completedAt ?? now) - task.startedAt);
23
+ return formatDurationSeconds(elapsedMillis / 1000);
24
+ };
25
+
26
+ export const formatEta = (task: TaskSnapshot, now: number): string => {
27
+ if (task.status !== "running" || task.units._tag !== "DeterminateTaskUnits") {
28
+ return "";
29
+ }
30
+
31
+ const { completed, total } = task.units;
32
+ const remaining = total - completed;
33
+ if (completed <= 0 || remaining <= 0) {
34
+ return "";
35
+ }
36
+
37
+ const elapsedMillis = Math.max(1, now - task.startedAt);
38
+ const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
39
+ return formatDurationSeconds(etaMillis / 1000);
40
+ };
41
+
42
+ export const formatAmount = (task: TaskSnapshot, tick: number): string => {
43
+ if (task.units._tag === "DeterminateTaskUnits") {
44
+ const totalText = `${task.units.total}`;
45
+ const completedText = `${task.units.completed}`.padStart(totalText.length, " ");
46
+ return `${completedText}/${totalText}`;
47
+ }
48
+
49
+ if (task.status === "running") {
50
+ const frameIndex = (task.units.spinnerFrame + tick) % SPINNER_FRAMES.length;
51
+ return SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0]!;
52
+ }
53
+
54
+ return task.status === "done" ? "✓" : "✗";
55
+ };
@@ -0,0 +1 @@
1
+ export { InkRenderer, type InkRendererService } from "./service";