effective-progress 0.2.3 → 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.3",
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 { 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));
@@ -27,34 +22,45 @@ const renderDeterminate = (
27
22
  return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
28
23
  };
29
24
 
25
+ const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
26
+ const elapsedMillis = (snapshot.completedAt ?? now) - snapshot.startedAt;
27
+ const duration =
28
+ snapshot.status === "running"
29
+ ? Duration.seconds(Math.floor(elapsedMillis / 1000))
30
+ : Duration.millis(elapsedMillis);
31
+ return ` (${Duration.format(duration)})`;
32
+ };
33
+
30
34
  const buildTaskLine = (
31
35
  snapshot: TaskSnapshot,
32
36
  depth: number,
33
37
  tick: number,
34
- colors: CompiledProgressBarColors,
38
+ colors: ColorizerService,
39
+ now: number,
35
40
  ): string => {
36
41
  const progressbar = snapshot.config;
37
42
  const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
43
+ const elapsed = formatElapsed(snapshot, now);
38
44
 
39
45
  if (snapshot.status === "failed") {
40
- return `${prefix}${colors.failed("[failed]")}`;
46
+ return `${prefix}${colors.failed("[failed]")}${elapsed}`;
41
47
  }
42
48
 
43
49
  if (snapshot.status === "done") {
44
50
  if (snapshot.units._tag === "DeterminateTaskUnits") {
45
- return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
51
+ return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}${elapsed}`;
46
52
  }
47
- return `${prefix}${colors.done("[done]")}`;
53
+ return `${prefix}${colors.done("[done]")}${elapsed}`;
48
54
  }
49
55
 
50
56
  if (snapshot.units._tag === "DeterminateTaskUnits") {
51
- return prefix + renderDeterminate(snapshot.units, progressbar, colors);
57
+ return prefix + renderDeterminate(snapshot.units, progressbar, colors) + elapsed;
52
58
  }
53
59
 
54
60
  const frames = progressbar.spinnerFrames;
55
61
  const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
56
62
  const frame = frames[frameIndex] ?? frames[0]!;
57
- return `${prefix}${colors.spinner(frame)}`;
63
+ return `${prefix}${colors.spinner(frame)}${elapsed}`;
58
64
  };
59
65
 
60
66
  export const runProgressServiceRenderer = (
@@ -66,209 +72,200 @@ export const runProgressServiceRenderer = (
66
72
  isTTY: boolean,
67
73
  rendererConfig: RendererConfigShape,
68
74
  maxRetainedLogLines: number,
69
- ) => {
70
- const retainLogHistory = maxRetainedLogLines > 0;
71
- const colorCache = new Map<string, CompiledProgressBarColors>();
72
- let previousLineCount = 0;
73
- let nonTTYTaskSignatureById = new Map<number, string>();
74
- let tick = 0;
75
- let rendererActive = false;
76
- let sessionActive = false;
77
-
78
- const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
79
- const key = encodeProgressBarColorsKey(progressbar.colors);
80
- const cached = colorCache.get(key);
81
- if (cached) {
82
- return cached;
83
- }
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
+ }
84
91
 
85
- const compiled = compileProgressBarColors(progressbar.colors);
86
- colorCache.set(key, compiled);
87
- return compiled;
88
- };
92
+ const visibleLineLimit = Math.max(1, terminalRows - 1);
93
+ if (lines.length <= visibleLineLimit) {
94
+ return lines;
95
+ }
89
96
 
90
- const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
91
- Effect.gen(function* () {
92
- const terminalRows = yield* terminal.stderrRows;
93
- if (terminalRows === undefined) {
94
- return lines;
95
- }
97
+ if (visibleLineLimit === 1) {
98
+ return [`... ${lines.length} lines hidden`];
99
+ }
96
100
 
97
- const visibleLineLimit = Math.max(1, terminalRows - 1);
98
- if (lines.length <= visibleLineLimit) {
99
- return lines;
100
- }
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
+ });
101
107
 
102
- if (visibleLineLimit === 1) {
103
- return [`... ${lines.length} lines hidden`];
108
+ const startTTYSession = Effect.gen(function* () {
109
+ if (!isTTY || sessionActive) {
110
+ return;
104
111
  }
105
112
 
106
- const hiddenLineCount = lines.length - visibleLineLimit + 1;
107
- return [
108
- `... ${hiddenLineCount} lines hidden (showing latest lines)`,
109
- ...lines.slice(lines.length - (visibleLineLimit - 1)),
110
- ];
113
+ yield* terminal.writeStderr(HIDE_CURSOR);
114
+ sessionActive = true;
111
115
  });
112
116
 
113
- const startTTYSession = Effect.gen(function* () {
114
- if (!isTTY || sessionActive) {
115
- return;
116
- }
117
-
118
- yield* terminal.writeStderr(HIDE_CURSOR);
119
- sessionActive = true;
120
- });
121
-
122
- const stopTTYSession = Effect.gen(function* () {
123
- if (!isTTY || !sessionActive) {
124
- return;
125
- }
117
+ const stopTTYSession = Effect.gen(function* () {
118
+ if (!isTTY || !sessionActive) {
119
+ return;
120
+ }
126
121
 
127
- yield* terminal.writeStderr("\n" + SHOW_CURSOR);
128
- previousLineCount = 0;
129
- sessionActive = false;
130
- });
122
+ yield* terminal.writeStderr("\n" + SHOW_CURSOR);
123
+ previousLineCount = 0;
124
+ sessionActive = false;
125
+ });
131
126
 
132
- const renderNonTTYTaskUpdates = (
133
- ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
134
- taskLines: ReadonlyArray<string>,
135
- ) => {
136
- const nextTaskSignatureById = new Map<number, string>();
137
- const changedTaskLines: Array<string> = [];
138
- const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
139
-
140
- for (let i = 0; i < ordered.length; i++) {
141
- const taskId = ordered[i]!.snapshot.id as number;
142
- const snapshot = ordered[i]!.snapshot;
143
- const line = taskLines[i]!;
144
- const signature =
145
- snapshot.units._tag === "DeterminateTaskUnits"
146
- ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
147
- : `${snapshot.status}:${snapshot.description}`;
148
-
149
- nextTaskSignatureById.set(taskId, signature);
150
- if (nonTTYTaskSignatureById.get(taskId) !== signature) {
151
- 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
+ }
152
148
  }
153
- }
154
149
 
155
- return Effect.gen(function* () {
156
- if (changedTaskLines.length > 0) {
157
- yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
158
- }
150
+ return Effect.gen(function* () {
151
+ if (changedTaskLines.length > 0) {
152
+ yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
153
+ }
159
154
 
160
- nonTTYTaskSignatureById = nextTaskSignatureById;
161
- });
162
- };
163
-
164
- const renderFrame = (mode: "tick" | "final") =>
165
- Effect.gen(function* () {
166
- const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
167
- const store = yield* Ref.get(storeRef);
168
- const ordered = store.renderOrder.flatMap((row) => {
169
- const snapshot = store.tasks.get(row.id);
170
- if (!snapshot || (snapshot.transient && snapshot.status !== "running")) return [];
171
- return [{ snapshot, depth: row.depth }];
172
- });
173
- const frameTick = mode === "final" ? tick + 1 : tick;
174
- const taskLines = ordered.map(({ snapshot, depth }) => {
175
- const lineTick = isTTY ? frameTick : 0;
176
- return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.config));
155
+ nonTTYTaskSignatureById = nextTaskSignatureById;
177
156
  });
157
+ };
178
158
 
179
- if (isTTY) {
180
- 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
+ });
181
175
 
182
- // 1. Cursor reset — move up and clear previous frame lines
183
- if (previousLineCount > 0) {
184
- frame += "\r" + CLEAR_LINE;
185
- for (let i = 1; i < previousLineCount; i++) {
186
- 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
+ }
187
185
  }
188
- }
189
186
 
190
- if (retainLogHistory) {
191
- const historyLogs = yield* Ref.get(logsRef);
192
- const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
193
- if (lines.length > 0) {
194
- frame += lines.join("\n");
195
- }
196
- previousLineCount = lines.length;
197
- } else {
198
- // 2. Logs (scroll above the task block)
199
- if (drainedLogs.length > 0) {
200
- frame += drainedLogs.join("\n") + "\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;
201
204
  }
202
- // 3. Task lines
203
- if (taskLines.length > 0) {
204
- frame += taskLines.join("\n");
205
+
206
+ // 4. Single atomic write
207
+ if (frame) {
208
+ yield* terminal.writeStderr(frame);
205
209
  }
206
- previousLineCount = taskLines.length;
210
+ return;
207
211
  }
208
212
 
209
- // 4. Single atomic write
210
- if (frame) {
211
- yield* terminal.writeStderr(frame);
213
+ if (drainedLogs.length > 0) {
214
+ yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
212
215
  }
213
- return;
214
- }
216
+ yield* renderNonTTYTaskUpdates(ordered, taskLines);
217
+ });
215
218
 
216
- if (drainedLogs.length > 0) {
217
- yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
219
+ const renderLoop = Effect.gen(function* () {
220
+ rendererActive = true;
221
+ if (isTTY) {
222
+ yield* startTTYSession;
218
223
  }
219
- yield* renderNonTTYTaskUpdates(ordered, taskLines);
220
- });
221
-
222
- const renderLoop = Effect.gen(function* () {
223
- rendererActive = true;
224
- if (isTTY) {
225
- yield* startTTYSession;
226
- }
227
224
 
228
- while (true) {
229
- const dirty = yield* Ref.getAndSet(dirtyRef, false);
230
- const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
231
- (task) => !(task.transient && task.status !== "running"),
232
- );
233
- const hasActiveSpinners = tasks.some(
234
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
235
- );
236
- 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;
237
234
 
238
- if (isTTY) {
239
- if (dirty || hasActiveSpinners || hasPendingLogs) {
235
+ if (isTTY) {
236
+ if (dirty || hasActiveSpinners || hasPendingLogs) {
237
+ yield* renderFrame("tick");
238
+ }
239
+ } else if (dirty || hasActiveSpinners) {
240
240
  yield* renderFrame("tick");
241
241
  }
242
- } else if (dirty || hasActiveSpinners) {
243
- yield* renderFrame("tick");
244
- }
245
242
 
246
- tick += 1;
247
- yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
248
- }
249
- }).pipe(
250
- Effect.ensuring(
251
- Effect.gen(function* () {
252
- if (!rendererActive) {
253
- return;
254
- }
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
+ }
255
252
 
256
- if (isTTY) {
257
- if (sessionActive) {
258
- yield* renderFrame("final");
259
- yield* stopTTYSession;
253
+ if (isTTY) {
254
+ if (sessionActive) {
255
+ yield* renderFrame("final");
256
+ yield* stopTTYSession;
257
+ }
258
+ return;
260
259
  }
261
- return;
262
- }
263
260
 
264
- yield* renderFrame("final");
265
- }),
266
- ),
267
- );
261
+ yield* renderFrame("final");
262
+ }),
263
+ ),
264
+ );
268
265
 
269
- if (isTTY && rendererConfig.disableUserInput) {
270
- return terminal.withRawInputCapture(renderLoop);
271
- }
266
+ if (isTTY && rendererConfig.disableUserInput) {
267
+ return yield* terminal.withRawInputCapture(renderLoop);
268
+ }
272
269
 
273
- return renderLoop;
274
- };
270
+ return yield* renderLoop;
271
+ });
package/src/runtime.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
1
+ import { Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
2
  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";
@@ -82,6 +83,8 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
82
83
  transient: options.transient ?? snapshot.transient,
83
84
  units,
84
85
  config: snapshot.config,
86
+ startedAt: snapshot.startedAt,
87
+ completedAt: snapshot.completedAt,
85
88
  });
86
89
  };
87
90
 
@@ -117,7 +120,6 @@ const removeFromRenderOrder = (
117
120
  const makeProgressService = Effect.gen(function* () {
118
121
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
119
122
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
120
-
121
123
  const rendererConfig = decodeRendererConfigSync(
122
124
  mergeConfig(
123
125
  defaultRendererConfig,
@@ -138,6 +140,7 @@ const makeProgressService = Effect.gen(function* () {
138
140
  const storeRef = yield* Ref.make<TaskStore>({
139
141
  tasks: new Map<TaskId, TaskSnapshot>(),
140
142
  renderOrder: [],
143
+ colorizers: new Map<TaskId, ColorizerService>(),
141
144
  });
142
145
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
143
146
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
@@ -167,6 +170,7 @@ const makeProgressService = Effect.gen(function* () {
167
170
  options.parentId === undefined
168
171
  ? yield* FiberRef.get(currentParentRef)
169
172
  : Option.some(options.parentId);
173
+ const colorizerOption = yield* Effect.serviceOption(Colorizer);
170
174
  const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
171
175
  const units =
172
176
  options.total === undefined || options.total <= 0
@@ -181,6 +185,7 @@ const makeProgressService = Effect.gen(function* () {
181
185
  mergeConfig(inheritedProgressBarConfig, options.progressbar),
182
186
  );
183
187
 
188
+ const now = yield* Clock.currentTimeMillis;
184
189
  const parentIdValue = Option.getOrNull(resolvedParentId);
185
190
  const snapshot = new TaskSnapshot({
186
191
  id: taskId,
@@ -190,6 +195,8 @@ const makeProgressService = Effect.gen(function* () {
190
195
  transient: options.transient ?? false,
191
196
  units,
192
197
  config: resolvedProgressBarConfig,
198
+ startedAt: now,
199
+ completedAt: null,
193
200
  });
194
201
 
195
202
  yield* Ref.update(storeRef, (s) => {
@@ -198,7 +205,11 @@ const makeProgressService = Effect.gen(function* () {
198
205
  const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
199
206
  const nextOrder = [...s.renderOrder];
200
207
  nextOrder.splice(index, 0, { id: taskId, depth });
201
- 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 };
202
213
  });
203
214
  yield* markDirty;
204
215
 
@@ -211,7 +222,7 @@ const makeProgressService = Effect.gen(function* () {
211
222
  if (!snapshot) return store;
212
223
  const nextTasks = new Map(store.tasks);
213
224
  nextTasks.set(taskId, updatedSnapshot(snapshot, options));
214
- return { tasks: nextTasks, renderOrder: store.renderOrder };
225
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
215
226
  }).pipe(Effect.zipRight(markDirty));
216
227
 
217
228
  const advanceTask = (taskId: TaskId, amount = 1) =>
@@ -240,69 +251,95 @@ const makeProgressService = Effect.gen(function* () {
240
251
  transient: snapshot.transient,
241
252
  units,
242
253
  config: snapshot.config,
254
+ startedAt: snapshot.startedAt,
255
+ completedAt: snapshot.completedAt,
243
256
  }),
244
257
  );
245
258
 
246
- return { tasks: nextTasks, renderOrder: store.renderOrder };
259
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
247
260
  }).pipe(Effect.zipRight(markDirty));
248
261
 
249
262
  const completeTask = (taskId: TaskId) =>
250
- Ref.update(storeRef, (store) => {
251
- const snapshot = store.tasks.get(taskId);
252
- if (!snapshot) return store;
253
-
254
- const nextTasks = new Map(store.tasks);
255
- if (snapshot.transient) {
256
- nextTasks.delete(taskId);
257
- return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
258
- }
259
-
260
- nextTasks.set(
261
- taskId,
262
- new TaskSnapshot({
263
- id: snapshot.id,
264
- parentId: snapshot.parentId,
265
- description: snapshot.description,
266
- status: "done",
267
- transient: snapshot.transient,
268
- units:
269
- snapshot.units._tag === "DeterminateTaskUnits"
270
- ? new DeterminateTaskUnits({
271
- completed: snapshot.units.total,
272
- total: snapshot.units.total,
273
- })
274
- : snapshot.units,
275
- config: snapshot.config,
276
- }),
277
- );
278
- return { tasks: nextTasks, renderOrder: store.renderOrder };
279
- }).pipe(Effect.zipRight(markDirty));
263
+ Effect.gen(function* () {
264
+ const now = yield* Clock.currentTimeMillis;
265
+ yield* Ref.update(storeRef, (store) => {
266
+ const snapshot = store.tasks.get(taskId);
267
+ if (!snapshot) return store;
268
+
269
+ const nextTasks = new Map(store.tasks);
270
+ if (snapshot.transient) {
271
+ nextTasks.delete(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
+ };
279
+ }
280
+
281
+ nextTasks.set(
282
+ taskId,
283
+ new TaskSnapshot({
284
+ id: snapshot.id,
285
+ parentId: snapshot.parentId,
286
+ description: snapshot.description,
287
+ status: "done",
288
+ transient: snapshot.transient,
289
+ units:
290
+ snapshot.units._tag === "DeterminateTaskUnits"
291
+ ? new DeterminateTaskUnits({
292
+ completed: snapshot.units.total,
293
+ total: snapshot.units.total,
294
+ })
295
+ : snapshot.units,
296
+ config: snapshot.config,
297
+ startedAt: snapshot.startedAt,
298
+ completedAt: now,
299
+ }),
300
+ );
301
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
302
+ });
303
+ yield* markDirty;
304
+ });
280
305
 
281
306
  const failTask = (taskId: TaskId) =>
282
- Ref.update(storeRef, (store) => {
283
- const snapshot = store.tasks.get(taskId);
284
- if (!snapshot) return store;
285
-
286
- const nextTasks = new Map(store.tasks);
287
- if (snapshot.transient) {
288
- nextTasks.delete(taskId);
289
- return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
290
- }
291
-
292
- nextTasks.set(
293
- taskId,
294
- new TaskSnapshot({
295
- id: snapshot.id,
296
- parentId: snapshot.parentId,
297
- description: snapshot.description,
298
- status: "failed",
299
- transient: snapshot.transient,
300
- units: snapshot.units,
301
- config: snapshot.config,
302
- }),
303
- );
304
- return { tasks: nextTasks, renderOrder: store.renderOrder };
305
- }).pipe(Effect.zipRight(markDirty));
307
+ Effect.gen(function* () {
308
+ const now = yield* Clock.currentTimeMillis;
309
+ yield* Ref.update(storeRef, (store) => {
310
+ const snapshot = store.tasks.get(taskId);
311
+ if (!snapshot) return store;
312
+
313
+ const nextTasks = new Map(store.tasks);
314
+ if (snapshot.transient) {
315
+ nextTasks.delete(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
+ };
323
+ }
324
+
325
+ nextTasks.set(
326
+ taskId,
327
+ new TaskSnapshot({
328
+ id: snapshot.id,
329
+ parentId: snapshot.parentId,
330
+ description: snapshot.description,
331
+ status: "failed",
332
+ transient: snapshot.transient,
333
+ units: snapshot.units,
334
+ config: snapshot.config,
335
+ startedAt: snapshot.startedAt,
336
+ completedAt: now,
337
+ }),
338
+ );
339
+ return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
340
+ });
341
+ yield* markDirty;
342
+ });
306
343
 
307
344
  const appendLog = (args: ReadonlyArray<unknown>) =>
308
345
  Effect.gen(function* () {
@@ -405,22 +442,19 @@ const makeProgressService = Effect.gen(function* () {
405
442
  return Progress.of(service);
406
443
  });
407
444
 
408
- export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {
409
- 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
+ );
410
460
  }
411
-
412
- export const provideProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
413
- Effect.gen(function* () {
414
- const existing = yield* Effect.serviceOption(Progress);
415
- if (Option.isSome(existing)) {
416
- return yield* Effect.provideService(effect, Progress, existing.value);
417
- }
418
-
419
- const existingTerminal = yield* Effect.serviceOption(ProgressTerminal);
420
- if (Option.isSome(existingTerminal)) {
421
- return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
422
- }
423
-
424
- const defaultLayers = Layer.provide(Progress.Default, ProgressTerminal.Default);
425
- return yield* Effect.scoped(effect.pipe(Effect.provide(defaultLayers)));
426
- });
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,14 +18,13 @@ 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);
25
24
 
26
25
  export const defaultRendererConfig: RendererConfigShape = {
27
26
  disableUserInput: true,
28
- renderIntervalMillis: 50, // 20 FPS
27
+ renderIntervalMillis: 100, // 10 FPS
29
28
  maxLogLines: 0,
30
29
  nonTtyUpdateStep: 5,
31
30
  };
@@ -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
 
@@ -103,6 +100,8 @@ export class TaskSnapshot extends Schema.TaggedClass<TaskSnapshot>()("TaskSnapsh
103
100
  transient: Schema.Boolean,
104
101
  units: TaskUnitsSchema,
105
102
  config: ProgressBarConfigSchema,
103
+ startedAt: Schema.Number,
104
+ completedAt: Schema.NullOr(Schema.Number),
106
105
  }) {}
107
106
 
108
107
  export interface RenderRow {
@@ -113,6 +112,7 @@ export interface RenderRow {
113
112
  export interface TaskStore {
114
113
  readonly tasks: Map<TaskId, TaskSnapshot>;
115
114
  readonly renderOrder: ReadonlyArray<RenderRow>;
115
+ readonly colorizers: Map<TaskId, ColorizerService>;
116
116
  }
117
117
 
118
118
  export interface ProgressService {
@@ -144,7 +144,7 @@ export interface ProgressService {
144
144
  };
145
145
  }
146
146
 
147
- 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>() {}
148
148
 
149
149
  export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
150
150
  taskId: TaskIdSchema,