effective-progress 0.1.3 → 0.2.1

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
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/effective-progress)](https://www.npmjs.com/package/effective-progress)
4
4
 
5
5
  > [!WARNING]
6
- > Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
6
+ > Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
7
7
  > I recommend using only the `Progress.all` and `Progress.forEach` APIs for now, as they will likely change the least. The lower-level APIs for manual progress bar control are more likely to see breaking changes as I iterate on the design.
8
8
  >
9
9
  > Please open an issue or reach out if you have any questions or want to contribute!
@@ -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
 
@@ -119,6 +120,7 @@ Task-level `progressbar` config is optional and inherits from its parent task (o
119
120
  ```ts
120
121
  yield *
121
122
  progress.withTask(
123
+ Effect.sleep("1 second"),
122
124
  {
123
125
  description: "Worker pipeline",
124
126
  progressbar: {
@@ -129,26 +131,50 @@ yield *
129
131
  },
130
132
  },
131
133
  },
132
- () => Effect.sleep("1 second"),
133
134
  );
134
135
  ```
135
136
 
136
- For manual service usage, capture logs explicitly:
137
+ ## Terminal service and mocking
138
+
139
+ `effective-progress` now exposes a `ProgressTerminal` service that controls terminal detection and I/O:
140
+
141
+ - `isTTY`
142
+ - `stderrRows`
143
+ - `stderrColumns`
144
+ - `writeStderr(text)`
145
+ - `withRawInputCapture(effect)`
146
+
147
+ You can provide a mock if you want to alter the behavior of terminal detection or if you want to capture the output for testing:
148
+
149
+ ```ts
150
+ import { Effect } from "effect";
151
+ import * as Progress from "effective-progress";
152
+
153
+ const mockTerminal: Progress.ProgressTerminalService = {
154
+ isTTY: Effect.succeed(true),
155
+ stderrRows: Effect.succeed(40),
156
+ stderrColumns: Effect.succeed(120),
157
+ writeStderr: (_text) => Effect.void,
158
+ withRawInputCapture: (effect) => effect,
159
+ };
160
+
161
+ const program = Progress.withTask(Effect.sleep("100 millis"), { description: "work" }).pipe(
162
+ Effect.provideService(Progress.ProgressTerminal, mockTerminal),
163
+ );
164
+ ```
165
+
166
+ ## Manual task control
167
+
168
+ For manual usage, `withTask` captures logs implicitly and provides the current `Task` context:
137
169
 
138
170
  ```ts
139
- const program = Progress.provide(
171
+ const program = Progress.withTask(
140
172
  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
- );
173
+ const currentTask = yield* Progress.Task;
174
+ yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
175
+ yield* Effect.sleep("1 second");
151
176
  }),
177
+ { description: "Manual task" },
152
178
  );
153
179
  ```
154
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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,9 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect } from "effect";
2
+ import { dual } from "effect/Function";
2
3
  import type { Concurrency } from "effect/Types";
3
- import { makeProgressService } from "./runtime";
4
- import { Progress } from "./types";
5
- import type { TrackOptions } from "./types";
4
+ import { Progress, provideProgressService } from "./runtime";
5
+ import { Task } from "./types";
6
+ import type { AddTaskOptions, TrackOptions } from "./types";
6
7
  import { inferTotal } from "./utils";
7
8
 
8
9
  export interface EffectExecutionOptions {
@@ -22,7 +23,7 @@ export type AllReturn<
22
23
  O extends EffectAllExecutionOptions,
23
24
  > =
24
25
  [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>>
26
+ [Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress | Task>>
26
27
  : never;
27
28
 
28
29
  export interface ForEachExecutionOptions extends EffectExecutionOptions {
@@ -31,85 +32,113 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
31
32
 
32
33
  export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
33
34
 
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
- });
48
-
49
- export const all = <
50
- const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
51
- O extends EffectAllExecutionOptions,
52
- >(
53
- effects: Arg,
54
- options: Omit<TrackOptions, "total"> & O,
55
- ): AllReturn<Arg, O> =>
56
- provide(
35
+ export const withTask: {
36
+ <A, E, R>(
37
+ effect: Effect.Effect<A, E, R>,
38
+ options: AddTaskOptions,
39
+ ): Effect.Effect<A, E, Exclude<R, Progress | Task>>;
40
+ <A, E, R>(
41
+ options: AddTaskOptions,
42
+ ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Progress | Task>>;
43
+ } = dual(2, <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
44
+ provideProgressService(
57
45
  Effect.gen(function* () {
58
46
  const progress = yield* Progress;
59
- return yield* progress.withTask(
60
- {
61
- description: options.description,
62
- total: effects.length,
63
- transient: options.transient,
64
- progressbar: options.progressbar,
65
- },
66
- (taskId) =>
67
- Effect.all(
68
- effects.map((effect) =>
69
- Effect.tap(progress.withCapturedLogs(effect), () => progress.advanceTask(taskId, 1)),
70
- ),
71
- {
72
- concurrency: options.concurrency,
73
- batching: options.batching,
74
- discard: options.discard,
75
- mode: options.mode,
76
- concurrentFinalizers: options.concurrentFinalizers,
77
- },
78
- ),
79
- );
47
+ return yield* progress.withTask(effect, options);
80
48
  }),
81
- ) as AllReturn<Arg, O>;
49
+ ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>,
50
+ );
82
51
 
83
- export const forEach = <A, B, E, R>(
84
- iterable: Iterable<A>,
85
- f: (item: A, index: number) => Effect.Effect<B, E, R>,
86
- options: ForEachOptions,
87
- ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
88
- provide(
89
- Effect.gen(function* () {
90
- const progress = yield* Progress;
52
+ export const all: {
53
+ <const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>, O extends EffectAllExecutionOptions>(
54
+ effects: Arg,
55
+ options: Omit<TrackOptions, "total"> & O,
56
+ ): AllReturn<Arg, O>;
57
+ <O extends EffectAllExecutionOptions>(
58
+ options: Omit<TrackOptions, "total"> & O,
59
+ ): <const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>>(effects: Arg) => AllReturn<
60
+ Arg,
61
+ O
62
+ >;
63
+ } = dual(
64
+ 2,
65
+ <const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>, O extends EffectAllExecutionOptions>(
66
+ effects: Arg,
67
+ options: Omit<TrackOptions, "total"> & O,
68
+ ) =>
69
+ provideProgressService(
70
+ Effect.gen(function* () {
71
+ const progress = yield* Progress;
72
+ return yield* progress.withTask(
73
+ Effect.gen(function* () {
74
+ const taskId = yield* Task;
75
+ return yield* Effect.all(
76
+ effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
77
+ {
78
+ concurrency: options.concurrency,
79
+ batching: options.batching,
80
+ discard: options.discard,
81
+ mode: options.mode,
82
+ concurrentFinalizers: options.concurrentFinalizers,
83
+ },
84
+ );
85
+ }),
86
+ {
87
+ description: options.description,
88
+ total: effects.length,
89
+ transient: options.transient,
90
+ progressbar: options.progressbar,
91
+ },
92
+ );
93
+ }),
94
+ ) as AllReturn<Arg, O>,
95
+ );
91
96
 
92
- return yield* progress.withTask(
93
- {
94
- description: options.description,
95
- total: options.total ?? inferTotal(iterable),
96
- transient: options.transient,
97
- progressbar: options.progressbar,
98
- },
99
- (taskId) =>
100
- Effect.forEach(
101
- iterable,
102
- (item, index) =>
103
- Effect.tap(progress.withCapturedLogs(f(item, index)), () =>
104
- progress.advanceTask(taskId, 1),
105
- ),
106
- {
107
- concurrency: options.concurrency,
108
- batching: options.batching,
109
- discard: options.discard,
110
- concurrentFinalizers: options.concurrentFinalizers,
111
- },
112
- ),
113
- );
114
- }),
115
- );
97
+ export const forEach: {
98
+ <A, B, E, R>(
99
+ iterable: Iterable<A>,
100
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
101
+ options: ForEachOptions,
102
+ ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
103
+ <A, B, E, R>(
104
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
105
+ options: ForEachOptions,
106
+ ): (
107
+ iterable: Iterable<A>,
108
+ ) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
109
+ } = dual(
110
+ 3,
111
+ <A, B, E, R>(
112
+ iterable: Iterable<A>,
113
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
114
+ options: ForEachOptions,
115
+ ) =>
116
+ provideProgressService(
117
+ Effect.gen(function* () {
118
+ const progress = yield* Progress;
119
+
120
+ return yield* progress.withTask(
121
+ Effect.gen(function* () {
122
+ const taskId = yield* Task;
123
+ return yield* Effect.forEach(
124
+ iterable,
125
+ (item, index) =>
126
+ Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
127
+ {
128
+ concurrency: options.concurrency,
129
+ batching: options.batching,
130
+ discard: options.discard,
131
+ concurrentFinalizers: options.concurrentFinalizers,
132
+ },
133
+ );
134
+ }),
135
+ {
136
+ description: options.description,
137
+ total: options.total ?? inferTotal(iterable),
138
+ transient: options.transient,
139
+ progressbar: options.progressbar,
140
+ },
141
+ );
142
+ }),
143
+ ) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>,
144
+ );
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,11 @@
1
- import { Console, Effect, Exit, FiberRef, Option, Ref } from "effect";
1
+ import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
+ import { dual } from "effect/Function";
2
3
  import { mergeWith } from "es-toolkit/object";
3
4
  import { formatWithOptions } from "node:util";
4
5
  import type { PartialDeep } from "type-fest";
5
6
  import { makeProgressConsole } from "./console";
6
7
  import { runProgressServiceRenderer } from "./renderer";
8
+ import { ProgressTerminal } from "./terminal";
7
9
  import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
8
10
  import {
9
11
  decodeProgressBarConfigSync,
@@ -12,16 +14,14 @@ import {
12
14
  defaultRendererConfig,
13
15
  DeterminateTaskUnits,
14
16
  IndeterminateTaskUnits,
15
- Progress,
16
17
  ProgressBarConfig,
17
18
  RendererConfig,
19
+ Task,
18
20
  TaskId,
19
21
  TaskSnapshot,
20
22
  } from "./types";
21
23
  import { inferTotal } from "./utils";
22
24
 
23
- const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
24
-
25
25
  const mergeConfig = <T extends Record<PropertyKey, any>>(
26
26
  base: T,
27
27
  override: PartialDeep<T> | undefined,
@@ -80,7 +80,7 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
80
80
  });
81
81
  };
82
82
 
83
- export const makeProgressService = Effect.gen(function* () {
83
+ const makeProgressService = Effect.gen(function* () {
84
84
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
85
85
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
86
86
 
@@ -96,6 +96,8 @@ export const makeProgressService = Effect.gen(function* () {
96
96
  Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
97
97
  ),
98
98
  );
99
+ const terminal = yield* ProgressTerminal;
100
+ const isTTY = yield* terminal.isTTY;
99
101
  const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
100
102
 
101
103
  const nextTaskIdRef = yield* Ref.make(0);
@@ -103,9 +105,6 @@ export const makeProgressService = Effect.gen(function* () {
103
105
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
104
106
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
105
107
  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
108
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
110
109
  const scope = yield* Effect.scope;
111
110
 
@@ -115,29 +114,15 @@ export const makeProgressService = Effect.gen(function* () {
115
114
  logsRef,
116
115
  pendingLogsRef,
117
116
  dirtyRef,
117
+ terminal,
118
+ isTTY,
118
119
  rendererConfig,
119
120
  maxRetainedLogLines,
120
- rendererLatch,
121
121
  ),
122
122
  scope,
123
123
  );
124
124
 
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
- });
125
+ const markDirty = Ref.set(dirtyRef, true);
141
126
 
142
127
  const addTask = (options: AddTaskOptions) =>
143
128
  Effect.gen(function* () {
@@ -151,18 +136,11 @@ export const makeProgressService = Effect.gen(function* () {
151
136
  ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
152
137
  : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
153
138
  const tasks = yield* Ref.get(tasksRef);
154
- const parentSnapshot =
155
- Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
139
+ const parentSnapshot = Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
156
140
  const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
157
141
  const resolvedProgressBarConfig = decodeProgressBarConfigSync(
158
142
  mergeConfig(inheritedProgressBarConfig, options.progressbar),
159
143
  );
160
- const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
161
- started ? [false, true] : [true, true],
162
- );
163
- if (shouldOpenRenderer) {
164
- yield* rendererLatch.open;
165
- }
166
144
 
167
145
  const snapshot = new TaskSnapshot({
168
146
  id: taskId,
@@ -292,22 +270,16 @@ export const makeProgressService = Effect.gen(function* () {
292
270
  return next;
293
271
  }).pipe(Effect.zipRight(markDirty));
294
272
 
295
- const log = (...args: ReadonlyArray<unknown>) =>
273
+ const appendLog = (args: ReadonlyArray<unknown>) =>
296
274
  Effect.gen(function* () {
297
275
  if (args.length === 0) {
298
276
  return;
299
277
  }
300
278
 
301
- const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
302
- started ? [false, true] : [true, true],
303
- );
304
- if (shouldOpenRenderer) {
305
- yield* rendererLatch.open;
306
- }
307
-
279
+ // TODO: Might wanna replace this or make it configurable. Look for other options.
308
280
  const message = formatWithOptions(
309
281
  {
310
- colors: rendererConfig.isTTY,
282
+ colors: isTTY,
311
283
  depth: 6,
312
284
  },
313
285
  ...args,
@@ -327,58 +299,66 @@ export const makeProgressService = Effect.gen(function* () {
327
299
  yield* markDirty;
328
300
  });
329
301
 
330
- const withCapturedLogs: ProgressService["withCapturedLogs"] = (effect) =>
331
- Effect.gen(function* () {
332
- const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
333
- return yield* Effect.withConsole(effect, makeProgressConsole(log, outerConsole));
334
- });
302
+ const log = (...args: ReadonlyArray<unknown>) => appendLog(args);
335
303
 
336
304
  const getTask = (taskId: TaskId) =>
337
305
  Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
338
306
 
339
307
  const listTasks = Ref.get(tasksRef).pipe(Effect.map((tasks) => Array.from(tasks.values())));
340
308
 
341
- const withTask: ProgressService["withTask"] = (options, effect) =>
342
- Effect.gen(function* () {
343
- const inheritedParentId = yield* FiberRef.get(currentParentRef);
344
- const resolvedParentId =
345
- options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
346
-
347
- const taskId = yield* addTask({
348
- ...options,
349
- parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
350
- transient: options.transient ?? Option.isSome(resolvedParentId),
351
- });
309
+ const withTask: ProgressService["withTask"] = dual(
310
+ 2,
311
+ <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
312
+ Effect.gen(function* () {
313
+ const outerConsole = yield* Effect.console;
314
+ const inheritedParentId = yield* FiberRef.get(currentParentRef);
315
+ const resolvedParentId =
316
+ options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
317
+
318
+ const taskId = yield* addTask({
319
+ ...options,
320
+ parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
321
+ transient: options.transient ?? Option.isSome(resolvedParentId),
322
+ });
352
323
 
353
- const exit = yield* Effect.exit(
354
- Effect.locally(effect(taskId), currentParentRef, Option.some(taskId)),
355
- );
324
+ const exit = yield* Effect.exit(
325
+ Effect.locally(
326
+ Effect.withConsole(
327
+ Effect.provideService(effect, Task, taskId),
328
+ makeProgressConsole(log, outerConsole),
329
+ ),
330
+ currentParentRef,
331
+ Option.some(taskId),
332
+ ),
333
+ );
356
334
 
357
- if (Exit.isSuccess(exit)) {
358
- yield* completeTask(taskId);
359
- } else {
360
- yield* failTask(taskId);
361
- }
335
+ if (Exit.isSuccess(exit)) {
336
+ yield* completeTask(taskId);
337
+ } else {
338
+ yield* failTask(taskId);
339
+ }
362
340
 
363
- return yield* Exit.match(exit, {
364
- onFailure: Effect.failCause,
365
- onSuccess: Effect.succeed,
366
- });
367
- });
341
+ return yield* Exit.match(exit, {
342
+ onFailure: Effect.failCause,
343
+ onSuccess: Effect.succeed,
344
+ });
345
+ }),
346
+ );
368
347
 
369
348
  const trackIterable: ProgressService["trackIterable"] = (iterable, options, f) =>
370
349
  withTask(
350
+ Effect.gen(function* () {
351
+ const taskId = yield* Task;
352
+ return yield* Effect.forEach(iterable, (item, index) =>
353
+ Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
354
+ );
355
+ }),
371
356
  {
372
357
  description: options.description,
373
358
  total: options.total ?? inferTotal(iterable),
374
359
  transient: options.transient,
375
360
  progressbar: options.progressbar,
376
361
  },
377
- (taskId) => {
378
- return Effect.forEach(iterable, (item, index) =>
379
- Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
380
- );
381
- },
382
362
  );
383
363
 
384
364
  const service: ProgressService = {
@@ -388,7 +368,6 @@ export const makeProgressService = Effect.gen(function* () {
388
368
  completeTask,
389
369
  failTask,
390
370
  log,
391
- withCapturedLogs,
392
371
  getTask,
393
372
  listTasks,
394
373
  withTask,
@@ -397,3 +376,23 @@ export const makeProgressService = Effect.gen(function* () {
397
376
 
398
377
  return Progress.of(service);
399
378
  });
379
+
380
+ export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {
381
+ static readonly Default = Layer.scoped(Progress, makeProgressService);
382
+ }
383
+
384
+ export const provideProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
385
+ Effect.gen(function* () {
386
+ const existing = yield* Effect.serviceOption(Progress);
387
+ if (Option.isSome(existing)) {
388
+ return yield* Effect.provideService(effect, Progress, existing.value);
389
+ }
390
+
391
+ const existingTerminal = yield* Effect.serviceOption(ProgressTerminal);
392
+ if (Option.isSome(existingTerminal)) {
393
+ return yield* Effect.scoped(effect.pipe(Effect.provide(Progress.Default)));
394
+ }
395
+
396
+ const defaultLayers = Layer.provide(Progress.Default, ProgressTerminal.Default);
397
+ return yield* Effect.scoped(effect.pipe(Effect.provide(defaultLayers)));
398
+ });
@@ -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,25 @@ 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
- readonly withTask: <A, E, R>(
121
- options: AddTaskOptions,
122
- effect: (taskId: TaskId) => Effect.Effect<A, E, R>,
123
- ) => Effect.Effect<A, E, R>;
117
+ readonly withTask: {
118
+ <A, E, R>(
119
+ effect: Effect.Effect<A, E, R>,
120
+ options: AddTaskOptions,
121
+ ): Effect.Effect<A, E, Exclude<R, Task>>;
122
+ <A, E, R>(
123
+ options: AddTaskOptions,
124
+ ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Task>>;
125
+ };
124
126
  readonly trackIterable: <A, B, E, R>(
125
127
  iterable: Iterable<A>,
126
128
  options: TrackOptions,
127
129
  f: (item: A, index: number) => Effect.Effect<B, E, R>,
128
- ) => Effect.Effect<ReadonlyArray<B>, E, R>;
130
+ ) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Task>>;
129
131
  }
130
132
 
131
- export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {}
133
+ export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}
132
134
 
133
135
  export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
134
136
  taskId: TaskIdSchema,