effective-progress 0.2.4 → 0.3.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
@@ -105,10 +105,6 @@ const configured = program.pipe(
105
105
  }),
106
106
  Effect.provideService(Progress.ProgressBarConfig, {
107
107
  barWidth: 36,
108
- colors: {
109
- fill: { kind: "hex", value: "#00b894" },
110
- spinner: { kind: "ansi256", value: 214 },
111
- },
112
108
  }),
113
109
  );
114
110
 
@@ -124,9 +120,6 @@ yield *
124
120
  progressbar: {
125
121
  barWidth: 20,
126
122
  spinnerFrames: [".", "o", "O", "0"],
127
- colors: {
128
- spinner: { kind: "named", value: "magentaBright" },
129
- },
130
123
  },
131
124
  });
132
125
  ```
@@ -177,38 +170,64 @@ const program = Progress.task(
177
170
 
178
171
  ## Progress bar colors
179
172
 
180
- `progressbar.colors` is configured with typed color tokens that are validated by Effect Schema.
173
+ Colors are configured through the `Colorizer` service. You can set a global colorizer, or provide per-task overrides using `Effect.provideService`.
174
+
175
+ ### Global colorizer
176
+
177
+ Provide a custom `Colorizer` at the top level to change colors for all tasks:
181
178
 
182
179
  ```ts
183
- progressbar: {
184
- spinnerFrames: ["-", "\\", "|", "/"],
185
- barWidth: 30,
186
- fillChar: "━",
187
- emptyChar: "",
188
- leftBracket: "",
189
- rightBracket: "",
190
- colors: {
191
- fill: { kind: "named", value: "cyan" },
192
- empty: { kind: "hex", value: "#9ca3af", modifiers: ["dim"] },
193
- brackets: { kind: "rgb", value: { r: 156, g: 163, b: 175 } },
194
- percent: { kind: "named", value: "whiteBright", modifiers: ["bold"] },
195
- spinner: { kind: "ansi256", value: 214 },
196
- done: { kind: "named", value: "greenBright" },
197
- failed: { kind: "named", value: "redBright", modifiers: ["bold"] },
198
- },
199
- }
180
+ import chalk from "chalk";
181
+ import { Effect } from "effect";
182
+ import * as Progress from "effective-progress";
183
+
184
+ const program = Progress.task(myEffect, { description: "Work" }).pipe(
185
+ Effect.provideService(
186
+ Progress.Colorizer,
187
+ Progress.Colorizer.of({
188
+ fill: chalk.hex("#00b894"),
189
+ empty: chalk.white.dim,
190
+ brackets: chalk.rgb(180, 190, 210),
191
+ percent: chalk.whiteBright.bold,
192
+ spinner: chalk.ansi256(214),
193
+ done: chalk.greenBright,
194
+ failed: chalk.redBright.bold,
195
+ }),
196
+ ),
197
+ );
200
198
  ```
201
199
 
202
- Supported color styles:
200
+ ### Per-task colorizer
201
+
202
+ Wrap any effect with `Effect.provideService(Colorizer, ...)` to override colors for that task (and its children). The colorizer is captured at task-creation time, so each task can have its own colors:
203
203
 
204
- - `named` (for example `cyan`, `greenBright`)
205
- - `hex` (for example `#00b894`)
206
- - `rgb` (for example `{ r: 0, g: 184, b: 148 }`)
207
- - `ansi256` (for example `214`)
204
+ ```ts
205
+ Progress.forEach(
206
+ ["fetch", "transform", "persist"],
207
+ (stage) => Effect.gen(function* () {
208
+ yield* Effect.sleep("500 millis");
209
+ return stage;
210
+ }),
211
+ { description: "Worker pipeline" },
212
+ ).pipe(
213
+ Effect.provideService(
214
+ Progress.Colorizer,
215
+ Progress.Colorizer.of({
216
+ fill: chalk.red,
217
+ empty: chalk.white.dim,
218
+ brackets: chalk.white.dim,
219
+ percent: chalk.white.bold,
220
+ spinner: chalk.magentaBright,
221
+ done: chalk.greenBright,
222
+ failed: chalk.redBright,
223
+ }),
224
+ ),
225
+ );
226
+ ```
208
227
 
209
- Supported modifiers:
228
+ Tasks inherit the colorizer from their parent task.
210
229
 
211
- - `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden`, `strikethrough`
230
+ Each `ColorizerService` slot (`fill`, `empty`, `brackets`, `percent`, `spinner`, `done`, `failed`) is a `(text: string) => string` function. Use any chalk style — named colors, hex, rgb, ansi256, and modifiers like `.bold` or `.dim` all work.
212
231
 
213
232
  ## Dependencies & package size
214
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.2.4",
3
+ "version": "0.3.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": {
package/src/api.ts CHANGED
@@ -1,11 +1,20 @@
1
- import { Effect, Exit } from "effect";
1
+ import { Effect, Exit, Option } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import type { Concurrency } from "effect/Types";
4
- import { Progress, provideProgressService } from "./runtime";
4
+ import { Progress } from "./runtime";
5
5
  import { Task } from "./types";
6
6
  import type { AddTaskOptions, TrackOptions } from "./types";
7
7
  import { inferTotal } from "./utils";
8
8
 
9
+ const provideProgress = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
10
+ Effect.gen(function* () {
11
+ const existing = yield* Effect.serviceOption(Progress);
12
+ if (Option.isSome(existing)) {
13
+ return yield* Effect.provideService(effect, Progress, existing.value);
14
+ }
15
+ return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
16
+ });
17
+
9
18
  export interface EffectExecutionOptions {
10
19
  readonly concurrency?: Concurrency;
11
20
  readonly batching?: boolean | "inherit";
@@ -19,7 +28,9 @@ export interface EffectAllExecutionOptions extends EffectExecutionOptions {
19
28
 
20
29
  export type AllOptions = Omit<TrackOptions, "total"> & EffectAllExecutionOptions;
21
30
  export type AllReturn<
22
- Arg extends ReadonlyArray<Effect.Effect<any, any, any>> | Record<string, Effect.Effect<any, any, any>>,
31
+ Arg extends
32
+ | ReadonlyArray<Effect.Effect<any, any, any>>
33
+ | Record<string, Effect.Effect<any, any, any>>,
23
34
  O extends EffectAllExecutionOptions,
24
35
  > = [
25
36
  [Arg] extends [ReadonlyArray<Effect.Effect<any, any, any>>]
@@ -48,7 +59,7 @@ export const task: {
48
59
  } = dual(
49
60
  2,
50
61
  <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
51
- provideProgressService(
62
+ provideProgress(
52
63
  Effect.gen(function* () {
53
64
  const progress = yield* Progress;
54
65
  return yield* progress.withTask(effect, options);
@@ -56,7 +67,9 @@ export const task: {
56
67
  ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>,
57
68
  );
58
69
 
59
- type AllArg = ReadonlyArray<Effect.Effect<any, any, any>> | Record<string, Effect.Effect<any, any, any>>;
70
+ type AllArg =
71
+ | ReadonlyArray<Effect.Effect<any, any, any>>
72
+ | Record<string, Effect.Effect<any, any, any>>;
60
73
 
61
74
  const wrapEffects = (
62
75
  effects: AllArg,
@@ -83,7 +96,7 @@ export const all: {
83
96
  effects: Arg,
84
97
  options: Omit<TrackOptions, "total"> & O,
85
98
  ) =>
86
- provideProgressService(
99
+ provideProgress(
87
100
  Effect.gen(function* () {
88
101
  const progress = yield* Progress;
89
102
  return yield* progress.runTask(
@@ -143,7 +156,7 @@ export const forEach: {
143
156
  f: (item: A, index: number) => Effect.Effect<B, E, R>,
144
157
  options: ForEachOptions,
145
158
  ) =>
146
- provideProgressService(
159
+ provideProgress(
147
160
  Effect.gen(function* () {
148
161
  const progress = yield* Progress;
149
162
 
package/src/colors.ts CHANGED
@@ -1,141 +1,7 @@
1
1
  import chalk from "chalk";
2
- import type { ChalkInstance } from "chalk";
3
- import { Schema } from "effect";
2
+ import { Context, Layer } from "effect";
4
3
 
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
- instance[modifier];
108
-
109
- const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance =>
110
- instance[color];
111
-
112
- const resolveBaseStyle = (style: ColorStyle): ChalkInstance => {
113
- switch (style.kind) {
114
- case "named":
115
- return applyNamedColor(chalk, style.value);
116
- case "hex":
117
- return chalk.hex(style.value);
118
- case "rgb":
119
- return chalk.rgb(style.value.r, style.value.g, style.value.b);
120
- case "ansi256":
121
- return chalk.ansi256(style.value);
122
- }
123
- };
124
-
125
- const applyModifiers = (instance: ChalkInstance, modifiers: ReadonlyArray<StyleModifier>) => {
126
- let styled = instance;
127
- for (const modifier of modifiers) {
128
- styled = applyModifier(styled, modifier);
129
- }
130
- return styled;
131
- };
132
-
133
- export const applyColorStyle = (style: ColorStyle, text: string): string => {
134
- const styled = applyModifiers(resolveBaseStyle(style), style.modifiers ?? []);
135
- return styled(text);
136
- };
137
-
138
- export interface CompiledProgressBarColors {
4
+ export interface ColorizerService {
139
5
  readonly fill: (text: string) => string;
140
6
  readonly empty: (text: string) => string;
141
7
  readonly brackets: (text: string) => string;
@@ -145,12 +11,20 @@ export interface CompiledProgressBarColors {
145
11
  readonly failed: (text: string) => string;
146
12
  }
147
13
 
148
- export const compileProgressBarColors = (colors: ProgressBarColors): CompiledProgressBarColors => ({
149
- fill: (text) => applyColorStyle(colors.fill, text),
150
- empty: (text) => applyColorStyle(colors.empty, text),
151
- brackets: (text) => applyColorStyle(colors.brackets, text),
152
- percent: (text) => applyColorStyle(colors.percent, text),
153
- spinner: (text) => applyColorStyle(colors.spinner, text),
154
- done: (text) => applyColorStyle(colors.done, text),
155
- failed: (text) => applyColorStyle(colors.failed, text),
156
- });
14
+ export class Colorizer extends Context.Tag("stromseng.dev/effective-progress/Colorizer")<
15
+ Colorizer,
16
+ ColorizerService
17
+ >() {
18
+ static readonly Default = Layer.succeed(
19
+ Colorizer,
20
+ Colorizer.of({
21
+ fill: chalk.blue,
22
+ empty: chalk.white.dim,
23
+ brackets: chalk.white.dim,
24
+ percent: chalk.white.bold,
25
+ spinner: chalk.yellow,
26
+ done: chalk.green,
27
+ failed: chalk.red,
28
+ }),
29
+ );
30
+ }
package/src/renderer.ts CHANGED
@@ -1,9 +1,5 @@
1
- import { Clock, Duration, Effect, Ref, Schema } from "effect";
2
- import {
3
- type CompiledProgressBarColors,
4
- compileProgressBarColors,
5
- ProgressBarColorsSchema,
6
- } from "./colors";
1
+ import { Clock, Duration, Effect, Ref } from "effect";
2
+ import { Colorizer, type ColorizerService } from "./colors";
7
3
  import type { ProgressTerminalService } from "./terminal";
8
4
  import type { ProgressBarConfigShape, RendererConfigShape, TaskStore } from "./types";
9
5
  import { DeterminateTaskUnits, TaskSnapshot } from "./types";
@@ -12,12 +8,11 @@ const HIDE_CURSOR = "\x1b[?25l";
12
8
  const SHOW_CURSOR = "\x1b[?25h";
13
9
  const CLEAR_LINE = "\x1b[2K";
14
10
  const MOVE_UP_ONE = "\x1b[1A";
15
- const encodeProgressBarColorsKey = Schema.encodeSync(Schema.parseJson(ProgressBarColorsSchema));
16
11
 
17
12
  const renderDeterminate = (
18
13
  units: DeterminateTaskUnits,
19
14
  progressbar: ProgressBarConfigShape,
20
- colors: CompiledProgressBarColors,
15
+ colors: ColorizerService,
21
16
  ): string => {
22
17
  const safeTotal = units.total <= 0 ? 1 : units.total;
23
18
  const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
@@ -40,7 +35,7 @@ const buildTaskLine = (
40
35
  snapshot: TaskSnapshot,
41
36
  depth: number,
42
37
  tick: number,
43
- colors: CompiledProgressBarColors,
38
+ colors: ColorizerService,
44
39
  now: number,
45
40
  ): string => {
46
41
  const progressbar = snapshot.config;
@@ -77,210 +72,200 @@ export const runProgressServiceRenderer = (
77
72
  isTTY: boolean,
78
73
  rendererConfig: RendererConfigShape,
79
74
  maxRetainedLogLines: number,
80
- ) => {
81
- const retainLogHistory = maxRetainedLogLines > 0;
82
- const colorCache = new Map<string, CompiledProgressBarColors>();
83
- let previousLineCount = 0;
84
- let nonTTYTaskSignatureById = new Map<number, string>();
85
- let tick = 0;
86
- let rendererActive = false;
87
- let sessionActive = false;
88
-
89
- const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
90
- const key = encodeProgressBarColorsKey(progressbar.colors);
91
- const cached = colorCache.get(key);
92
- if (cached) {
93
- return cached;
94
- }
75
+ ) =>
76
+ Effect.gen(function* () {
77
+ const colorizer = yield* Colorizer;
78
+ const retainLogHistory = maxRetainedLogLines > 0;
79
+ let previousLineCount = 0;
80
+ let nonTTYTaskSignatureById = new Map<number, string>();
81
+ let tick = 0;
82
+ let rendererActive = false;
83
+ let sessionActive = false;
84
+
85
+ const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
86
+ Effect.gen(function* () {
87
+ const terminalRows = yield* terminal.stderrRows;
88
+ if (terminalRows === undefined) {
89
+ return lines;
90
+ }
95
91
 
96
- const compiled = compileProgressBarColors(progressbar.colors);
97
- colorCache.set(key, compiled);
98
- return compiled;
99
- };
92
+ const visibleLineLimit = Math.max(1, terminalRows - 1);
93
+ if (lines.length <= visibleLineLimit) {
94
+ return lines;
95
+ }
100
96
 
101
- const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
102
- Effect.gen(function* () {
103
- const terminalRows = yield* terminal.stderrRows;
104
- if (terminalRows === undefined) {
105
- return lines;
106
- }
97
+ if (visibleLineLimit === 1) {
98
+ return [`... ${lines.length} lines hidden`];
99
+ }
107
100
 
108
- const visibleLineLimit = Math.max(1, terminalRows - 1);
109
- if (lines.length <= visibleLineLimit) {
110
- return lines;
111
- }
101
+ const hiddenLineCount = lines.length - visibleLineLimit + 1;
102
+ return [
103
+ `... ${hiddenLineCount} lines hidden (showing latest lines)`,
104
+ ...lines.slice(lines.length - (visibleLineLimit - 1)),
105
+ ];
106
+ });
112
107
 
113
- if (visibleLineLimit === 1) {
114
- return [`... ${lines.length} lines hidden`];
108
+ const startTTYSession = Effect.gen(function* () {
109
+ if (!isTTY || sessionActive) {
110
+ return;
115
111
  }
116
112
 
117
- const hiddenLineCount = lines.length - visibleLineLimit + 1;
118
- return [
119
- `... ${hiddenLineCount} lines hidden (showing latest lines)`,
120
- ...lines.slice(lines.length - (visibleLineLimit - 1)),
121
- ];
113
+ yield* terminal.writeStderr(HIDE_CURSOR);
114
+ sessionActive = true;
122
115
  });
123
116
 
124
- const startTTYSession = Effect.gen(function* () {
125
- if (!isTTY || sessionActive) {
126
- return;
127
- }
128
-
129
- yield* terminal.writeStderr(HIDE_CURSOR);
130
- sessionActive = true;
131
- });
132
-
133
- const stopTTYSession = Effect.gen(function* () {
134
- if (!isTTY || !sessionActive) {
135
- return;
136
- }
117
+ const stopTTYSession = Effect.gen(function* () {
118
+ if (!isTTY || !sessionActive) {
119
+ return;
120
+ }
137
121
 
138
- yield* terminal.writeStderr("\n" + SHOW_CURSOR);
139
- previousLineCount = 0;
140
- sessionActive = false;
141
- });
122
+ yield* terminal.writeStderr("\n" + SHOW_CURSOR);
123
+ previousLineCount = 0;
124
+ sessionActive = false;
125
+ });
142
126
 
143
- const renderNonTTYTaskUpdates = (
144
- ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
145
- taskLines: ReadonlyArray<string>,
146
- ) => {
147
- const nextTaskSignatureById = new Map<number, string>();
148
- const changedTaskLines: Array<string> = [];
149
- const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
150
-
151
- for (let i = 0; i < ordered.length; i++) {
152
- const taskId = ordered[i]!.snapshot.id as number;
153
- const snapshot = ordered[i]!.snapshot;
154
- const line = taskLines[i]!;
155
- const signature =
156
- snapshot.units._tag === "DeterminateTaskUnits"
157
- ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
158
- : `${snapshot.status}:${snapshot.description}`;
159
-
160
- nextTaskSignatureById.set(taskId, signature);
161
- if (nonTTYTaskSignatureById.get(taskId) !== signature) {
162
- changedTaskLines.push(line);
127
+ const renderNonTTYTaskUpdates = (
128
+ ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
129
+ taskLines: ReadonlyArray<string>,
130
+ ) => {
131
+ const nextTaskSignatureById = new Map<number, string>();
132
+ const changedTaskLines: Array<string> = [];
133
+ const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
134
+
135
+ for (let i = 0; i < ordered.length; i++) {
136
+ const taskId = ordered[i]!.snapshot.id as number;
137
+ const snapshot = ordered[i]!.snapshot;
138
+ const line = taskLines[i]!;
139
+ const signature =
140
+ snapshot.units._tag === "DeterminateTaskUnits"
141
+ ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
142
+ : `${snapshot.status}:${snapshot.description}`;
143
+
144
+ nextTaskSignatureById.set(taskId, signature);
145
+ if (nonTTYTaskSignatureById.get(taskId) !== signature) {
146
+ changedTaskLines.push(line);
147
+ }
163
148
  }
164
- }
165
149
 
166
- return Effect.gen(function* () {
167
- if (changedTaskLines.length > 0) {
168
- yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
169
- }
150
+ return Effect.gen(function* () {
151
+ if (changedTaskLines.length > 0) {
152
+ yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
153
+ }
170
154
 
171
- nonTTYTaskSignatureById = nextTaskSignatureById;
172
- });
173
- };
174
-
175
- const renderFrame = (mode: "tick" | "final") =>
176
- Effect.gen(function* () {
177
- const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
178
- const store = yield* Ref.get(storeRef);
179
- const ordered = store.renderOrder.flatMap((row) => {
180
- const snapshot = store.tasks.get(row.id);
181
- if (!snapshot || (snapshot.transient && snapshot.status !== "running")) return [];
182
- return [{ snapshot, depth: row.depth }];
183
- });
184
- const now = yield* Clock.currentTimeMillis;
185
- const frameTick = mode === "final" ? tick + 1 : tick;
186
- const taskLines = ordered.map(({ snapshot, depth }) => {
187
- const lineTick = isTTY ? frameTick : 0;
188
- return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.config), now);
155
+ nonTTYTaskSignatureById = nextTaskSignatureById;
189
156
  });
157
+ };
190
158
 
191
- if (isTTY) {
192
- let frame = "";
159
+ const renderFrame = (mode: "tick" | "final") =>
160
+ Effect.gen(function* () {
161
+ const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
162
+ const store = yield* Ref.get(storeRef);
163
+ const ordered = store.renderOrder.flatMap((row) => {
164
+ const snapshot = store.tasks.get(row.id);
165
+ if (!snapshot || (snapshot.transient && snapshot.status !== "running")) return [];
166
+ return [{ snapshot, depth: row.depth }];
167
+ });
168
+ const now = yield* Clock.currentTimeMillis;
169
+ const frameTick = mode === "final" ? tick + 1 : tick;
170
+ const taskLines = ordered.map(({ snapshot, depth }) => {
171
+ const lineTick = isTTY ? frameTick : 0;
172
+ const taskColorizer = store.colorizers.get(snapshot.id) ?? colorizer;
173
+ return buildTaskLine(snapshot, depth, lineTick, taskColorizer, now);
174
+ });
193
175
 
194
- // 1. Cursor reset — move up and clear previous frame lines
195
- if (previousLineCount > 0) {
196
- frame += "\r" + CLEAR_LINE;
197
- for (let i = 1; i < previousLineCount; i++) {
198
- frame += MOVE_UP_ONE + CLEAR_LINE;
176
+ if (isTTY) {
177
+ let frame = "";
178
+
179
+ // 1. Cursor reset move up and clear previous frame lines
180
+ if (previousLineCount > 0) {
181
+ frame += "\r" + CLEAR_LINE;
182
+ for (let i = 1; i < previousLineCount; i++) {
183
+ frame += MOVE_UP_ONE + CLEAR_LINE;
184
+ }
199
185
  }
200
- }
201
186
 
202
- if (retainLogHistory) {
203
- const historyLogs = yield* Ref.get(logsRef);
204
- const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
205
- if (lines.length > 0) {
206
- frame += lines.join("\n");
187
+ if (retainLogHistory) {
188
+ const historyLogs = yield* Ref.get(logsRef);
189
+ const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
190
+ if (lines.length > 0) {
191
+ frame += lines.join("\n");
192
+ }
193
+ previousLineCount = lines.length;
194
+ } else {
195
+ // 2. Logs (scroll above the task block)
196
+ if (drainedLogs.length > 0) {
197
+ frame += drainedLogs.join("\n") + "\n";
198
+ }
199
+ // 3. Task lines
200
+ if (taskLines.length > 0) {
201
+ frame += taskLines.join("\n");
202
+ }
203
+ previousLineCount = taskLines.length;
207
204
  }
208
- previousLineCount = lines.length;
209
- } else {
210
- // 2. Logs (scroll above the task block)
211
- if (drainedLogs.length > 0) {
212
- frame += drainedLogs.join("\n") + "\n";
213
- }
214
- // 3. Task lines
215
- if (taskLines.length > 0) {
216
- frame += taskLines.join("\n");
205
+
206
+ // 4. Single atomic write
207
+ if (frame) {
208
+ yield* terminal.writeStderr(frame);
217
209
  }
218
- previousLineCount = taskLines.length;
210
+ return;
219
211
  }
220
212
 
221
- // 4. Single atomic write
222
- if (frame) {
223
- yield* terminal.writeStderr(frame);
213
+ if (drainedLogs.length > 0) {
214
+ yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
224
215
  }
225
- return;
226
- }
216
+ yield* renderNonTTYTaskUpdates(ordered, taskLines);
217
+ });
227
218
 
228
- if (drainedLogs.length > 0) {
229
- yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
219
+ const renderLoop = Effect.gen(function* () {
220
+ rendererActive = true;
221
+ if (isTTY) {
222
+ yield* startTTYSession;
230
223
  }
231
- yield* renderNonTTYTaskUpdates(ordered, taskLines);
232
- });
233
-
234
- const renderLoop = Effect.gen(function* () {
235
- rendererActive = true;
236
- if (isTTY) {
237
- yield* startTTYSession;
238
- }
239
224
 
240
- while (true) {
241
- const dirty = yield* Ref.getAndSet(dirtyRef, false);
242
- const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
243
- (task) => !(task.transient && task.status !== "running"),
244
- );
245
- const hasActiveSpinners = tasks.some(
246
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
247
- );
248
- const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
225
+ while (true) {
226
+ const dirty = yield* Ref.getAndSet(dirtyRef, false);
227
+ const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
228
+ (task) => !(task.transient && task.status !== "running"),
229
+ );
230
+ const hasActiveSpinners = tasks.some(
231
+ (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
232
+ );
233
+ const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
249
234
 
250
- if (isTTY) {
251
- if (dirty || hasActiveSpinners || hasPendingLogs) {
235
+ if (isTTY) {
236
+ if (dirty || hasActiveSpinners || hasPendingLogs) {
237
+ yield* renderFrame("tick");
238
+ }
239
+ } else if (dirty || hasActiveSpinners) {
252
240
  yield* renderFrame("tick");
253
241
  }
254
- } else if (dirty || hasActiveSpinners) {
255
- yield* renderFrame("tick");
256
- }
257
242
 
258
- tick += 1;
259
- yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
260
- }
261
- }).pipe(
262
- Effect.ensuring(
263
- Effect.gen(function* () {
264
- if (!rendererActive) {
265
- return;
266
- }
243
+ tick += 1;
244
+ yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
245
+ }
246
+ }).pipe(
247
+ Effect.ensuring(
248
+ Effect.gen(function* () {
249
+ if (!rendererActive) {
250
+ return;
251
+ }
267
252
 
268
- if (isTTY) {
269
- if (sessionActive) {
270
- yield* renderFrame("final");
271
- yield* stopTTYSession;
253
+ if (isTTY) {
254
+ if (sessionActive) {
255
+ yield* renderFrame("final");
256
+ yield* stopTTYSession;
257
+ }
258
+ return;
272
259
  }
273
- return;
274
- }
275
260
 
276
- yield* renderFrame("final");
277
- }),
278
- ),
279
- );
261
+ yield* renderFrame("final");
262
+ }),
263
+ ),
264
+ );
280
265
 
281
- if (isTTY && rendererConfig.disableUserInput) {
282
- return terminal.withRawInputCapture(renderLoop);
283
- }
266
+ if (isTTY && rendererConfig.disableUserInput) {
267
+ return yield* terminal.withRawInputCapture(renderLoop);
268
+ }
284
269
 
285
- return renderLoop;
286
- };
270
+ return yield* renderLoop;
271
+ });
package/src/runtime.ts CHANGED
@@ -3,6 +3,7 @@ import { dual } from "effect/Function";
3
3
  import { mergeWith } from "es-toolkit/object";
4
4
  import { formatWithOptions } from "node:util";
5
5
  import type { PartialDeep } from "type-fest";
6
+ import { Colorizer, type ColorizerService } from "./colors";
6
7
  import { makeProgressConsole } from "./console";
7
8
  import { runProgressServiceRenderer } from "./renderer";
8
9
  import { ProgressTerminal } from "./terminal";
@@ -119,7 +120,6 @@ const removeFromRenderOrder = (
119
120
  const makeProgressService = Effect.gen(function* () {
120
121
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
121
122
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
122
-
123
123
  const rendererConfig = decodeRendererConfigSync(
124
124
  mergeConfig(
125
125
  defaultRendererConfig,
@@ -140,6 +140,7 @@ const makeProgressService = Effect.gen(function* () {
140
140
  const storeRef = yield* Ref.make<TaskStore>({
141
141
  tasks: new Map<TaskId, TaskSnapshot>(),
142
142
  renderOrder: [],
143
+ colorizers: new Map<TaskId, ColorizerService>(),
143
144
  });
144
145
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
145
146
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
@@ -169,6 +170,7 @@ const makeProgressService = Effect.gen(function* () {
169
170
  options.parentId === undefined
170
171
  ? yield* FiberRef.get(currentParentRef)
171
172
  : Option.some(options.parentId);
173
+ const colorizerOption = yield* Effect.serviceOption(Colorizer);
172
174
  const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
173
175
  const units =
174
176
  options.total === undefined || options.total <= 0
@@ -203,7 +205,11 @@ const makeProgressService = Effect.gen(function* () {
203
205
  const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
204
206
  const nextOrder = [...s.renderOrder];
205
207
  nextOrder.splice(index, 0, { id: taskId, depth });
206
- return { tasks: nextTasks, renderOrder: nextOrder };
208
+ const nextColorizers = new Map(s.colorizers);
209
+ if (Option.isSome(colorizerOption)) {
210
+ nextColorizers.set(taskId, colorizerOption.value);
211
+ }
212
+ return { tasks: nextTasks, renderOrder: nextOrder, colorizers: nextColorizers };
207
213
  });
208
214
  yield* markDirty;
209
215
 
@@ -216,7 +222,7 @@ const makeProgressService = Effect.gen(function* () {
216
222
  if (!snapshot) return store;
217
223
  const nextTasks = new Map(store.tasks);
218
224
  nextTasks.set(taskId, updatedSnapshot(snapshot, options));
219
- return { tasks: nextTasks, renderOrder: store.renderOrder };
225
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
220
226
  }).pipe(Effect.zipRight(markDirty));
221
227
 
222
228
  const advanceTask = (taskId: TaskId, amount = 1) =>
@@ -250,7 +256,7 @@ const makeProgressService = Effect.gen(function* () {
250
256
  }),
251
257
  );
252
258
 
253
- return { tasks: nextTasks, renderOrder: store.renderOrder };
259
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
254
260
  }).pipe(Effect.zipRight(markDirty));
255
261
 
256
262
  const completeTask = (taskId: TaskId) =>
@@ -263,7 +269,13 @@ const makeProgressService = Effect.gen(function* () {
263
269
  const nextTasks = new Map(store.tasks);
264
270
  if (snapshot.transient) {
265
271
  nextTasks.delete(taskId);
266
- return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
272
+ const nextColorizers = new Map(store.colorizers);
273
+ nextColorizers.delete(taskId);
274
+ return {
275
+ tasks: nextTasks,
276
+ renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
277
+ colorizers: nextColorizers,
278
+ };
267
279
  }
268
280
 
269
281
  nextTasks.set(
@@ -286,7 +298,7 @@ const makeProgressService = Effect.gen(function* () {
286
298
  completedAt: now,
287
299
  }),
288
300
  );
289
- return { tasks: nextTasks, renderOrder: store.renderOrder };
301
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
290
302
  });
291
303
  yield* markDirty;
292
304
  });
@@ -301,7 +313,13 @@ const makeProgressService = Effect.gen(function* () {
301
313
  const nextTasks = new Map(store.tasks);
302
314
  if (snapshot.transient) {
303
315
  nextTasks.delete(taskId);
304
- return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
316
+ const nextColorizers = new Map(store.colorizers);
317
+ nextColorizers.delete(taskId);
318
+ return {
319
+ tasks: nextTasks,
320
+ renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
321
+ colorizers: nextColorizers,
322
+ };
305
323
  }
306
324
 
307
325
  nextTasks.set(
@@ -318,7 +336,7 @@ const makeProgressService = Effect.gen(function* () {
318
336
  completedAt: now,
319
337
  }),
320
338
  );
321
- return { tasks: nextTasks, renderOrder: store.renderOrder };
339
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
322
340
  });
323
341
  yield* markDirty;
324
342
  });
@@ -424,22 +442,19 @@ const makeProgressService = Effect.gen(function* () {
424
442
  return Progress.of(service);
425
443
  });
426
444
 
427
- export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {
428
- static readonly Default = Layer.scoped(Progress, makeProgressService);
445
+ export class Progress extends Context.Tag("stromseng.dev/effective-progress/Progress")<
446
+ Progress,
447
+ ProgressService
448
+ >() {
449
+ static readonly Default = Layer.unwrapEffect(
450
+ Effect.gen(function* () {
451
+ const colorizerOption = yield* Effect.serviceOption(Colorizer);
452
+ const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
453
+ const base = Layer.scoped(Progress, makeProgressService);
454
+ return base.pipe(
455
+ Option.isNone(colorizerOption) ? Layer.provide(Colorizer.Default) : (l) => l,
456
+ Option.isNone(terminalOption) ? Layer.provide(ProgressTerminal.Default) : (l) => l,
457
+ );
458
+ }),
459
+ );
429
460
  }
430
-
431
- export const provideProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
432
- Effect.gen(function* () {
433
- const existing = yield* Effect.serviceOption(Progress);
434
- if (Option.isSome(existing)) {
435
- return yield* Effect.provideService(effect, Progress, existing.value);
436
- }
437
-
438
- const existingTerminal = yield* Effect.serviceOption(ProgressTerminal);
439
- if (Option.isSome(existingTerminal)) {
440
- return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
441
- }
442
-
443
- const defaultLayers = Layer.provide(Progress.Default, ProgressTerminal.Default);
444
- return yield* Effect.scoped(effect.pipe(Effect.provide(defaultLayers)));
445
- });
package/src/terminal.ts CHANGED
@@ -42,18 +42,20 @@ const withRawInputCapture: ProgressTerminalService["withRawInputCapture"] = (eff
42
42
  );
43
43
  });
44
44
 
45
+ const defaultTerminalService: ProgressTerminalService = {
46
+ isTTY: Effect.sync(() => Boolean(process.stderr.isTTY)),
47
+ stderrRows: Effect.sync(() => process.stderr.rows),
48
+ stderrColumns: Effect.sync(() => process.stderr.columns),
49
+ writeStderr: (text) =>
50
+ Effect.sync(() => {
51
+ process.stderr.write(text);
52
+ }),
53
+ withRawInputCapture,
54
+ };
55
+
45
56
  export class ProgressTerminal extends Context.Tag("stromseng.dev/ProgressTerminal")<
46
57
  ProgressTerminal,
47
58
  ProgressTerminalService
48
59
  >() {
49
- static readonly Default = Layer.succeed(ProgressTerminal, {
50
- isTTY: Effect.sync(() => Boolean(process.stderr.isTTY)),
51
- stderrRows: Effect.sync(() => process.stderr.rows),
52
- stderrColumns: Effect.sync(() => process.stderr.columns),
53
- writeStderr: (text) =>
54
- Effect.sync(() => {
55
- process.stderr.write(text);
56
- }),
57
- withRawInputCapture,
58
- } satisfies ProgressTerminalService);
60
+ static readonly Default = Layer.succeed(ProgressTerminal, defaultTerminalService);
59
61
  }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Brand, Context, Effect, Option, Schema } from "effect";
2
2
  import type { PartialDeep } from "type-fest";
3
- import { defaultProgressBarColors, ProgressBarColorsSchema } from "./colors";
3
+ import type { ColorizerService } from "./colors";
4
4
 
5
5
  export const RendererConfigSchema = Schema.Struct({
6
6
  disableUserInput: Schema.Boolean,
@@ -18,7 +18,6 @@ export const ProgressBarConfigSchema = Schema.Struct({
18
18
  emptyChar: Schema.String,
19
19
  leftBracket: Schema.String,
20
20
  rightBracket: Schema.String,
21
- colors: ProgressBarColorsSchema,
22
21
  });
23
22
  export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
24
23
  export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarConfigSchema);
@@ -37,18 +36,16 @@ export const defaultProgressBarConfig: ProgressBarConfigShape = {
37
36
  emptyChar: "─",
38
37
  leftBracket: "",
39
38
  rightBracket: "",
40
- colors: defaultProgressBarColors,
41
39
  };
42
40
 
43
- export class RendererConfig extends Context.Tag("stromseng.dev/RendererConfig")<
41
+ export class RendererConfig extends Context.Tag("stromseng.dev/effective-progress/RendererConfig")<
44
42
  RendererConfig,
45
43
  PartialDeep<RendererConfigShape>
46
44
  >() {}
47
45
 
48
- export class ProgressBarConfig extends Context.Tag("stromseng.dev/ProgressBarConfig")<
49
- ProgressBarConfig,
50
- PartialDeep<ProgressBarConfigShape>
51
- >() {}
46
+ export class ProgressBarConfig extends Context.Tag(
47
+ "stromseng.dev/effective-progress/ProgressBarConfig",
48
+ )<ProgressBarConfig, PartialDeep<ProgressBarConfigShape>>() {}
52
49
 
53
50
  const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
54
51
 
@@ -115,6 +112,7 @@ export interface RenderRow {
115
112
  export interface TaskStore {
116
113
  readonly tasks: Map<TaskId, TaskSnapshot>;
117
114
  readonly renderOrder: ReadonlyArray<RenderRow>;
115
+ readonly colorizers: Map<TaskId, ColorizerService>;
118
116
  }
119
117
 
120
118
  export interface ProgressService {
@@ -146,7 +144,7 @@ export interface ProgressService {
146
144
  };
147
145
  }
148
146
 
149
- export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}
147
+ export class Task extends Context.Tag("stromseng.dev/effective-progress/Task")<Task, TaskId>() {}
150
148
 
151
149
  export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
152
150
  taskId: TaskIdSchema,