effective-progress 0.1.3 → 0.2.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
@@ -82,6 +82,7 @@ Effect.runPromise(program);
82
82
  - `examples/simpleExample.ts` - low-boilerplate real-world flow
83
83
  - `examples/advancedExample.ts` - full API usage with custom config and manual task control
84
84
  - `examples/showcase.ts` - nested concurrent tasks, spinner workloads, and mixed Effect/Console logging
85
+ - `examples/performance.ts` - stress-style run with high log volume and deeply nested progress updates
85
86
 
86
87
  ## Log retention
87
88
 
@@ -114,6 +115,40 @@ const configured = program.pipe(
114
115
  Effect.runPromise(configured);
115
116
  ```
116
117
 
118
+ ## Terminal service and mocking
119
+
120
+ `effective-progress` now exposes a `ProgressTerminal` service that controls terminal detection and I/O:
121
+
122
+ - `isTTY`
123
+ - `stderrRows`
124
+ - `stderrColumns`
125
+ - `writeStderr(text)`
126
+ - `withRawInputCapture(effect)`
127
+
128
+ You can provide a mock in tests instead of monkeypatching global process streams:
129
+
130
+ ```ts
131
+ import { Effect } from "effect";
132
+ import * as Progress from "effective-progress";
133
+
134
+ const mockTerminal: Progress.ProgressTerminalService = {
135
+ isTTY: Effect.succeed(true),
136
+ stderrRows: Effect.succeed(40),
137
+ stderrColumns: Effect.succeed(120),
138
+ writeStderr: (_text) => Effect.void,
139
+ withRawInputCapture: (effect) => effect,
140
+ };
141
+
142
+ const program = Progress.withTask({ description: "work" }, Effect.sleep("100 millis")).pipe(
143
+ Effect.provideService(Progress.ProgressTerminal, mockTerminal),
144
+ );
145
+ ```
146
+
147
+ ## Migration note
148
+
149
+ `RendererConfig.isTTY` has been removed.
150
+ TTY mode is now sourced from `ProgressTerminal.isTTY`.
151
+
117
152
  Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
118
153
 
119
154
  ```ts
@@ -129,25 +164,19 @@ yield *
129
164
  },
130
165
  },
131
166
  },
132
- () => Effect.sleep("1 second"),
167
+ Effect.sleep("1 second"),
133
168
  );
134
169
  ```
135
170
 
136
- For manual service usage, capture logs explicitly:
171
+ For manual usage, `withTask` captures logs implicitly and provides the current `Task` context:
137
172
 
138
173
  ```ts
139
- const program = Progress.provide(
174
+ const program = Progress.withTask(
175
+ { description: "Manual task" },
140
176
  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
- );
177
+ const currentTask = yield* Progress.Task;
178
+ yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
179
+ yield* Effect.sleep("1 second");
151
180
  }),
152
181
  );
153
182
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.1.3",
3
+ "version": "0.2.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": {
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "chalk": "^5.6.2",
34
- "effect": "^3.19.16",
34
+ "effect": "^3.19.17",
35
35
  "es-toolkit": "^1.44.0",
36
36
  "type-fest": "^5.4.4"
37
37
  },
package/src/api.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect } from "effect";
2
2
  import type { Concurrency } from "effect/Types";
3
- import { makeProgressService } from "./runtime";
4
- import { Progress } from "./types";
5
- import type { TrackOptions } from "./types";
3
+ import { Progress, provideProgressService } from "./runtime";
4
+ import { Task } from "./types";
5
+ import type { AddTaskOptions, TrackOptions } from "./types";
6
6
  import { inferTotal } from "./utils";
7
7
 
8
8
  export interface EffectExecutionOptions {
@@ -22,7 +22,7 @@ export type AllReturn<
22
22
  O extends EffectAllExecutionOptions,
23
23
  > =
24
24
  [Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>>] extends
25
- [Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress>>
25
+ [Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress | Task>>
26
26
  : never;
27
27
 
28
28
  export interface ForEachExecutionOptions extends EffectExecutionOptions {
@@ -31,20 +31,16 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
31
31
 
32
32
  export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
33
33
 
34
- export const provide = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
35
- Effect.gen(function* () {
36
- const existing = yield* Effect.serviceOption(Progress);
37
- if (Option.isSome(existing)) {
38
- return yield* Effect.provideService(effect, Progress, existing.value);
39
- }
40
-
41
- return yield* Effect.scoped(
42
- Effect.gen(function* () {
43
- const service = yield* makeProgressService;
44
- return yield* Effect.provideService(effect, Progress, service);
45
- }),
46
- );
47
- });
34
+ export const withTask = <A, E, R>(
35
+ options: AddTaskOptions,
36
+ effect: Effect.Effect<A, E, R>,
37
+ ): Effect.Effect<A, E, Exclude<R, Progress | Task>> =>
38
+ provideProgressService(
39
+ Effect.gen(function* () {
40
+ const progress = yield* Progress;
41
+ return yield* progress.withTask(options, effect);
42
+ }),
43
+ ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>;
48
44
 
49
45
  export const all = <
50
46
  const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
@@ -53,7 +49,7 @@ export const all = <
53
49
  effects: Arg,
54
50
  options: Omit<TrackOptions, "total"> & O,
55
51
  ): AllReturn<Arg, O> =>
56
- provide(
52
+ provideProgressService(
57
53
  Effect.gen(function* () {
58
54
  const progress = yield* Progress;
59
55
  return yield* progress.withTask(
@@ -63,11 +59,10 @@ export const all = <
63
59
  transient: options.transient,
64
60
  progressbar: options.progressbar,
65
61
  },
66
- (taskId) =>
67
- Effect.all(
68
- effects.map((effect) =>
69
- Effect.tap(progress.withCapturedLogs(effect), () => progress.advanceTask(taskId, 1)),
70
- ),
62
+ Effect.gen(function* () {
63
+ const taskId = yield* Task;
64
+ return yield* Effect.all(
65
+ effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
71
66
  {
72
67
  concurrency: options.concurrency,
73
68
  batching: options.batching,
@@ -75,7 +70,8 @@ export const all = <
75
70
  mode: options.mode,
76
71
  concurrentFinalizers: options.concurrentFinalizers,
77
72
  },
78
- ),
73
+ );
74
+ }),
79
75
  );
80
76
  }),
81
77
  ) as AllReturn<Arg, O>;
@@ -84,8 +80,8 @@ export const forEach = <A, B, E, R>(
84
80
  iterable: Iterable<A>,
85
81
  f: (item: A, index: number) => Effect.Effect<B, E, R>,
86
82
  options: ForEachOptions,
87
- ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
88
- provide(
83
+ ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>> =>
84
+ provideProgressService(
89
85
  Effect.gen(function* () {
90
86
  const progress = yield* Progress;
91
87
 
@@ -96,20 +92,20 @@ export const forEach = <A, B, E, R>(
96
92
  transient: options.transient,
97
93
  progressbar: options.progressbar,
98
94
  },
99
- (taskId) =>
100
- Effect.forEach(
95
+ Effect.gen(function* () {
96
+ const taskId = yield* Task;
97
+ return yield* Effect.forEach(
101
98
  iterable,
102
99
  (item, index) =>
103
- Effect.tap(progress.withCapturedLogs(f(item, index)), () =>
104
- progress.advanceTask(taskId, 1),
105
- ),
100
+ Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
106
101
  {
107
102
  concurrency: options.concurrency,
108
103
  batching: options.batching,
109
104
  discard: options.discard,
110
105
  concurrentFinalizers: options.concurrentFinalizers,
111
106
  },
112
- ),
107
+ );
108
+ }),
113
109
  );
114
110
  }),
115
- );
111
+ ) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
package/src/colors.ts CHANGED
@@ -103,61 +103,11 @@ export const defaultProgressBarColors: ProgressBarColors = {
103
103
  failed: { kind: "named", value: "red" },
104
104
  };
105
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
- };
106
+ const applyModifier = (instance: ChalkInstance, modifier: StyleModifier): ChalkInstance =>
107
+ instance[modifier] as ChalkInstance;
124
108
 
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
- };
109
+ const applyNamedColor = (instance: ChalkInstance, color: NamedColor): ChalkInstance =>
110
+ instance[color] as ChalkInstance;
161
111
 
162
112
  const resolveBaseStyle = (style: ColorStyle): ChalkInstance => {
163
113
  switch (style.kind) {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./api";
2
2
  export * from "./colors";
3
- export * from "./runtime";
3
+ export { Progress } from "./runtime";
4
+ export * from "./terminal";
4
5
  export * from "./types";
package/src/renderer.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  compileProgressBarColors,
5
5
  ProgressBarColorsSchema,
6
6
  } from "./colors";
7
+ import type { ProgressTerminalService } from "./terminal";
7
8
  import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
8
9
  import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
9
10
 
@@ -84,19 +85,18 @@ export const runProgressServiceRenderer = (
84
85
  logsRef: Ref.Ref<ReadonlyArray<string>>,
85
86
  pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
86
87
  dirtyRef: Ref.Ref<boolean>,
88
+ terminal: ProgressTerminalService,
89
+ isTTY: boolean,
87
90
  rendererConfig: RendererConfigShape,
88
91
  maxRetainedLogLines: number,
89
- rendererLatch: Effect.Latch,
90
92
  ) => {
91
- const isTTY = rendererConfig.isTTY;
92
93
  const retainLogHistory = maxRetainedLogLines > 0;
93
94
  const colorCache = new Map<string, CompiledProgressBarColors>();
94
95
  let previousLineCount = 0;
95
- let previousTaskLineCount = 0;
96
96
  let nonTTYTaskSignatureById = new Map<number, string>();
97
97
  let tick = 0;
98
98
  let rendererActive = false;
99
- let teardownInput: (() => void) | undefined;
99
+ let sessionActive = false;
100
100
 
101
101
  const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
102
102
  const key = encodeProgressBarColorsKey(progressbar.colors);
@@ -110,17 +110,47 @@ export const runProgressServiceRenderer = (
110
110
  return compiled;
111
111
  };
112
112
 
113
- const clearTTYLines = (lineCount: number) => {
114
- if (lineCount <= 0) {
113
+ const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
114
+ Effect.gen(function* () {
115
+ const terminalRows = yield* terminal.stderrRows;
116
+ if (terminalRows === undefined) {
117
+ return lines;
118
+ }
119
+
120
+ const visibleLineLimit = Math.max(1, terminalRows - 1);
121
+ if (lines.length <= visibleLineLimit) {
122
+ return lines;
123
+ }
124
+
125
+ if (visibleLineLimit === 1) {
126
+ return [`... ${lines.length} lines hidden`];
127
+ }
128
+
129
+ const hiddenLineCount = lines.length - visibleLineLimit + 1;
130
+ return [
131
+ `... ${hiddenLineCount} lines hidden (showing latest lines)`,
132
+ ...lines.slice(lines.length - (visibleLineLimit - 1)),
133
+ ];
134
+ });
135
+
136
+ const startTTYSession = Effect.gen(function* () {
137
+ if (!isTTY || sessionActive) {
115
138
  return;
116
139
  }
117
140
 
118
- let output = "\r" + CLEAR_LINE;
119
- for (let i = 1; i < lineCount; i++) {
120
- output += MOVE_UP_ONE + CLEAR_LINE;
141
+ yield* terminal.writeStderr(HIDE_CURSOR);
142
+ sessionActive = true;
143
+ });
144
+
145
+ const stopTTYSession = Effect.gen(function* () {
146
+ if (!isTTY || !sessionActive) {
147
+ return;
121
148
  }
122
- process.stderr.write(output + "\r");
123
- };
149
+
150
+ yield* terminal.writeStderr("\n" + SHOW_CURSOR);
151
+ previousLineCount = 0;
152
+ sessionActive = false;
153
+ });
124
154
 
125
155
  const renderNonTTYTaskUpdates = (
126
156
  ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
@@ -145,11 +175,13 @@ export const runProgressServiceRenderer = (
145
175
  }
146
176
  }
147
177
 
148
- if (changedTaskLines.length > 0) {
149
- process.stderr.write(changedTaskLines.join("\n") + "\n");
150
- }
178
+ return Effect.gen(function* () {
179
+ if (changedTaskLines.length > 0) {
180
+ yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
181
+ }
151
182
 
152
- nonTTYTaskSignatureById = nextTaskSignatureById;
183
+ nonTTYTaskSignatureById = nextTaskSignatureById;
184
+ });
153
185
  };
154
186
 
155
187
  const renderFrame = (mode: "tick" | "final") =>
@@ -166,78 +198,69 @@ export const runProgressServiceRenderer = (
166
198
  });
167
199
 
168
200
  if (isTTY) {
201
+ let frame = "";
202
+
203
+ // 1. Cursor reset — move up and clear previous frame lines
204
+ if (previousLineCount > 0) {
205
+ frame += "\r" + CLEAR_LINE;
206
+ for (let i = 1; i < previousLineCount; i++) {
207
+ frame += MOVE_UP_ONE + CLEAR_LINE;
208
+ }
209
+ }
210
+
169
211
  if (retainLogHistory) {
170
212
  const historyLogs = yield* Ref.get(logsRef);
171
- const lines = [...historyLogs, ...taskLines];
172
- clearTTYLines(previousLineCount);
213
+ const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
173
214
  if (lines.length > 0) {
174
- process.stderr.write(lines.join("\n"));
215
+ frame += lines.join("\n");
175
216
  }
176
217
  previousLineCount = lines.length;
177
- return;
218
+ } else {
219
+ // 2. Logs (scroll above the task block)
220
+ if (drainedLogs.length > 0) {
221
+ frame += drainedLogs.join("\n") + "\n";
222
+ }
223
+ // 3. Task lines
224
+ if (taskLines.length > 0) {
225
+ frame += taskLines.join("\n");
226
+ }
227
+ previousLineCount = taskLines.length;
178
228
  }
179
229
 
180
- clearTTYLines(previousTaskLineCount);
181
- if (drainedLogs.length > 0) {
182
- process.stderr.write(drainedLogs.join("\n") + "\n");
230
+ // 4. Single atomic write
231
+ if (frame) {
232
+ yield* terminal.writeStderr(frame);
183
233
  }
184
- if (taskLines.length > 0) {
185
- process.stderr.write(taskLines.join("\n"));
186
- }
187
- previousTaskLineCount = taskLines.length;
188
234
  return;
189
235
  }
190
236
 
191
237
  if (drainedLogs.length > 0) {
192
- process.stderr.write(drainedLogs.join("\n") + "\n");
238
+ yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
193
239
  }
194
- renderNonTTYTaskUpdates(ordered, taskLines);
240
+ yield* renderNonTTYTaskUpdates(ordered, taskLines);
195
241
  });
196
242
 
197
- return Effect.gen(function* () {
198
- yield* rendererLatch.await;
243
+ const renderLoop = Effect.gen(function* () {
199
244
  rendererActive = true;
200
-
201
245
  if (isTTY) {
202
- process.stderr.write(HIDE_CURSOR);
203
-
204
- if (rendererConfig.disableUserInput && process.stdin.isTTY) {
205
- const stdin = process.stdin;
206
- const wasRaw = Boolean(stdin.isRaw);
207
- stdin.resume();
208
- stdin.setRawMode?.(true);
209
-
210
- const onData = (chunk: Buffer) => {
211
- if (chunk.length === 1 && chunk[0] === 3) {
212
- process.kill(process.pid, "SIGINT");
213
- }
214
- };
215
-
216
- stdin.on("data", onData);
217
-
218
- teardownInput = () => {
219
- try {
220
- stdin.off("data", onData);
221
- stdin.setRawMode?.(wasRaw);
222
- stdin.pause();
223
- } catch {
224
- // Best effort terminal restoration.
225
- }
226
- };
227
- }
246
+ yield* startTTYSession;
228
247
  }
229
248
 
230
249
  while (true) {
231
250
  const dirty = yield* Ref.getAndSet(dirtyRef, false);
232
- const hasActiveSpinners = yield* Ref.get(tasksRef).pipe(
233
- Effect.map((tasks) =>
234
- Array.from(tasks.values()).some(
235
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
236
- ),
237
- ),
251
+ const tasks = Array.from((yield* Ref.get(tasksRef)).values()).filter(
252
+ (task) => !(task.transient && task.status !== "running"),
253
+ );
254
+ const hasActiveSpinners = tasks.some(
255
+ (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
238
256
  );
257
+ const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
239
258
 
240
- if (dirty || hasActiveSpinners) {
259
+ if (isTTY) {
260
+ if (dirty || hasActiveSpinners || hasPendingLogs) {
261
+ yield* renderFrame("tick");
262
+ }
263
+ } else if (dirty || hasActiveSpinners) {
241
264
  yield* renderFrame("tick");
242
265
  }
243
266
 
@@ -251,13 +274,22 @@ export const runProgressServiceRenderer = (
251
274
  return;
252
275
  }
253
276
 
254
- yield* renderFrame("final");
255
-
256
277
  if (isTTY) {
257
- teardownInput?.();
258
- process.stderr.write("\n" + SHOW_CURSOR);
278
+ if (sessionActive) {
279
+ yield* renderFrame("final");
280
+ yield* stopTTYSession;
281
+ }
282
+ return;
259
283
  }
284
+
285
+ yield* renderFrame("final");
260
286
  }),
261
287
  ),
262
288
  );
289
+
290
+ if (isTTY && rendererConfig.disableUserInput) {
291
+ return terminal.withRawInputCapture(renderLoop);
292
+ }
293
+
294
+ return renderLoop;
263
295
  };
package/src/runtime.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { Console, Effect, Exit, FiberRef, Option, Ref } from "effect";
1
+ import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
2
  import { mergeWith } from "es-toolkit/object";
3
3
  import { formatWithOptions } from "node:util";
4
4
  import type { PartialDeep } from "type-fest";
5
5
  import { makeProgressConsole } from "./console";
6
6
  import { runProgressServiceRenderer } from "./renderer";
7
+ import { ProgressTerminal } from "./terminal";
7
8
  import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
8
9
  import {
9
10
  decodeProgressBarConfigSync,
@@ -12,16 +13,14 @@ import {
12
13
  defaultRendererConfig,
13
14
  DeterminateTaskUnits,
14
15
  IndeterminateTaskUnits,
15
- Progress,
16
16
  ProgressBarConfig,
17
17
  RendererConfig,
18
+ Task,
18
19
  TaskId,
19
20
  TaskSnapshot,
20
21
  } from "./types";
21
22
  import { inferTotal } from "./utils";
22
23
 
23
- const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
24
-
25
24
  const mergeConfig = <T extends Record<PropertyKey, any>>(
26
25
  base: T,
27
26
  override: PartialDeep<T> | undefined,
@@ -80,7 +79,7 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
80
79
  });
81
80
  };
82
81
 
83
- export const makeProgressService = Effect.gen(function* () {
82
+ const makeProgressService = Effect.gen(function* () {
84
83
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
85
84
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
86
85
 
@@ -96,6 +95,8 @@ export const makeProgressService = Effect.gen(function* () {
96
95
  Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
97
96
  ),
98
97
  );
98
+ const terminal = yield* ProgressTerminal;
99
+ const isTTY = yield* terminal.isTTY;
99
100
  const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
100
101
 
101
102
  const nextTaskIdRef = yield* Ref.make(0);
@@ -103,9 +104,6 @@ export const makeProgressService = Effect.gen(function* () {
103
104
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
104
105
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
105
106
  const dirtyRef = yield* Ref.make(true);
106
- const dirtyScheduledRef = yield* Ref.make(false);
107
- const rendererStartedRef = yield* Ref.make(false);
108
- const rendererLatch = yield* Effect.makeLatch(false);
109
107
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
110
108
  const scope = yield* Effect.scope;
111
109
 
@@ -115,29 +113,15 @@ export const makeProgressService = Effect.gen(function* () {
115
113
  logsRef,
116
114
  pendingLogsRef,
117
115
  dirtyRef,
116
+ terminal,
117
+ isTTY,
118
118
  rendererConfig,
119
119
  maxRetainedLogLines,
120
- rendererLatch,
121
120
  ),
122
121
  scope,
123
122
  );
124
123
 
125
- const markDirty = Effect.gen(function* () {
126
- const shouldSchedule = yield* Ref.modify(dirtyScheduledRef, (scheduled) =>
127
- scheduled ? [false, true] : [true, true],
128
- );
129
-
130
- if (!shouldSchedule) {
131
- return;
132
- }
133
-
134
- yield* Effect.forkDaemon(
135
- Effect.sleep(DIRTY_DEBOUNCE_INTERVAL).pipe(
136
- Effect.zipRight(Ref.set(dirtyRef, true)),
137
- Effect.ensuring(Ref.set(dirtyScheduledRef, false)),
138
- ),
139
- );
140
- });
124
+ const markDirty = Ref.set(dirtyRef, true);
141
125
 
142
126
  const addTask = (options: AddTaskOptions) =>
143
127
  Effect.gen(function* () {
@@ -151,18 +135,11 @@ export const makeProgressService = Effect.gen(function* () {
151
135
  ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
152
136
  : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
153
137
  const tasks = yield* Ref.get(tasksRef);
154
- const parentSnapshot =
155
- Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
138
+ const parentSnapshot = Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
156
139
  const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
157
140
  const resolvedProgressBarConfig = decodeProgressBarConfigSync(
158
141
  mergeConfig(inheritedProgressBarConfig, options.progressbar),
159
142
  );
160
- const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
161
- started ? [false, true] : [true, true],
162
- );
163
- if (shouldOpenRenderer) {
164
- yield* rendererLatch.open;
165
- }
166
143
 
167
144
  const snapshot = new TaskSnapshot({
168
145
  id: taskId,
@@ -292,22 +269,15 @@ export const makeProgressService = Effect.gen(function* () {
292
269
  return next;
293
270
  }).pipe(Effect.zipRight(markDirty));
294
271
 
295
- const log = (...args: ReadonlyArray<unknown>) =>
272
+ const appendLog = (args: ReadonlyArray<unknown>) =>
296
273
  Effect.gen(function* () {
297
274
  if (args.length === 0) {
298
275
  return;
299
276
  }
300
277
 
301
- const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
302
- started ? [false, true] : [true, true],
303
- );
304
- if (shouldOpenRenderer) {
305
- yield* rendererLatch.open;
306
- }
307
-
308
278
  const message = formatWithOptions(
309
279
  {
310
- colors: rendererConfig.isTTY,
280
+ colors: isTTY,
311
281
  depth: 6,
312
282
  },
313
283
  ...args,
@@ -327,10 +297,9 @@ export const makeProgressService = Effect.gen(function* () {
327
297
  yield* markDirty;
328
298
  });
329
299
 
330
- const withCapturedLogs: ProgressService["withCapturedLogs"] = (effect) =>
300
+ const log = (...args: ReadonlyArray<unknown>) =>
331
301
  Effect.gen(function* () {
332
- const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
333
- return yield* Effect.withConsole(effect, makeProgressConsole(log, outerConsole));
302
+ yield* appendLog(args);
334
303
  });
335
304
 
336
305
  const getTask = (taskId: TaskId) =>
@@ -340,6 +309,7 @@ export const makeProgressService = Effect.gen(function* () {
340
309
 
341
310
  const withTask: ProgressService["withTask"] = (options, effect) =>
342
311
  Effect.gen(function* () {
312
+ const outerConsole = yield* Effect.console;
343
313
  const inheritedParentId = yield* FiberRef.get(currentParentRef);
344
314
  const resolvedParentId =
345
315
  options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
@@ -351,7 +321,14 @@ export const makeProgressService = Effect.gen(function* () {
351
321
  });
352
322
 
353
323
  const exit = yield* Effect.exit(
354
- Effect.locally(effect(taskId), currentParentRef, Option.some(taskId)),
324
+ Effect.locally(
325
+ Effect.withConsole(
326
+ Effect.provideService(effect, Task, taskId),
327
+ makeProgressConsole(log, outerConsole),
328
+ ),
329
+ currentParentRef,
330
+ Option.some(taskId),
331
+ ),
355
332
  );
356
333
 
357
334
  if (Exit.isSuccess(exit)) {
@@ -374,11 +351,12 @@ export const makeProgressService = Effect.gen(function* () {
374
351
  transient: options.transient,
375
352
  progressbar: options.progressbar,
376
353
  },
377
- (taskId) => {
378
- return Effect.forEach(iterable, (item, index) =>
354
+ Effect.gen(function* () {
355
+ const taskId = yield* Task;
356
+ return yield* Effect.forEach(iterable, (item, index) =>
379
357
  Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
380
358
  );
381
- },
359
+ }),
382
360
  );
383
361
 
384
362
  const service: ProgressService = {
@@ -388,7 +366,6 @@ export const makeProgressService = Effect.gen(function* () {
388
366
  completeTask,
389
367
  failTask,
390
368
  log,
391
- withCapturedLogs,
392
369
  getTask,
393
370
  listTasks,
394
371
  withTask,
@@ -397,3 +374,23 @@ export const makeProgressService = Effect.gen(function* () {
397
374
 
398
375
  return Progress.of(service);
399
376
  });
377
+
378
+ export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {
379
+ static readonly Default = Layer.scoped(Progress, makeProgressService);
380
+ }
381
+
382
+ export const provideProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
383
+ Effect.gen(function* () {
384
+ const existing = yield* Effect.serviceOption(Progress);
385
+ if (Option.isSome(existing)) {
386
+ return yield* Effect.provideService(effect, Progress, existing.value);
387
+ }
388
+
389
+ const existingTerminal = yield* Effect.serviceOption(ProgressTerminal);
390
+ if (Option.isSome(existingTerminal)) {
391
+ return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
392
+ }
393
+
394
+ const defaultLayers = Layer.provide(Progress.Default, ProgressTerminal.Default);
395
+ return yield* Effect.scoped(effect.pipe(Effect.provide(defaultLayers)));
396
+ });
@@ -0,0 +1,61 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ export interface ProgressTerminalService {
4
+ readonly isTTY: Effect.Effect<boolean>;
5
+ readonly stderrRows: Effect.Effect<number | undefined>;
6
+ readonly stderrColumns: Effect.Effect<number | undefined>;
7
+ readonly writeStderr: (text: string) => Effect.Effect<void>;
8
+ readonly withRawInputCapture: <A, E, R>(
9
+ effect: Effect.Effect<A, E, R>,
10
+ ) => Effect.Effect<A, E, R>;
11
+ }
12
+
13
+ const withRawInputCapture: ProgressTerminalService["withRawInputCapture"] = (effect) =>
14
+ Effect.suspend(() => {
15
+ if (!process.stdin.isTTY) {
16
+ return effect;
17
+ }
18
+
19
+ const stdin = process.stdin;
20
+ const wasRaw = Boolean(stdin.isRaw);
21
+ const onData = (chunk: Buffer) => {
22
+ if (chunk.length === 1 && chunk[0] === 3) {
23
+ process.kill(process.pid, "SIGINT");
24
+ }
25
+ };
26
+
27
+ return Effect.acquireUseRelease(
28
+ Effect.sync(() => {
29
+ stdin.resume();
30
+ stdin.setRawMode?.(true);
31
+ stdin.on("data", onData);
32
+ }),
33
+ () => effect,
34
+ () =>
35
+ Effect.sync(() => {
36
+ try {
37
+ stdin.off("data", onData);
38
+ stdin.setRawMode?.(wasRaw);
39
+ stdin.pause();
40
+ } catch {
41
+ // Best effort terminal restoration.
42
+ }
43
+ }),
44
+ );
45
+ });
46
+
47
+ export class ProgressTerminal extends Context.Tag("stromseng.dev/ProgressTerminal")<
48
+ ProgressTerminal,
49
+ ProgressTerminalService
50
+ >() {
51
+ static readonly Default = Layer.succeed(ProgressTerminal, {
52
+ isTTY: Effect.sync(() => Boolean(process.stderr.isTTY)),
53
+ stderrRows: Effect.sync(() => process.stderr.rows),
54
+ stderrColumns: Effect.sync(() => process.stderr.columns),
55
+ writeStderr: (text) =>
56
+ Effect.sync(() => {
57
+ process.stderr.write(text);
58
+ }),
59
+ withRawInputCapture,
60
+ } satisfies ProgressTerminalService);
61
+ }
package/src/types.ts CHANGED
@@ -3,7 +3,6 @@ import type { PartialDeep } from "type-fest";
3
3
  import { defaultProgressBarColors, ProgressBarColorsSchema } from "./colors";
4
4
 
5
5
  export const RendererConfigSchema = Schema.Struct({
6
- isTTY: Schema.Boolean,
7
6
  disableUserInput: Schema.Boolean,
8
7
  renderIntervalMillis: Schema.Number,
9
8
  maxLogLines: Schema.optional(Schema.Number),
@@ -25,7 +24,6 @@ export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
25
24
  export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarConfigSchema);
26
25
 
27
26
  export const defaultRendererConfig: RendererConfigShape = {
28
- isTTY: Boolean(process.stderr.isTTY),
29
27
  disableUserInput: true,
30
28
  renderIntervalMillis: 50, // 20 FPS
31
29
  maxLogLines: 0,
@@ -114,21 +112,20 @@ export interface ProgressService {
114
112
  readonly completeTask: (taskId: TaskId) => Effect.Effect<void>;
115
113
  readonly failTask: (taskId: TaskId) => Effect.Effect<void>;
116
114
  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>;
118
115
  readonly getTask: (taskId: TaskId) => Effect.Effect<Option.Option<TaskSnapshot>>;
119
116
  readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
120
117
  readonly withTask: <A, E, R>(
121
118
  options: AddTaskOptions,
122
- effect: (taskId: TaskId) => Effect.Effect<A, E, R>,
123
- ) => Effect.Effect<A, E, R>;
119
+ effect: Effect.Effect<A, E, R>,
120
+ ) => Effect.Effect<A, E, Exclude<R, Task>>;
124
121
  readonly trackIterable: <A, B, E, R>(
125
122
  iterable: Iterable<A>,
126
123
  options: TrackOptions,
127
124
  f: (item: A, index: number) => Effect.Effect<B, E, R>,
128
- ) => Effect.Effect<ReadonlyArray<B>, E, R>;
125
+ ) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Task>>;
129
126
  }
130
127
 
131
- export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {}
128
+ export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}
132
129
 
133
130
  export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
134
131
  taskId: TaskIdSchema,