effective-progress 0.1.2 → 0.1.3

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
@@ -1,11 +1,23 @@
1
1
  # effective-progress
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/effective-progress)](https://www.npmjs.com/package/effective-progress)
4
+
5
+ > [!WARNING]
6
+ > Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
7
+ > I recommend using only the `Progress.all` and `Progress.forEach` APIs for now, as they will likely change the least. The lower-level APIs for manual progress bar control are more likely to see breaking changes as I iterate on the design.
8
+ >
9
+ > Please open an issue or reach out if you have any questions or want to contribute!
10
+ > Feedback and contributions are very welcome!
11
+
12
+ <img alt="Showcase output" src="docs/images/showcase.gif" width="600" />
13
+
3
14
  `effective-progress` is an [Effect](https://effect.website/)-first terminal progress library with:
4
15
 
5
16
  - multiple progress bars
6
17
  - nested child progress bars
7
18
  - spinner support for indeterminate work
8
19
  - clean log rendering alongside progress output, allowing you to simply use Effects `Console.log` or `Effect.logInfo`.
20
+ - simple to use `.all` and `.forEach` APIs similar to the ones you already know and love from `effect`. Just swap `Effect` for `Progress` and get progress bars for free!
9
21
 
10
22
  ## Install
11
23
 
@@ -32,12 +44,7 @@ const program = Progress.all(
32
44
  Effect.runPromise(program);
33
45
  ```
34
46
 
35
- ```bash
36
- bun run examples/basic.ts
37
- Completed task 1
38
- Completed task 2
39
- - Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
40
- ```
47
+ <img alt="Basic example output" src="docs/images/basic.gif" width="600" />
41
48
 
42
49
  ## Nested example
43
50
 
@@ -68,25 +75,122 @@ const program = Progress.all(
68
75
  Effect.runPromise(program);
69
76
  ```
70
77
 
71
- ```bash
72
- ❯ bun run examples/nesting.ts
73
- - Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
74
- - Running subtasks for task 3: ━━━━━━━━────────────────────── 4/15 27%
75
- - Running subtasks for task 4: ━━━━━━━━────────────────────── 4/15 27%
76
- ```
78
+ <img alt="Nested example output" src="docs/images/nesting.gif" width="600" />
77
79
 
78
80
  ## Other examples
79
81
 
80
82
  - `examples/simpleExample.ts` - low-boilerplate real-world flow
81
83
  - `examples/advancedExample.ts` - full API usage with custom config and manual task control
84
+ - `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
82
85
 
83
86
  ## Log retention
84
87
 
85
- - `maxLogLines` controls in-memory log retention.
86
- - `maxLogLines` omitted or set to `0` means no log history is kept in memory.
88
+ - `maxLogLines` on `RendererConfig` controls in-memory log retention.
89
+ - Omitted or set to `0` means no log history is kept in memory.
87
90
  - `maxLogLines > 0` keeps only the latest `N` log lines in memory.
88
91
 
92
+ ## Configuring renderer and progress bars
93
+
94
+ Configure global renderer behavior once, and a global base progress bar style:
95
+
96
+ ```ts
97
+ import { Effect } from "effect";
98
+ import * as Progress from "effective-progress";
99
+
100
+ const configured = program.pipe(
101
+ Effect.provideService(Progress.RendererConfig, {
102
+ maxLogLines: 12,
103
+ nonTtyUpdateStep: 2,
104
+ }),
105
+ Effect.provideService(Progress.ProgressBarConfig, {
106
+ barWidth: 36,
107
+ colors: {
108
+ fill: { kind: "hex", value: "#00b894" },
109
+ spinner: { kind: "ansi256", value: 214 },
110
+ },
111
+ }),
112
+ );
113
+
114
+ Effect.runPromise(configured);
115
+ ```
116
+
117
+ Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
118
+
119
+ ```ts
120
+ yield *
121
+ progress.withTask(
122
+ {
123
+ description: "Worker pipeline",
124
+ progressbar: {
125
+ barWidth: 20,
126
+ spinnerFrames: [".", "o", "O", "0"],
127
+ colors: {
128
+ spinner: { kind: "named", value: "magentaBright" },
129
+ },
130
+ },
131
+ },
132
+ () => Effect.sleep("1 second"),
133
+ );
134
+ ```
135
+
136
+ For manual service usage, capture logs explicitly:
137
+
138
+ ```ts
139
+ const program = Progress.provide(
140
+ Effect.gen(function* () {
141
+ const progress = yield* Progress.Progress;
142
+
143
+ yield* progress.withTask({ description: "Manual task" }, () =>
144
+ progress.withCapturedLogs(
145
+ Effect.gen(function* () {
146
+ yield* Console.log("This log is rendered through progress output");
147
+ yield* Effect.sleep("1 second");
148
+ }),
149
+ ),
150
+ );
151
+ }),
152
+ );
153
+ ```
154
+
155
+ ## Progress bar colors
156
+
157
+ `progressbar.colors` is configured with typed color tokens that are validated by Effect Schema.
158
+
159
+ ```ts
160
+ progressbar: {
161
+ spinnerFrames: ["-", "\\", "|", "/"],
162
+ barWidth: 30,
163
+ fillChar: "━",
164
+ emptyChar: "─",
165
+ leftBracket: "",
166
+ rightBracket: "",
167
+ colors: {
168
+ fill: { kind: "named", value: "cyan" },
169
+ empty: { kind: "hex", value: "#9ca3af", modifiers: ["dim"] },
170
+ brackets: { kind: "rgb", value: { r: 156, g: 163, b: 175 } },
171
+ percent: { kind: "named", value: "whiteBright", modifiers: ["bold"] },
172
+ spinner: { kind: "ansi256", value: 214 },
173
+ done: { kind: "named", value: "greenBright" },
174
+ failed: { kind: "named", value: "redBright", modifiers: ["bold"] },
175
+ },
176
+ }
177
+ ```
178
+
179
+ Supported color styles:
180
+
181
+ - `named` (for example `cyan`, `greenBright`)
182
+ - `hex` (for example `#00b894`)
183
+ - `rgb` (for example `{ r: 0, g: 184, b: 148 }`)
184
+ - `ansi256` (for example `214`)
185
+
186
+ Supported modifiers:
187
+
188
+ - `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden`, `strikethrough`
189
+
190
+ ## Dependencies & package size
191
+
192
+ This library is designed for CLI workflows, where package size is typically a lower-priority concern. Alongside `effect`, it relies on two mature runtime dependencies: `chalk` and `es-toolkit`.
193
+
89
194
  ## Notes
90
195
 
91
- - This is a WIP library, so expect breaking changes. Feedback and contributions are very welcome!
92
196
  - 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.1.2",
3
+ "version": "0.1.3",
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": {
@@ -31,7 +31,9 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "chalk": "^5.6.2",
34
- "effect": "^3.19.16"
34
+ "effect": "^3.19.16",
35
+ "es-toolkit": "^1.44.0",
36
+ "type-fest": "^5.4.4"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@effect/language-service": "^0.73.1",
package/src/api.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { Console, Effect, Option } from "effect";
1
+ import { Effect, Option } from "effect";
2
2
  import type { Concurrency } from "effect/Types";
3
- import { makeProgressConsole } from "./console";
4
3
  import { makeProgressService } from "./runtime";
5
4
  import { Progress } from "./types";
6
5
  import type { TrackOptions } from "./types";
@@ -32,23 +31,17 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
32
31
 
33
32
  export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
34
33
 
35
- export const withProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
34
+ export const provide = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
36
35
  Effect.gen(function* () {
37
- const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
38
36
  const existing = yield* Effect.serviceOption(Progress);
39
37
  if (Option.isSome(existing)) {
40
- const console = makeProgressConsole(existing.value, outerConsole);
41
- return yield* Effect.withConsole(
42
- Effect.provideService(effect, Progress, existing.value),
43
- console,
44
- );
38
+ return yield* Effect.provideService(effect, Progress, existing.value);
45
39
  }
46
40
 
47
41
  return yield* Effect.scoped(
48
42
  Effect.gen(function* () {
49
43
  const service = yield* makeProgressService;
50
- const console = makeProgressConsole(service, outerConsole);
51
- return yield* Effect.withConsole(Effect.provideService(effect, Progress, service), console);
44
+ return yield* Effect.provideService(effect, Progress, service);
52
45
  }),
53
46
  );
54
47
  });
@@ -60,7 +53,7 @@ export const all = <
60
53
  effects: Arg,
61
54
  options: Omit<TrackOptions, "total"> & O,
62
55
  ): AllReturn<Arg, O> =>
63
- withProgressService(
56
+ provide(
64
57
  Effect.gen(function* () {
65
58
  const progress = yield* Progress;
66
59
  return yield* progress.withTask(
@@ -68,10 +61,13 @@ export const all = <
68
61
  description: options.description,
69
62
  total: effects.length,
70
63
  transient: options.transient,
64
+ progressbar: options.progressbar,
71
65
  },
72
66
  (taskId) =>
73
67
  Effect.all(
74
- effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
68
+ effects.map((effect) =>
69
+ Effect.tap(progress.withCapturedLogs(effect), () => progress.advanceTask(taskId, 1)),
70
+ ),
75
71
  {
76
72
  concurrency: options.concurrency,
77
73
  batching: options.batching,
@@ -89,7 +85,7 @@ export const forEach = <A, B, E, R>(
89
85
  f: (item: A, index: number) => Effect.Effect<B, E, R>,
90
86
  options: ForEachOptions,
91
87
  ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
92
- withProgressService(
88
+ provide(
93
89
  Effect.gen(function* () {
94
90
  const progress = yield* Progress;
95
91
 
@@ -98,11 +94,15 @@ export const forEach = <A, B, E, R>(
98
94
  description: options.description,
99
95
  total: options.total ?? inferTotal(iterable),
100
96
  transient: options.transient,
97
+ progressbar: options.progressbar,
101
98
  },
102
99
  (taskId) =>
103
100
  Effect.forEach(
104
101
  iterable,
105
- (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
102
+ (item, index) =>
103
+ Effect.tap(progress.withCapturedLogs(f(item, index)), () =>
104
+ progress.advanceTask(taskId, 1),
105
+ ),
106
106
  {
107
107
  concurrency: options.concurrency,
108
108
  batching: options.batching,
package/src/colors.ts ADDED
@@ -0,0 +1,206 @@
1
+ import chalk from "chalk";
2
+ import type { ChalkInstance } from "chalk";
3
+ import { Schema } from "effect";
4
+
5
+ export const StyleModifierSchema = Schema.Literal(
6
+ "bold",
7
+ "dim",
8
+ "italic",
9
+ "underline",
10
+ "inverse",
11
+ "hidden",
12
+ "strikethrough",
13
+ );
14
+ export type StyleModifier = typeof StyleModifierSchema.Type;
15
+
16
+ export const NamedColorSchema = Schema.Literal(
17
+ "black",
18
+ "red",
19
+ "green",
20
+ "yellow",
21
+ "blue",
22
+ "magenta",
23
+ "cyan",
24
+ "white",
25
+ "blackBright",
26
+ "redBright",
27
+ "greenBright",
28
+ "yellowBright",
29
+ "blueBright",
30
+ "magentaBright",
31
+ "cyanBright",
32
+ "whiteBright",
33
+ );
34
+ export type NamedColor = typeof NamedColorSchema.Type;
35
+
36
+ const HexColorSchema = Schema.String.pipe(Schema.pattern(/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/));
37
+ const ColorChannelSchema = Schema.Number.pipe(
38
+ Schema.int(),
39
+ Schema.greaterThanOrEqualTo(0),
40
+ Schema.lessThanOrEqualTo(255),
41
+ );
42
+ const Ansi256Schema = Schema.Number.pipe(
43
+ Schema.int(),
44
+ Schema.greaterThanOrEqualTo(0),
45
+ Schema.lessThanOrEqualTo(255),
46
+ );
47
+ const ModifiersSchema = Schema.optional(Schema.Array(StyleModifierSchema));
48
+
49
+ export const NamedColorStyleSchema = Schema.Struct({
50
+ kind: Schema.Literal("named"),
51
+ value: NamedColorSchema,
52
+ modifiers: ModifiersSchema,
53
+ });
54
+
55
+ export const HexColorStyleSchema = Schema.Struct({
56
+ kind: Schema.Literal("hex"),
57
+ value: HexColorSchema,
58
+ modifiers: ModifiersSchema,
59
+ });
60
+
61
+ export const RgbColorStyleSchema = Schema.Struct({
62
+ kind: Schema.Literal("rgb"),
63
+ value: Schema.Struct({
64
+ r: ColorChannelSchema,
65
+ g: ColorChannelSchema,
66
+ b: ColorChannelSchema,
67
+ }),
68
+ modifiers: ModifiersSchema,
69
+ });
70
+
71
+ export const Ansi256ColorStyleSchema = Schema.Struct({
72
+ kind: Schema.Literal("ansi256"),
73
+ value: Ansi256Schema,
74
+ modifiers: ModifiersSchema,
75
+ });
76
+
77
+ export const ColorStyleSchema = Schema.Union(
78
+ NamedColorStyleSchema,
79
+ HexColorStyleSchema,
80
+ RgbColorStyleSchema,
81
+ Ansi256ColorStyleSchema,
82
+ );
83
+ export type ColorStyle = typeof ColorStyleSchema.Type;
84
+
85
+ export const ProgressBarColorsSchema = Schema.Struct({
86
+ fill: ColorStyleSchema,
87
+ empty: ColorStyleSchema,
88
+ brackets: ColorStyleSchema,
89
+ percent: ColorStyleSchema,
90
+ spinner: ColorStyleSchema,
91
+ done: ColorStyleSchema,
92
+ failed: ColorStyleSchema,
93
+ });
94
+ export type ProgressBarColors = typeof ProgressBarColorsSchema.Type;
95
+
96
+ export const defaultProgressBarColors: ProgressBarColors = {
97
+ fill: { kind: "named", value: "cyan" },
98
+ empty: { kind: "named", value: "white", modifiers: ["dim"] },
99
+ brackets: { kind: "named", value: "white", modifiers: ["dim"] },
100
+ percent: { kind: "named", value: "white", modifiers: ["bold"] },
101
+ spinner: { kind: "named", value: "yellow" },
102
+ done: { kind: "named", value: "green" },
103
+ failed: { kind: "named", value: "red" },
104
+ };
105
+
106
+ const applyModifier = (instance: ChalkInstance, modifier: StyleModifier): ChalkInstance => {
107
+ switch (modifier) {
108
+ case "bold":
109
+ return instance.bold;
110
+ case "dim":
111
+ return instance.dim;
112
+ case "italic":
113
+ return instance.italic;
114
+ case "underline":
115
+ return instance.underline;
116
+ case "inverse":
117
+ return instance.inverse;
118
+ case "hidden":
119
+ return instance.hidden;
120
+ case "strikethrough":
121
+ return instance.strikethrough;
122
+ }
123
+ };
124
+
125
+ const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance => {
126
+ switch (color) {
127
+ case "black":
128
+ return instance.black;
129
+ case "red":
130
+ return instance.red;
131
+ case "green":
132
+ return instance.green;
133
+ case "yellow":
134
+ return instance.yellow;
135
+ case "blue":
136
+ return instance.blue;
137
+ case "magenta":
138
+ return instance.magenta;
139
+ case "cyan":
140
+ return instance.cyan;
141
+ case "white":
142
+ return instance.white;
143
+ case "blackBright":
144
+ return instance.blackBright;
145
+ case "redBright":
146
+ return instance.redBright;
147
+ case "greenBright":
148
+ return instance.greenBright;
149
+ case "yellowBright":
150
+ return instance.yellowBright;
151
+ case "blueBright":
152
+ return instance.blueBright;
153
+ case "magentaBright":
154
+ return instance.magentaBright;
155
+ case "cyanBright":
156
+ return instance.cyanBright;
157
+ case "whiteBright":
158
+ return instance.whiteBright;
159
+ }
160
+ };
161
+
162
+ const resolveBaseStyle = (style: ColorStyle): ChalkInstance => {
163
+ switch (style.kind) {
164
+ case "named":
165
+ return applyNamedColor(chalk, style.value);
166
+ case "hex":
167
+ return chalk.hex(style.value);
168
+ case "rgb":
169
+ return chalk.rgb(style.value.r, style.value.g, style.value.b);
170
+ case "ansi256":
171
+ return chalk.ansi256(style.value);
172
+ }
173
+ };
174
+
175
+ const applyModifiers = (instance: ChalkInstance, modifiers: ReadonlyArray<StyleModifier>) => {
176
+ let styled = instance;
177
+ for (const modifier of modifiers) {
178
+ styled = applyModifier(styled, modifier);
179
+ }
180
+ return styled;
181
+ };
182
+
183
+ export const applyColorStyle = (style: ColorStyle, text: string): string => {
184
+ const styled = applyModifiers(resolveBaseStyle(style), style.modifiers ?? []);
185
+ return styled(text);
186
+ };
187
+
188
+ export interface CompiledProgressBarColors {
189
+ readonly fill: (text: string) => string;
190
+ readonly empty: (text: string) => string;
191
+ readonly brackets: (text: string) => string;
192
+ readonly percent: (text: string) => string;
193
+ readonly spinner: (text: string) => string;
194
+ readonly done: (text: string) => string;
195
+ readonly failed: (text: string) => string;
196
+ }
197
+
198
+ export const compileProgressBarColors = (colors: ProgressBarColors): CompiledProgressBarColors => ({
199
+ fill: (text) => applyColorStyle(colors.fill, text),
200
+ empty: (text) => applyColorStyle(colors.empty, text),
201
+ brackets: (text) => applyColorStyle(colors.brackets, text),
202
+ percent: (text) => applyColorStyle(colors.percent, text),
203
+ spinner: (text) => applyColorStyle(colors.spinner, text),
204
+ done: (text) => applyColorStyle(colors.done, text),
205
+ failed: (text) => applyColorStyle(colors.failed, text),
206
+ });
package/src/console.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import { Console, Effect } from "effect";
2
2
  import { formatWithOptions } from "node:util";
3
- import type { ProgressService } from "./types";
4
3
 
5
4
  export const makeProgressConsole = (
6
- progress: ProgressService,
5
+ progressLog: (...args: ReadonlyArray<unknown>) => Effect.Effect<void, never, never>,
7
6
  outerConsole: Console.Console,
8
7
  ): Console.Console => {
9
- const log = (...args: ReadonlyArray<unknown>) => progress.log(...args);
8
+ const log = (...args: ReadonlyArray<unknown>) => progressLog(...args);
10
9
  const unsafeLog = (...args: ReadonlyArray<unknown>) => {
11
- Effect.runFork(progress.log(...args));
10
+ Effect.runFork(progressLog(...args));
12
11
  };
13
12
 
14
13
  const delegate = (effect: Effect.Effect<void, never, never>) => effect;
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./api";
2
+ export * from "./colors";
2
3
  export * from "./runtime";
3
4
  export * from "./types";
package/src/renderer.ts CHANGED
@@ -1,49 +1,59 @@
1
- import { Effect, Ref } from "effect";
2
- import chalk from "chalk";
3
- import type { ProgressBarConfigShape } from "./types";
1
+ import { Effect, Ref, Schema } from "effect";
2
+ import {
3
+ type CompiledProgressBarColors,
4
+ compileProgressBarColors,
5
+ ProgressBarColorsSchema,
6
+ } from "./colors";
7
+ import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
4
8
  import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
5
9
 
6
10
  const HIDE_CURSOR = "\x1b[?25l";
7
11
  const SHOW_CURSOR = "\x1b[?25h";
8
12
  const CLEAR_LINE = "\x1b[2K";
9
13
  const MOVE_UP_ONE = "\x1b[1A";
10
- const RENDER_INTERVAL = "80 millis";
14
+ const encodeProgressBarColorsKey = Schema.encodeSync(Schema.parseJson(ProgressBarColorsSchema));
11
15
 
12
- const renderDeterminate = (units: DeterminateTaskUnits, config: ProgressBarConfigShape): string => {
16
+ const renderDeterminate = (
17
+ units: DeterminateTaskUnits,
18
+ progressbar: ProgressBarConfigShape,
19
+ colors: CompiledProgressBarColors,
20
+ ): string => {
13
21
  const safeTotal = units.total <= 0 ? 1 : units.total;
14
22
  const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
15
- const filled = Math.round(ratio * config.barWidth);
16
- const bar = `${chalk.cyan(config.fillChar.repeat(filled))}${chalk.dim(config.emptyChar.repeat(config.barWidth - filled))}`;
23
+ const filled = Math.round(ratio * progressbar.barWidth);
24
+ const bar = `${colors.fill(progressbar.fillChar.repeat(filled))}${colors.empty(progressbar.emptyChar.repeat(progressbar.barWidth - filled))}`;
17
25
  const percent = String(Math.round(ratio * 100)).padStart(3, " ");
18
- return `${chalk.dim(config.leftBracket)}${bar}${chalk.dim(config.rightBracket)} ${units.completed}/${units.total} ${chalk.bold(percent + "%")}`;
26
+ return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
19
27
  };
20
28
 
21
29
  const buildTaskLine = (
22
30
  snapshot: TaskSnapshot,
23
31
  depth: number,
24
32
  tick: number,
25
- config: ProgressBarConfigShape,
33
+ colors: CompiledProgressBarColors,
26
34
  ): string => {
35
+ const progressbar = snapshot.progressbar;
27
36
  const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
28
37
 
29
38
  if (snapshot.status === "failed") {
30
- return `${prefix}${chalk.red("[failed]")}`;
39
+ return `${prefix}${colors.failed("[failed]")}`;
31
40
  }
32
41
 
33
42
  if (snapshot.status === "done") {
34
43
  if (snapshot.units._tag === "DeterminateTaskUnits") {
35
- return `${prefix}${chalk.green("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
44
+ return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
36
45
  }
37
- return `${prefix}${chalk.green("[done]")}`;
46
+ return `${prefix}${colors.done("[done]")}`;
38
47
  }
39
48
 
40
49
  if (snapshot.units._tag === "DeterminateTaskUnits") {
41
- return prefix + renderDeterminate(snapshot.units, config);
50
+ return prefix + renderDeterminate(snapshot.units, progressbar, colors);
42
51
  }
43
52
 
44
- const frames = config.spinnerFrames;
53
+ const frames = progressbar.spinnerFrames;
45
54
  const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
46
- return `${prefix}${chalk.yellow(frames[frameIndex])}`;
55
+ const frame = frames[frameIndex] ?? frames[0]!;
56
+ return `${prefix}${colors.spinner(frame)}`;
47
57
  };
48
58
 
49
59
  const orderTasksForRender = (
@@ -74,17 +84,32 @@ export const runProgressServiceRenderer = (
74
84
  logsRef: Ref.Ref<ReadonlyArray<string>>,
75
85
  pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
76
86
  dirtyRef: Ref.Ref<boolean>,
77
- config: ProgressBarConfigShape,
87
+ rendererConfig: RendererConfigShape,
78
88
  maxRetainedLogLines: number,
89
+ rendererLatch: Effect.Latch,
79
90
  ) => {
80
- const isTTY = config.isTTY;
91
+ const isTTY = rendererConfig.isTTY;
81
92
  const retainLogHistory = maxRetainedLogLines > 0;
93
+ const colorCache = new Map<string, CompiledProgressBarColors>();
82
94
  let previousLineCount = 0;
83
95
  let previousTaskLineCount = 0;
84
96
  let nonTTYTaskSignatureById = new Map<number, string>();
85
97
  let tick = 0;
98
+ let rendererActive = false;
86
99
  let teardownInput: (() => void) | undefined;
87
100
 
101
+ const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
102
+ const key = encodeProgressBarColorsKey(progressbar.colors);
103
+ const cached = colorCache.get(key);
104
+ if (cached) {
105
+ return cached;
106
+ }
107
+
108
+ const compiled = compileProgressBarColors(progressbar.colors);
109
+ colorCache.set(key, compiled);
110
+ return compiled;
111
+ };
112
+
88
113
  const clearTTYLines = (lineCount: number) => {
89
114
  if (lineCount <= 0) {
90
115
  return;
@@ -103,7 +128,7 @@ export const runProgressServiceRenderer = (
103
128
  ) => {
104
129
  const nextTaskSignatureById = new Map<number, string>();
105
130
  const changedTaskLines: Array<string> = [];
106
- const nonTtyUpdateStep = Math.max(1, Math.floor(config.nonTtyUpdateStep));
131
+ const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
107
132
 
108
133
  for (let i = 0; i < ordered.length; i++) {
109
134
  const taskId = ordered[i]!.snapshot.id as number;
@@ -137,7 +162,7 @@ export const runProgressServiceRenderer = (
137
162
  const frameTick = mode === "final" ? tick + 1 : tick;
138
163
  const taskLines = ordered.map(({ snapshot, depth }) => {
139
164
  const lineTick = isTTY ? frameTick : 0;
140
- return buildTaskLine(snapshot, depth, lineTick, config);
165
+ return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.progressbar));
141
166
  });
142
167
 
143
168
  if (isTTY) {
@@ -170,10 +195,13 @@ export const runProgressServiceRenderer = (
170
195
  });
171
196
 
172
197
  return Effect.gen(function* () {
198
+ yield* rendererLatch.await;
199
+ rendererActive = true;
200
+
173
201
  if (isTTY) {
174
202
  process.stderr.write(HIDE_CURSOR);
175
203
 
176
- if (config.disableUserInput && process.stdin.isTTY) {
204
+ if (rendererConfig.disableUserInput && process.stdin.isTTY) {
177
205
  const stdin = process.stdin;
178
206
  const wasRaw = Boolean(stdin.isRaw);
179
207
  stdin.resume();
@@ -214,11 +242,15 @@ export const runProgressServiceRenderer = (
214
242
  }
215
243
 
216
244
  tick += 1;
217
- yield* Effect.sleep(RENDER_INTERVAL);
245
+ yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
218
246
  }
219
247
  }).pipe(
220
248
  Effect.ensuring(
221
249
  Effect.gen(function* () {
250
+ if (!rendererActive) {
251
+ return;
252
+ }
253
+
222
254
  yield* renderFrame("final");
223
255
 
224
256
  if (isTTY) {
package/src/runtime.ts CHANGED
@@ -1,13 +1,20 @@
1
- import { Effect, Exit, FiberRef, Option, Ref } from "effect";
1
+ import { Console, Effect, Exit, FiberRef, Option, Ref } from "effect";
2
+ import { mergeWith } from "es-toolkit/object";
2
3
  import { formatWithOptions } from "node:util";
4
+ import type { PartialDeep } from "type-fest";
5
+ import { makeProgressConsole } from "./console";
3
6
  import { runProgressServiceRenderer } from "./renderer";
4
7
  import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
5
8
  import {
9
+ decodeProgressBarConfigSync,
10
+ decodeRendererConfigSync,
6
11
  defaultProgressBarConfig,
12
+ defaultRendererConfig,
7
13
  DeterminateTaskUnits,
8
14
  IndeterminateTaskUnits,
9
15
  Progress,
10
16
  ProgressBarConfig,
17
+ RendererConfig,
11
18
  TaskId,
12
19
  TaskSnapshot,
13
20
  } from "./types";
@@ -15,6 +22,21 @@ import { inferTotal } from "./utils";
15
22
 
16
23
  const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
17
24
 
25
+ const mergeConfig = <T extends Record<PropertyKey, any>>(
26
+ base: T,
27
+ override: PartialDeep<T> | undefined,
28
+ ): T =>
29
+ mergeWith(
30
+ structuredClone(base),
31
+ (override ?? {}) as Record<PropertyKey, any>,
32
+ (_targetValue, sourceValue) => {
33
+ if (Array.isArray(sourceValue)) {
34
+ return sourceValue;
35
+ }
36
+ return undefined;
37
+ },
38
+ ) as T;
39
+
18
40
  const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): TaskSnapshot => {
19
41
  const currentUnits = snapshot.units;
20
42
  const units = (() => {
@@ -54,13 +76,27 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
54
76
  status: snapshot.status,
55
77
  transient: options.transient ?? snapshot.transient,
56
78
  units,
79
+ progressbar: snapshot.progressbar,
57
80
  });
58
81
  };
59
82
 
60
83
  export const makeProgressService = Effect.gen(function* () {
61
- const configOption = yield* Effect.serviceOption(ProgressBarConfig);
62
- const config = Option.getOrElse(configOption, () => defaultProgressBarConfig);
63
- const maxRetainedLogLines = Math.max(0, Math.floor(config.maxLogLines ?? 0));
84
+ const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
85
+ const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
86
+
87
+ const rendererConfig = decodeRendererConfigSync(
88
+ mergeConfig(
89
+ defaultRendererConfig,
90
+ Option.isSome(rendererConfigOption) ? rendererConfigOption.value : undefined,
91
+ ),
92
+ );
93
+ const progressBarConfig = decodeProgressBarConfigSync(
94
+ mergeConfig(
95
+ defaultProgressBarConfig,
96
+ Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
97
+ ),
98
+ );
99
+ const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
64
100
 
65
101
  const nextTaskIdRef = yield* Ref.make(0);
66
102
  const tasksRef = yield* Ref.make(new Map<TaskId, TaskSnapshot>());
@@ -68,17 +104,22 @@ export const makeProgressService = Effect.gen(function* () {
68
104
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
69
105
  const dirtyRef = yield* Ref.make(true);
70
106
  const dirtyScheduledRef = yield* Ref.make(false);
107
+ const rendererStartedRef = yield* Ref.make(false);
108
+ const rendererLatch = yield* Effect.makeLatch(false);
71
109
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
110
+ const scope = yield* Effect.scope;
72
111
 
73
- yield* Effect.forkScoped(
112
+ yield* Effect.forkIn(
74
113
  runProgressServiceRenderer(
75
114
  tasksRef,
76
115
  logsRef,
77
116
  pendingLogsRef,
78
117
  dirtyRef,
79
- config,
118
+ rendererConfig,
80
119
  maxRetainedLogLines,
120
+ rendererLatch,
81
121
  ),
122
+ scope,
82
123
  );
83
124
 
84
125
  const markDirty = Effect.gen(function* () {
@@ -109,6 +150,19 @@ export const makeProgressService = Effect.gen(function* () {
109
150
  options.total === undefined || options.total <= 0
110
151
  ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
111
152
  : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
153
+ const tasks = yield* Ref.get(tasksRef);
154
+ const parentSnapshot =
155
+ Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
156
+ const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
157
+ const resolvedProgressBarConfig = decodeProgressBarConfigSync(
158
+ mergeConfig(inheritedProgressBarConfig, options.progressbar),
159
+ );
160
+ const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
161
+ started ? [false, true] : [true, true],
162
+ );
163
+ if (shouldOpenRenderer) {
164
+ yield* rendererLatch.open;
165
+ }
112
166
 
113
167
  const snapshot = new TaskSnapshot({
114
168
  id: taskId,
@@ -117,6 +171,7 @@ export const makeProgressService = Effect.gen(function* () {
117
171
  status: "running",
118
172
  transient: options.transient ?? false,
119
173
  units,
174
+ progressbar: resolvedProgressBarConfig,
120
175
  });
121
176
 
122
177
  yield* Ref.update(tasksRef, (tasks) => {
@@ -156,8 +211,7 @@ export const makeProgressService = Effect.gen(function* () {
156
211
  total: snapshot.units.total,
157
212
  })
158
213
  : new IndeterminateTaskUnits({
159
- spinnerFrame:
160
- (snapshot.units.spinnerFrame + amount) % Math.max(1, config.spinnerFrames.length),
214
+ spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount),
161
215
  });
162
216
 
163
217
  next.set(
@@ -169,6 +223,7 @@ export const makeProgressService = Effect.gen(function* () {
169
223
  status: snapshot.status,
170
224
  transient: snapshot.transient,
171
225
  units,
226
+ progressbar: snapshot.progressbar,
172
227
  }),
173
228
  );
174
229
 
@@ -203,6 +258,7 @@ export const makeProgressService = Effect.gen(function* () {
203
258
  total: snapshot.units.total,
204
259
  })
205
260
  : snapshot.units,
261
+ progressbar: snapshot.progressbar,
206
262
  }),
207
263
  );
208
264
  return next;
@@ -230,6 +286,7 @@ export const makeProgressService = Effect.gen(function* () {
230
286
  status: "failed",
231
287
  transient: snapshot.transient,
232
288
  units: snapshot.units,
289
+ progressbar: snapshot.progressbar,
233
290
  }),
234
291
  );
235
292
  return next;
@@ -241,9 +298,16 @@ export const makeProgressService = Effect.gen(function* () {
241
298
  return;
242
299
  }
243
300
 
301
+ const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
302
+ started ? [false, true] : [true, true],
303
+ );
304
+ if (shouldOpenRenderer) {
305
+ yield* rendererLatch.open;
306
+ }
307
+
244
308
  const message = formatWithOptions(
245
309
  {
246
- colors: config.isTTY,
310
+ colors: rendererConfig.isTTY,
247
311
  depth: 6,
248
312
  },
249
313
  ...args,
@@ -263,6 +327,12 @@ export const makeProgressService = Effect.gen(function* () {
263
327
  yield* markDirty;
264
328
  });
265
329
 
330
+ const withCapturedLogs: ProgressService["withCapturedLogs"] = (effect) =>
331
+ Effect.gen(function* () {
332
+ const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
333
+ return yield* Effect.withConsole(effect, makeProgressConsole(log, outerConsole));
334
+ });
335
+
266
336
  const getTask = (taskId: TaskId) =>
267
337
  Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
268
338
 
@@ -302,6 +372,7 @@ export const makeProgressService = Effect.gen(function* () {
302
372
  description: options.description,
303
373
  total: options.total ?? inferTotal(iterable),
304
374
  transient: options.transient,
375
+ progressbar: options.progressbar,
305
376
  },
306
377
  (taskId) => {
307
378
  return Effect.forEach(iterable, (item, index) =>
@@ -310,16 +381,19 @@ export const makeProgressService = Effect.gen(function* () {
310
381
  },
311
382
  );
312
383
 
313
- return Progress.of({
384
+ const service: ProgressService = {
314
385
  addTask,
315
386
  updateTask,
316
387
  advanceTask,
317
388
  completeTask,
318
389
  failTask,
319
390
  log,
391
+ withCapturedLogs,
320
392
  getTask,
321
393
  listTasks,
322
394
  withTask,
323
395
  trackIterable,
324
- });
396
+ };
397
+
398
+ return Progress.of(service);
325
399
  });
package/src/types.ts CHANGED
@@ -1,39 +1,55 @@
1
1
  import { Brand, Context, Effect, Option, Schema } from "effect";
2
+ import type { PartialDeep } from "type-fest";
3
+ import { defaultProgressBarColors, ProgressBarColorsSchema } from "./colors";
2
4
 
3
- export const SPINNER_FRAMES = ["-", "\\", "|", "/"] as const;
4
- export const DEFAULT_PROGRESS_BAR_WIDTH = 30;
5
-
6
- export const ProgressBarConfigSchema = Schema.Struct({
5
+ export const RendererConfigSchema = Schema.Struct({
7
6
  isTTY: Schema.Boolean,
8
7
  disableUserInput: Schema.Boolean,
8
+ renderIntervalMillis: Schema.Number,
9
+ maxLogLines: Schema.optional(Schema.Number),
10
+ nonTtyUpdateStep: Schema.Number,
11
+ });
12
+ export type RendererConfigShape = typeof RendererConfigSchema.Type;
13
+ export const decodeRendererConfigSync = Schema.decodeUnknownSync(RendererConfigSchema);
14
+
15
+ export const ProgressBarConfigSchema = Schema.Struct({
9
16
  spinnerFrames: Schema.NonEmptyArray(Schema.String),
10
17
  barWidth: Schema.Number,
11
18
  fillChar: Schema.String,
12
19
  emptyChar: Schema.String,
13
20
  leftBracket: Schema.String,
14
21
  rightBracket: Schema.String,
15
- maxLogLines: Schema.optional(Schema.Number),
16
- nonTtyUpdateStep: Schema.Number,
22
+ colors: ProgressBarColorsSchema,
17
23
  });
18
-
19
24
  export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
25
+ export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarConfigSchema);
20
26
 
21
- export const defaultProgressBarConfig: ProgressBarConfigShape = {
27
+ export const defaultRendererConfig: RendererConfigShape = {
22
28
  isTTY: Boolean(process.stderr.isTTY),
23
29
  disableUserInput: true,
24
- spinnerFrames: SPINNER_FRAMES,
25
- barWidth: DEFAULT_PROGRESS_BAR_WIDTH,
30
+ renderIntervalMillis: 50, // 20 FPS
31
+ maxLogLines: 0,
32
+ nonTtyUpdateStep: 5,
33
+ };
34
+
35
+ export const defaultProgressBarConfig: ProgressBarConfigShape = {
36
+ spinnerFrames: ["-", "\\", "|", "/"],
37
+ barWidth: 30,
26
38
  fillChar: "━",
27
39
  emptyChar: "─",
28
40
  leftBracket: "",
29
41
  rightBracket: "",
30
- maxLogLines: 0,
31
- nonTtyUpdateStep: 5,
42
+ colors: defaultProgressBarColors,
32
43
  };
33
44
 
45
+ export class RendererConfig extends Context.Tag("stromseng.dev/RendererConfig")<
46
+ RendererConfig,
47
+ PartialDeep<RendererConfigShape>
48
+ >() {}
49
+
34
50
  export class ProgressBarConfig extends Context.Tag("stromseng.dev/ProgressBarConfig")<
35
51
  ProgressBarConfig,
36
- ProgressBarConfigShape
52
+ PartialDeep<ProgressBarConfigShape>
37
53
  >() {}
38
54
 
39
55
  const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
@@ -50,6 +66,7 @@ export interface AddTaskOptions {
50
66
  readonly total?: number;
51
67
  readonly transient?: boolean;
52
68
  readonly parentId?: TaskId;
69
+ readonly progressbar?: PartialDeep<ProgressBarConfigShape>;
53
70
  }
54
71
 
55
72
  export interface UpdateTaskOptions {
@@ -87,6 +104,7 @@ export class TaskSnapshot extends Schema.TaggedClass<TaskSnapshot>()("TaskSnapsh
87
104
  status: TaskStatusSchema,
88
105
  transient: Schema.Boolean,
89
106
  units: TaskUnitsSchema,
107
+ progressbar: ProgressBarConfigSchema,
90
108
  }) {}
91
109
 
92
110
  export interface ProgressService {
@@ -96,6 +114,7 @@ export interface ProgressService {
96
114
  readonly completeTask: (taskId: TaskId) => Effect.Effect<void>;
97
115
  readonly failTask: (taskId: TaskId) => Effect.Effect<void>;
98
116
  readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void>;
117
+ readonly withCapturedLogs: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
99
118
  readonly getTask: (taskId: TaskId) => Effect.Effect<Option.Option<TaskSnapshot>>;
100
119
  readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
101
120
  readonly withTask: <A, E, R>(