effective-progress 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Magnus Alexander Strømseng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # effective-progress
2
+
3
+ `effective-progress` is an Effect-first terminal progress library with:
4
+
5
+ - multiple progress bars
6
+ - nested child progress bars
7
+ - spinner support for indeterminate work
8
+ - clean log rendering alongside progress output
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ bun add effective-progress
14
+ ```
15
+
16
+ This shows the simplest usage: iterate 100 items with a single progress bar.
17
+
18
+ ```ts
19
+ import { Console, Effect } from "effect";
20
+ import * as Progress from "effective-progress";
21
+
22
+ const program = Progress.all(
23
+ Array.from({ length: 5 }).map((_, i) =>
24
+ Effect.gen(function* () {
25
+ yield* Effect.sleep("1 second");
26
+ yield* Console.log(`Completed task ${i + 1}`);
27
+ }),
28
+ ),
29
+ { description: "Running tasks in parallel", all: { concurrency: 2 } },
30
+ );
31
+
32
+ Effect.runPromise(program);
33
+ ```
34
+
35
+ ```bash
36
+ bun run examples/basic.ts
37
+ Completed task 1
38
+ Completed task 2
39
+ - Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
40
+ ```
41
+
42
+ ## Nested example
43
+
44
+ Run:
45
+
46
+ ```bash
47
+ bun examples/nesting.ts
48
+ ```
49
+
50
+ This demonstrates nested multibar behavior where parent tasks each run their own child progress bars.
51
+
52
+ ```ts
53
+ import { Effect } from "effect";
54
+ import * as Progress from "effective-progress";
55
+
56
+ const program = Progress.all(
57
+ Array.from({ length: 5 }).map((_, i) =>
58
+ Effect.asVoid(
59
+ Progress.all(
60
+ Array.from({ length: 15 }).map((_) => Effect.sleep("100 millis")),
61
+ { description: `Running subtasks for task ${i + 1}` },
62
+ ),
63
+ ),
64
+ ),
65
+ { description: "Running tasks in parallel", all: { concurrency: 2 } },
66
+ );
67
+
68
+ Effect.runPromise(program);
69
+ ```
70
+
71
+ ```bash
72
+ ❯ bun run examples/nesting.ts
73
+ - Running tasks in parallel: ━━━━━━━━━━━━────────────────── 2/5 40%
74
+ - Running subtasks for task 3: ━━━━━━━━────────────────────── 4/15 27%
75
+ - Running subtasks for task 4: ━━━━━━━━────────────────────── 4/15 27%
76
+ ```
77
+
78
+ ## Other examples
79
+
80
+ - `examples/simpleExample.ts` - low-boilerplate real-world flow
81
+ - `examples/advancedExample.ts` - full API usage with custom config and manual task control
82
+
83
+ ## Notes
84
+
85
+ - This is a WIP library, so expect breaking changes. Feedback and contributions are very welcome!
86
+ - As Effect 4.0 is around the corner with some changes to logging, there may be some adjustments needed to align with the new Effect APIs.
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src";
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "effective-progress",
3
+ "version": "0.1.0",
4
+ "description": "Effect-first terminal progress bars with nested multibar support",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/stromseng/effective-progress.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/stromseng/effective-progress/issues"
12
+ },
13
+ "homepage": "https://github.com/stromseng/effective-progress#readme",
14
+ "type": "module",
15
+ "module": "index.ts",
16
+ "files": [
17
+ "index.ts",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "prepare": "effect-language-service patch"
27
+ },
28
+ "dependencies": {
29
+ "@effect/language-service": "^0.73.1",
30
+ "chalk": "^5.6.2",
31
+ "effect": "^3.19.16"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "oxlint": "^1.47.0"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ }
40
+ }
package/src/api.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { Console, Effect, Option } from "effect";
2
+ import { makeProgressConsole } from "./console";
3
+ import { makeProgressService } from "./runtime";
4
+ import { Progress } from "./types";
5
+ import type { TrackOptions } from "./types";
6
+
7
+ export interface TrackEffectsOptions {
8
+ readonly description: string;
9
+ readonly total?: number;
10
+ readonly transient?: boolean;
11
+ readonly all?: {
12
+ readonly concurrency?: number | "unbounded";
13
+ readonly batching?: boolean | "inherit";
14
+ readonly concurrentFinalizers?: boolean;
15
+ };
16
+ }
17
+
18
+ export interface ForEachOptions extends TrackOptions {
19
+ readonly forEach?: {
20
+ readonly concurrency?: number | "unbounded";
21
+ readonly batching?: boolean | "inherit";
22
+ readonly concurrentFinalizers?: boolean;
23
+ };
24
+ }
25
+
26
+ const inferTotal = (iterable: Iterable<unknown>): number | undefined => {
27
+ if (Array.isArray(iterable)) {
28
+ return iterable.length;
29
+ }
30
+
31
+ if (typeof iterable === "string") {
32
+ return iterable.length;
33
+ }
34
+
35
+ const candidate = iterable as { length?: unknown; size?: unknown };
36
+ if (typeof candidate.length === "number") {
37
+ return candidate.length;
38
+ }
39
+
40
+ if (typeof candidate.size === "number") {
41
+ return candidate.size;
42
+ }
43
+
44
+ return undefined;
45
+ };
46
+
47
+ export const withProgressService = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
48
+ Effect.gen(function* () {
49
+ const outerConsole = yield* Console.consoleWith((console) => Effect.succeed(console));
50
+ const existing = yield* Effect.serviceOption(Progress);
51
+ if (Option.isSome(existing)) {
52
+ const console = makeProgressConsole(existing.value, outerConsole);
53
+ return yield* Effect.withConsole(
54
+ Effect.provideService(effect, Progress, existing.value),
55
+ console,
56
+ );
57
+ }
58
+
59
+ return yield* Effect.scoped(
60
+ Effect.gen(function* () {
61
+ const service = yield* makeProgressService;
62
+ const console = makeProgressConsole(service, outerConsole);
63
+ return yield* Effect.withConsole(Effect.provideService(effect, Progress, service), console);
64
+ }),
65
+ );
66
+ });
67
+
68
+ export const all = <A, E, R>(
69
+ effects: ReadonlyArray<Effect.Effect<A, E, R>>,
70
+ options: TrackEffectsOptions,
71
+ ): Effect.Effect<ReadonlyArray<A>, E, Exclude<R, Progress>> =>
72
+ withProgressService(
73
+ Effect.gen(function* () {
74
+ const progress = yield* Progress;
75
+ return yield* progress.withTask(
76
+ {
77
+ description: options.description,
78
+ total: options.total ?? effects.length,
79
+ transient: options.transient,
80
+ },
81
+ (taskId) =>
82
+ Effect.forEach(
83
+ effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
84
+ (effect) => effect,
85
+ {
86
+ concurrency: options.all?.concurrency,
87
+ batching: options.all?.batching,
88
+ concurrentFinalizers: options.all?.concurrentFinalizers,
89
+ },
90
+ ),
91
+ );
92
+ }),
93
+ );
94
+
95
+ export const forEach = <A, B, E, R>(
96
+ iterable: Iterable<A>,
97
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
98
+ options: ForEachOptions,
99
+ ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
100
+ withProgressService(
101
+ Effect.gen(function* () {
102
+ const progress = yield* Progress;
103
+
104
+ const items = Array.from(iterable);
105
+ const total = options.total ?? inferTotal(iterable);
106
+
107
+ return yield* progress.withTask(
108
+ {
109
+ description: options.description,
110
+ total,
111
+ transient: options.transient,
112
+ },
113
+ (taskId) =>
114
+ Effect.forEach(
115
+ items,
116
+ (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
117
+ {
118
+ concurrency: options.forEach?.concurrency,
119
+ batching: options.forEach?.batching,
120
+ concurrentFinalizers: options.forEach?.concurrentFinalizers,
121
+ },
122
+ ),
123
+ );
124
+ }),
125
+ );
126
+
127
+ export const trackProgress = all;
128
+
129
+ export const track = <A, B, E, R>(
130
+ iterable: Iterable<A>,
131
+ options: TrackOptions,
132
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
133
+ ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress>> =>
134
+ forEach(iterable, f, options);
package/src/console.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { Console, Effect } from "effect";
2
+ import { formatWithOptions } from "node:util";
3
+ import type { ProgressService } from "./types";
4
+
5
+ export const makeProgressConsole = (
6
+ progress: ProgressService,
7
+ outerConsole: Console.Console,
8
+ ): Console.Console => {
9
+ const log = (...args: ReadonlyArray<unknown>) => progress.log(...args);
10
+ const unsafeLog = (...args: ReadonlyArray<unknown>) => {
11
+ Effect.runFork(progress.log(...args));
12
+ };
13
+
14
+ const delegate = (effect: Effect.Effect<void, never, never>) => effect;
15
+
16
+ return {
17
+ [Console.TypeId]: Console.TypeId,
18
+ assert(condition, ...args) {
19
+ return condition ? Effect.void : log("Assertion failed:", ...args);
20
+ },
21
+ clear: Effect.void,
22
+ count: (_label) => Effect.void,
23
+ countReset: (_label) => Effect.void,
24
+ debug: (...args) => log(...args),
25
+ dir: (item, options) => log(formatWithOptions(options ?? {}, "%O", item)),
26
+ dirxml: (...args) => log(...args),
27
+ error: (...args) => log(...args),
28
+ group: (...args) => log(...args),
29
+ groupEnd: Effect.void,
30
+ info: (...args) => log(...args),
31
+ log: (...args) => log(...args),
32
+ table: (tabularData, properties) => log(tabularData, properties),
33
+ time: (_label) => Effect.void,
34
+ timeEnd: (_label) => Effect.void,
35
+ timeLog: (_label, ...args) => log(...args),
36
+ trace: (...args) => delegate(outerConsole.trace(...args)),
37
+ warn: (...args) => log(...args),
38
+ unsafe: {
39
+ assert(condition, ...args) {
40
+ if (!condition) unsafeLog("Assertion failed:", ...args);
41
+ },
42
+ clear() {},
43
+ count(_label) {},
44
+ countReset(_label) {},
45
+ debug(...args) {
46
+ unsafeLog(...args);
47
+ },
48
+ dir(item, options) {
49
+ unsafeLog(formatWithOptions(options ?? {}, "%O", item));
50
+ },
51
+ dirxml(...args) {
52
+ unsafeLog(...args);
53
+ },
54
+ error(...args) {
55
+ unsafeLog(...args);
56
+ },
57
+ group(...args) {
58
+ unsafeLog(...args);
59
+ },
60
+ groupCollapsed(...args) {
61
+ unsafeLog(...args);
62
+ },
63
+ groupEnd() {},
64
+ info(...args) {
65
+ unsafeLog(...args);
66
+ },
67
+ log(...args) {
68
+ unsafeLog(...args);
69
+ },
70
+ table(tabularData, properties) {
71
+ unsafeLog(tabularData, properties);
72
+ },
73
+ time(_label) {},
74
+ timeEnd(_label) {},
75
+ timeLog(_label, ...args) {
76
+ unsafeLog(...args);
77
+ },
78
+ trace(...args) {
79
+ outerConsole.unsafe.trace(...args);
80
+ },
81
+ warn(...args) {
82
+ unsafeLog(...args);
83
+ },
84
+ },
85
+ };
86
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./api";
2
+ export * from "./runtime";
3
+ export * from "./types";
@@ -0,0 +1,270 @@
1
+ import { Effect, Ref } from "effect";
2
+ import chalk from "chalk";
3
+ import type { ProgressBarConfigShape } from "./types";
4
+ import {
5
+ DeterminateTaskUnits,
6
+ TaskId,
7
+ TaskSnapshot,
8
+ } from "./types";
9
+
10
+ const HIDE_CURSOR = "\x1b[?25l";
11
+ const SHOW_CURSOR = "\x1b[?25h";
12
+ const CLEAR_LINE = "\x1b[2K";
13
+ const MOVE_UP_ONE = "\x1b[1A";
14
+ const RENDER_INTERVAL = "80 millis";
15
+
16
+ export interface LogEntry {
17
+ readonly id: number;
18
+ readonly message: string;
19
+ }
20
+
21
+ const renderDeterminate = (units: DeterminateTaskUnits, config: ProgressBarConfigShape): string => {
22
+ const safeTotal = units.total <= 0 ? 1 : units.total;
23
+ const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
24
+ const filled = Math.round(ratio * config.barWidth);
25
+ const bar = `${chalk.cyan(config.fillChar.repeat(filled))}${chalk.dim(config.emptyChar.repeat(config.barWidth - filled))}`;
26
+ const percent = String(Math.round(ratio * 100)).padStart(3, " ");
27
+ return `${chalk.dim(config.leftBracket)}${bar}${chalk.dim(config.rightBracket)} ${units.completed}/${units.total} ${chalk.bold(percent + "%")}`;
28
+ };
29
+
30
+ const buildTaskLine = (
31
+ snapshot: TaskSnapshot,
32
+ depth: number,
33
+ tick: number,
34
+ config: ProgressBarConfigShape,
35
+ ): string => {
36
+ const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
37
+
38
+ if (snapshot.status === "failed") {
39
+ return `${prefix}${chalk.red("[failed]")}`;
40
+ }
41
+
42
+ if (snapshot.status === "done") {
43
+ if (snapshot.units._tag === "DeterminateTaskUnits") {
44
+ return `${prefix}${chalk.green("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
45
+ }
46
+ return `${prefix}${chalk.green("[done]")}`;
47
+ }
48
+
49
+ if (snapshot.units._tag === "DeterminateTaskUnits") {
50
+ return prefix + renderDeterminate(snapshot.units, config);
51
+ }
52
+
53
+ const frames = config.spinnerFrames;
54
+ const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
55
+ return `${prefix}${chalk.yellow(frames[frameIndex])}`;
56
+ };
57
+
58
+ const orderTasksForRender = (
59
+ tasks: ReadonlyArray<TaskSnapshot>,
60
+ ): ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }> => {
61
+ const byParent = new Map<number | null, Array<TaskSnapshot>>();
62
+ for (const task of tasks) {
63
+ const bucket = byParent.get(task.parentId) ?? [];
64
+ bucket.push(task);
65
+ byParent.set(task.parentId, bucket);
66
+ }
67
+
68
+ const ordered: Array<{ snapshot: TaskSnapshot; depth: number }> = [];
69
+ const visit = (parentId: number | null, depth: number) => {
70
+ const children = byParent.get(parentId) ?? [];
71
+ for (const child of children) {
72
+ ordered.push({ snapshot: child, depth });
73
+ visit(child.id, depth + 1);
74
+ }
75
+ };
76
+
77
+ visit(null, 0);
78
+ return ordered;
79
+ };
80
+
81
+ export const runProgressServiceRenderer = (
82
+ tasksRef: Ref.Ref<Map<TaskId, TaskSnapshot>>,
83
+ logsRef: Ref.Ref<ReadonlyArray<LogEntry>>,
84
+ dirtyRef: Ref.Ref<boolean>,
85
+ config: ProgressBarConfigShape,
86
+ ) => {
87
+ const isTTY = config.isTTY;
88
+ let previousLineCount = 0;
89
+ let nonTTYLastLogId = 0;
90
+ let nonTTYTaskSignatureById = new Map<number, string>();
91
+ let tick = 0;
92
+ let teardownInput: (() => void) | undefined;
93
+
94
+ return Effect.gen(function* () {
95
+ const clearTTY = () => {
96
+ let output = "\r" + CLEAR_LINE;
97
+ for (let i = 1; i < previousLineCount; i++) {
98
+ output += MOVE_UP_ONE + CLEAR_LINE;
99
+ }
100
+ process.stderr.write(output + "\r");
101
+ previousLineCount = 0;
102
+ };
103
+
104
+ const renderFrame = (mode: "tick" | "final") =>
105
+ Effect.gen(function* () {
106
+ const logs = yield* Ref.get(logsRef);
107
+ const snapshots = Array.from((yield* Ref.get(tasksRef)).values()).filter(
108
+ (task) => !(task.transient && task.status !== "running"),
109
+ );
110
+ const ordered = orderTasksForRender(snapshots);
111
+ const frameTick = mode === "final" ? tick + 1 : tick;
112
+ const taskLines = ordered.map(({ snapshot, depth }) => {
113
+ const lineTick = isTTY ? frameTick : 0;
114
+ return buildTaskLine(snapshot, depth, lineTick, config);
115
+ });
116
+ const logLines = logs.map((log) => log.message);
117
+ const lines = [...logLines, ...taskLines];
118
+
119
+ if (isTTY) {
120
+ clearTTY();
121
+ if (lines.length > 0) {
122
+ process.stderr.write(lines.join("\n"));
123
+ previousLineCount = lines.length;
124
+ }
125
+ } else {
126
+ const appendedLogs = logs.filter((log) => log.id > nonTTYLastLogId);
127
+ if (appendedLogs.length > 0) {
128
+ process.stderr.write(appendedLogs.map((log) => log.message).join("\n") + "\n");
129
+ nonTTYLastLogId = appendedLogs[appendedLogs.length - 1]?.id ?? nonTTYLastLogId;
130
+ }
131
+
132
+ const nextTaskSignatureById = new Map<number, string>();
133
+ const changedTaskLines: Array<string> = [];
134
+ const nonTtyUpdateStep = Math.max(1, Math.floor(config.nonTtyUpdateStep));
135
+
136
+ for (let i = 0; i < ordered.length; i++) {
137
+ const taskId = ordered[i]!.snapshot.id as number;
138
+ const snapshot = ordered[i]!.snapshot;
139
+ const line = taskLines[i]!;
140
+ const signature =
141
+ snapshot.units._tag === "DeterminateTaskUnits"
142
+ ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
143
+ : `${snapshot.status}:${snapshot.description}`;
144
+
145
+ nextTaskSignatureById.set(taskId, signature);
146
+ if (nonTTYTaskSignatureById.get(taskId) !== signature) {
147
+ changedTaskLines.push(line);
148
+ }
149
+ }
150
+
151
+ if (changedTaskLines.length > 0) {
152
+ process.stderr.write(changedTaskLines.join("\n") + "\n");
153
+ }
154
+
155
+ nonTTYTaskSignatureById = nextTaskSignatureById;
156
+ }
157
+ });
158
+
159
+ if (isTTY) {
160
+ process.stderr.write(HIDE_CURSOR);
161
+
162
+ if (config.disableUserInput && process.stdin.isTTY) {
163
+ const stdin = process.stdin;
164
+ const wasRaw = Boolean(stdin.isRaw);
165
+ stdin.resume();
166
+ stdin.setRawMode?.(true);
167
+
168
+ const onData = (chunk: Buffer) => {
169
+ if (chunk.length === 1 && chunk[0] === 3) {
170
+ process.kill(process.pid, "SIGINT");
171
+ }
172
+ };
173
+
174
+ stdin.on("data", onData);
175
+
176
+ teardownInput = () => {
177
+ try {
178
+ stdin.off("data", onData);
179
+ stdin.setRawMode?.(wasRaw);
180
+ stdin.pause();
181
+ } catch {
182
+ // Best effort terminal restoration.
183
+ }
184
+ };
185
+ }
186
+ }
187
+
188
+ while (true) {
189
+ const dirty = yield* Ref.getAndSet(dirtyRef, false);
190
+ const hasActiveSpinners = yield* Ref.get(tasksRef).pipe(
191
+ Effect.map((tasks) =>
192
+ Array.from(tasks.values()).some(
193
+ (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
194
+ ),
195
+ ),
196
+ );
197
+
198
+ if (dirty || hasActiveSpinners) {
199
+ yield* renderFrame("tick");
200
+ }
201
+
202
+ tick += 1;
203
+ yield* Effect.sleep(RENDER_INTERVAL);
204
+ }
205
+ }).pipe(
206
+ Effect.ensuring(
207
+ Effect.gen(function* () {
208
+ const logs = yield* Ref.get(logsRef);
209
+ const snapshots = Array.from((yield* Ref.get(tasksRef)).values()).filter(
210
+ (task) => !(task.transient && task.status !== "running"),
211
+ );
212
+ const ordered = orderTasksForRender(snapshots);
213
+ const taskLines = ordered.map(({ snapshot, depth }) =>
214
+ buildTaskLine(snapshot, depth, tick + 1, config),
215
+ );
216
+ const logLines = logs.map((log) => log.message);
217
+ const lines = [...logLines, ...taskLines];
218
+
219
+ if (isTTY) {
220
+ let output = "\r" + CLEAR_LINE;
221
+ for (let i = 1; i < previousLineCount; i++) {
222
+ output += MOVE_UP_ONE + CLEAR_LINE;
223
+ }
224
+
225
+ if (lines.length > 0) {
226
+ process.stderr.write(output + "\r" + lines.join("\n"));
227
+ } else {
228
+ process.stderr.write(output + "\r");
229
+ }
230
+ } else {
231
+ const appendedLogs = logs.filter((log) => log.id > nonTTYLastLogId);
232
+ if (appendedLogs.length > 0) {
233
+ process.stderr.write(appendedLogs.map((log) => log.message).join("\n") + "\n");
234
+ nonTTYLastLogId = appendedLogs[appendedLogs.length - 1]?.id ?? nonTTYLastLogId;
235
+ }
236
+
237
+ const nextTaskSignatureById = new Map<number, string>();
238
+ const changedTaskLines: Array<string> = [];
239
+ const nonTtyUpdateStep = Math.max(1, Math.floor(config.nonTtyUpdateStep));
240
+
241
+ for (let i = 0; i < ordered.length; i++) {
242
+ const taskId = ordered[i]!.snapshot.id as number;
243
+ const snapshot = ordered[i]!.snapshot;
244
+ const line = taskLines[i]!;
245
+ const signature =
246
+ snapshot.units._tag === "DeterminateTaskUnits"
247
+ ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
248
+ : `${snapshot.status}:${snapshot.description}`;
249
+
250
+ nextTaskSignatureById.set(taskId, signature);
251
+ if (nonTTYTaskSignatureById.get(taskId) !== signature) {
252
+ changedTaskLines.push(line);
253
+ }
254
+ }
255
+
256
+ if (changedTaskLines.length > 0) {
257
+ process.stderr.write(changedTaskLines.join("\n") + "\n");
258
+ }
259
+
260
+ nonTTYTaskSignatureById = nextTaskSignatureById;
261
+ }
262
+
263
+ if (isTTY) {
264
+ teardownInput?.();
265
+ process.stderr.write("\n" + SHOW_CURSOR);
266
+ }
267
+ }),
268
+ ),
269
+ );
270
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,339 @@
1
+ import { Effect, Exit, FiberRef, Option, Ref } from "effect";
2
+ import { formatWithOptions } from "node:util";
3
+ import { runProgressServiceRenderer } from "./renderer";
4
+ import type {
5
+ AddTaskOptions,
6
+ ProgressService,
7
+ TrackOptions,
8
+ UpdateTaskOptions,
9
+ } from "./types";
10
+ import {
11
+ defaultProgressBarConfig,
12
+ DeterminateTaskUnits,
13
+ IndeterminateTaskUnits,
14
+ ProgressBarConfig,
15
+ TaskId,
16
+ TaskSnapshot,
17
+ } from "./types";
18
+
19
+ const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
20
+
21
+ const inferTotal = (iterable: Iterable<unknown>): number | undefined => {
22
+ if (Array.isArray(iterable)) {
23
+ return iterable.length;
24
+ }
25
+
26
+ if (typeof iterable === "string") {
27
+ return iterable.length;
28
+ }
29
+
30
+ const candidate = iterable as { length?: unknown; size?: unknown };
31
+ if (typeof candidate.length === "number") {
32
+ return candidate.length;
33
+ }
34
+
35
+ if (typeof candidate.size === "number") {
36
+ return candidate.size;
37
+ }
38
+
39
+ return undefined;
40
+ };
41
+
42
+ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): TaskSnapshot => {
43
+ const currentUnits = snapshot.units;
44
+ const units = (() => {
45
+ if (options.total !== undefined) {
46
+ if (options.total <= 0) {
47
+ return new IndeterminateTaskUnits({ spinnerFrame: 0 });
48
+ }
49
+
50
+ const completed =
51
+ options.completed ??
52
+ (currentUnits._tag === "DeterminateTaskUnits" ? currentUnits.completed : 0);
53
+
54
+ return new DeterminateTaskUnits({
55
+ completed: Math.max(0, completed),
56
+ total: Math.max(0, options.total),
57
+ });
58
+ }
59
+
60
+ if (currentUnits._tag === "DeterminateTaskUnits") {
61
+ if (options.completed === undefined) {
62
+ return currentUnits;
63
+ }
64
+
65
+ return new DeterminateTaskUnits({
66
+ completed: Math.max(0, options.completed),
67
+ total: currentUnits.total,
68
+ });
69
+ }
70
+
71
+ return currentUnits;
72
+ })();
73
+
74
+ return new TaskSnapshot({
75
+ id: snapshot.id,
76
+ parentId: snapshot.parentId,
77
+ description: options.description ?? snapshot.description,
78
+ status: snapshot.status,
79
+ transient: options.transient ?? snapshot.transient,
80
+ units,
81
+ });
82
+ };
83
+
84
+ export const makeProgressService = Effect.gen(function* () {
85
+ const configOption = yield* Effect.serviceOption(ProgressBarConfig);
86
+ const config = Option.getOrElse(configOption, () => defaultProgressBarConfig);
87
+
88
+ const nextTaskIdRef = yield* Ref.make(0);
89
+ const tasksRef = yield* Ref.make(new Map<TaskId, TaskSnapshot>());
90
+ const logsRef = yield* Ref.make<ReadonlyArray<{ id: number; message: string }>>([]);
91
+ const dirtyRef = yield* Ref.make(true);
92
+ const dirtyScheduledRef = yield* Ref.make(false);
93
+ const nextLogIdRef = yield* Ref.make(0);
94
+ const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
95
+
96
+ yield* Effect.forkScoped(runProgressServiceRenderer(tasksRef, logsRef, dirtyRef, config));
97
+
98
+ const markDirty = Effect.gen(function* () {
99
+ const shouldSchedule = yield* Ref.modify(dirtyScheduledRef, (scheduled) =>
100
+ scheduled ? [false, true] : [true, true],
101
+ );
102
+
103
+ if (!shouldSchedule) {
104
+ return;
105
+ }
106
+
107
+ yield* Effect.forkDaemon(
108
+ Effect.sleep(DIRTY_DEBOUNCE_INTERVAL).pipe(
109
+ Effect.zipRight(Ref.set(dirtyRef, true)),
110
+ Effect.ensuring(Ref.set(dirtyScheduledRef, false)),
111
+ ),
112
+ );
113
+ });
114
+
115
+ const addTask = (options: AddTaskOptions) =>
116
+ Effect.gen(function* () {
117
+ const parentId =
118
+ options.parentId === undefined
119
+ ? yield* FiberRef.get(currentParentRef)
120
+ : Option.some(options.parentId);
121
+ const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
122
+ const units =
123
+ options.total === undefined || options.total <= 0
124
+ ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
125
+ : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
126
+
127
+ const snapshot = new TaskSnapshot({
128
+ id: taskId,
129
+ parentId: Option.getOrNull(parentId),
130
+ description: options.description,
131
+ status: "running",
132
+ transient: options.transient ?? false,
133
+ units,
134
+ });
135
+
136
+ yield* Ref.update(tasksRef, (tasks) => {
137
+ const next = new Map(tasks);
138
+ next.set(taskId, snapshot);
139
+ return next;
140
+ });
141
+ yield* markDirty;
142
+
143
+ return taskId;
144
+ });
145
+
146
+ const updateTask = (taskId: TaskId, options: UpdateTaskOptions) =>
147
+ Ref.update(tasksRef, (tasks) => {
148
+ const snapshot = tasks.get(taskId);
149
+ if (!snapshot) {
150
+ return tasks;
151
+ }
152
+
153
+ const next = new Map(tasks);
154
+ next.set(taskId, updatedSnapshot(snapshot, options));
155
+ return next;
156
+ }).pipe(Effect.zipRight(markDirty));
157
+
158
+ const advanceTask = (taskId: TaskId, amount = 1) =>
159
+ Ref.update(tasksRef, (tasks) => {
160
+ const snapshot = tasks.get(taskId);
161
+ if (!snapshot) {
162
+ return tasks;
163
+ }
164
+
165
+ const next = new Map(tasks);
166
+ const units =
167
+ snapshot.units._tag === "DeterminateTaskUnits"
168
+ ? new DeterminateTaskUnits({
169
+ completed: Math.min(snapshot.units.total, snapshot.units.completed + amount),
170
+ total: snapshot.units.total,
171
+ })
172
+ : new IndeterminateTaskUnits({
173
+ spinnerFrame:
174
+ (snapshot.units.spinnerFrame + amount) % Math.max(1, config.spinnerFrames.length),
175
+ });
176
+
177
+ next.set(
178
+ taskId,
179
+ new TaskSnapshot({
180
+ id: snapshot.id,
181
+ parentId: snapshot.parentId,
182
+ description: snapshot.description,
183
+ status: snapshot.status,
184
+ transient: snapshot.transient,
185
+ units,
186
+ }),
187
+ );
188
+
189
+ return next;
190
+ }).pipe(Effect.zipRight(markDirty));
191
+
192
+ const completeTask = (taskId: TaskId) =>
193
+ Ref.update(tasksRef, (tasks) => {
194
+ const snapshot = tasks.get(taskId);
195
+ if (!snapshot) {
196
+ return tasks;
197
+ }
198
+
199
+ const next = new Map(tasks);
200
+ if (snapshot.transient) {
201
+ next.delete(taskId);
202
+ return next;
203
+ }
204
+
205
+ next.set(
206
+ taskId,
207
+ new TaskSnapshot({
208
+ id: snapshot.id,
209
+ parentId: snapshot.parentId,
210
+ description: snapshot.description,
211
+ status: "done",
212
+ transient: snapshot.transient,
213
+ units:
214
+ snapshot.units._tag === "DeterminateTaskUnits"
215
+ ? new DeterminateTaskUnits({
216
+ completed: snapshot.units.total,
217
+ total: snapshot.units.total,
218
+ })
219
+ : snapshot.units,
220
+ }),
221
+ );
222
+ return next;
223
+ }).pipe(Effect.zipRight(markDirty));
224
+
225
+ const failTask = (taskId: TaskId) =>
226
+ Ref.update(tasksRef, (tasks) => {
227
+ const snapshot = tasks.get(taskId);
228
+ if (!snapshot) {
229
+ return tasks;
230
+ }
231
+
232
+ const next = new Map(tasks);
233
+ if (snapshot.transient) {
234
+ next.delete(taskId);
235
+ return next;
236
+ }
237
+
238
+ next.set(
239
+ taskId,
240
+ new TaskSnapshot({
241
+ id: snapshot.id,
242
+ parentId: snapshot.parentId,
243
+ description: snapshot.description,
244
+ status: "failed",
245
+ transient: snapshot.transient,
246
+ units: snapshot.units,
247
+ }),
248
+ );
249
+ return next;
250
+ }).pipe(Effect.zipRight(markDirty));
251
+
252
+ const log = (...args: ReadonlyArray<unknown>) =>
253
+ Effect.gen(function* () {
254
+ const id = yield* Ref.updateAndGet(nextLogIdRef, (current) => current + 1);
255
+ const message = formatWithOptions(
256
+ {
257
+ colors: config.isTTY,
258
+ depth: 6,
259
+ },
260
+ ...args,
261
+ );
262
+
263
+ yield* Ref.update(logsRef, (logs) => {
264
+ const maxLogLines = Math.floor(config.maxLogLines);
265
+ const next = [...logs, { id, message }];
266
+ if (maxLogLines <= 0) {
267
+ return next;
268
+ }
269
+ if (next.length <= maxLogLines) {
270
+ return next;
271
+ }
272
+ return next.slice(next.length - maxLogLines);
273
+ });
274
+ yield* markDirty;
275
+ });
276
+
277
+ const getTask = (taskId: TaskId) =>
278
+ Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
279
+
280
+ const listTasks = Ref.get(tasksRef).pipe(Effect.map((tasks) => Array.from(tasks.values())));
281
+
282
+ const withTask: ProgressService["withTask"] = (options, effect) =>
283
+ Effect.gen(function* () {
284
+ const inheritedParentId = yield* FiberRef.get(currentParentRef);
285
+ const resolvedParentId =
286
+ options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
287
+
288
+ const taskId = yield* addTask({
289
+ ...options,
290
+ parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
291
+ transient: options.transient ?? Option.isSome(resolvedParentId),
292
+ });
293
+
294
+ const exit = yield* Effect.exit(
295
+ Effect.locally(effect(taskId), currentParentRef, Option.some(taskId)),
296
+ );
297
+
298
+ if (Exit.isSuccess(exit)) {
299
+ yield* completeTask(taskId);
300
+ } else {
301
+ yield* failTask(taskId);
302
+ }
303
+
304
+ return yield* Exit.match(exit, {
305
+ onFailure: Effect.failCause,
306
+ onSuccess: Effect.succeed,
307
+ });
308
+ });
309
+
310
+ const trackIterable: ProgressService["trackIterable"] = (iterable, options, f) =>
311
+ withTask(
312
+ {
313
+ description: options.description,
314
+ total: options.total ?? inferTotal(iterable),
315
+ transient: options.transient,
316
+ },
317
+ (taskId) => {
318
+ const items = Array.from(iterable);
319
+ return Effect.forEach(items, (item, index) =>
320
+ Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
321
+ );
322
+ },
323
+ );
324
+
325
+ const service: ProgressService = {
326
+ addTask,
327
+ updateTask,
328
+ advanceTask,
329
+ completeTask,
330
+ failTask,
331
+ log,
332
+ getTask,
333
+ listTasks,
334
+ withTask,
335
+ trackIterable,
336
+ };
337
+
338
+ return service;
339
+ });
package/src/types.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { Brand, Context, Effect, Option, Schema } from "effect";
2
+
3
+ export const SPINNER_FRAMES = ["-", "\\", "|", "/"] as const;
4
+ export const DEFAULT_PROGRESS_BAR_WIDTH = 30;
5
+
6
+ export const ProgressBarConfigSchema = Schema.Struct({
7
+ isTTY: Schema.Boolean,
8
+ disableUserInput: Schema.Boolean,
9
+ spinnerFrames: Schema.NonEmptyArray(Schema.String),
10
+ barWidth: Schema.Number,
11
+ fillChar: Schema.String,
12
+ emptyChar: Schema.String,
13
+ leftBracket: Schema.String,
14
+ rightBracket: Schema.String,
15
+ maxLogLines: Schema.Number,
16
+ nonTtyUpdateStep: Schema.Number,
17
+ });
18
+
19
+ export type ProgressBarConfigShape = typeof ProgressBarConfigSchema.Type;
20
+
21
+ export const defaultProgressBarConfig: ProgressBarConfigShape = {
22
+ isTTY: Boolean(process.stderr.isTTY),
23
+ disableUserInput: true,
24
+ spinnerFrames: SPINNER_FRAMES,
25
+ barWidth: DEFAULT_PROGRESS_BAR_WIDTH,
26
+ fillChar: "━",
27
+ emptyChar: "─",
28
+ leftBracket: "",
29
+ rightBracket: "",
30
+ maxLogLines: 0,
31
+ nonTtyUpdateStep: 5,
32
+ };
33
+
34
+ export class ProgressBarConfig extends Context.Tag("stromseng.dev/ProgressBarConfig")<
35
+ ProgressBarConfig,
36
+ ProgressBarConfigShape
37
+ >() {}
38
+
39
+ const TaskIdSchema = Schema.Number.pipe(Schema.brand("TaskId"));
40
+
41
+ export type TaskId = typeof TaskIdSchema.Type;
42
+ export const TaskId = Brand.nominal<TaskId>();
43
+
44
+ export const TaskStatusSchema = Schema.Literal("running", "done", "failed");
45
+
46
+ export type TaskStatus = typeof TaskStatusSchema.Type;
47
+
48
+ export interface AddTaskOptions {
49
+ readonly description: string;
50
+ readonly total?: number;
51
+ readonly transient?: boolean;
52
+ readonly parentId?: TaskId;
53
+ }
54
+
55
+ export interface UpdateTaskOptions {
56
+ readonly description?: string;
57
+ readonly completed?: number;
58
+ readonly total?: number;
59
+ readonly transient?: boolean;
60
+ }
61
+
62
+ export interface TrackOptions {
63
+ readonly description: string;
64
+ readonly total?: number;
65
+ readonly transient?: boolean;
66
+ }
67
+
68
+ export class DeterminateTaskUnits extends Schema.TaggedClass<DeterminateTaskUnits>()(
69
+ "DeterminateTaskUnits",
70
+ {
71
+ completed: Schema.Number,
72
+ total: Schema.Number,
73
+ },
74
+ ) {}
75
+
76
+ export class IndeterminateTaskUnits extends Schema.TaggedClass<IndeterminateTaskUnits>()(
77
+ "IndeterminateTaskUnits",
78
+ {
79
+ spinnerFrame: Schema.Number,
80
+ },
81
+ ) {}
82
+
83
+ export const TaskUnitsSchema = Schema.Union(DeterminateTaskUnits, IndeterminateTaskUnits);
84
+
85
+ export type TaskUnits = typeof TaskUnitsSchema.Type;
86
+
87
+ export class TaskSnapshot extends Schema.TaggedClass<TaskSnapshot>()("TaskSnapshot", {
88
+ id: TaskIdSchema,
89
+ parentId: Schema.NullOr(TaskIdSchema),
90
+ description: Schema.String,
91
+ status: TaskStatusSchema,
92
+ transient: Schema.Boolean,
93
+ units: TaskUnitsSchema,
94
+ }) {}
95
+
96
+ export interface ProgressService {
97
+ readonly addTask: (options: AddTaskOptions) => Effect.Effect<TaskId>;
98
+ readonly updateTask: (taskId: TaskId, options: UpdateTaskOptions) => Effect.Effect<void>;
99
+ readonly advanceTask: (taskId: TaskId, amount?: number) => Effect.Effect<void>;
100
+ readonly completeTask: (taskId: TaskId) => Effect.Effect<void>;
101
+ readonly failTask: (taskId: TaskId) => Effect.Effect<void>;
102
+ readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void>;
103
+ readonly getTask: (taskId: TaskId) => Effect.Effect<Option.Option<TaskSnapshot>>;
104
+ readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
105
+ readonly withTask: <A, E, R>(
106
+ options: AddTaskOptions,
107
+ effect: (taskId: TaskId) => Effect.Effect<A, E, R>,
108
+ ) => Effect.Effect<A, E, R>;
109
+ readonly trackIterable: <A, B, E, R>(
110
+ iterable: Iterable<A>,
111
+ options: TrackOptions,
112
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
113
+ ) => Effect.Effect<ReadonlyArray<B>, E, R>;
114
+ }
115
+
116
+ export class Progress extends Context.Tag("stromseng.dev/Progress")<Progress, ProgressService>() {}
117
+
118
+ export class TaskAddedEvent extends Schema.TaggedClass<TaskAddedEvent>()("TaskAdded", {
119
+ taskId: TaskIdSchema,
120
+ parentId: Schema.NullOr(TaskIdSchema),
121
+ description: Schema.String,
122
+ total: Schema.optional(Schema.Number),
123
+ transient: Schema.Boolean,
124
+ }) {}
125
+
126
+ export class TaskUpdatedEvent extends Schema.TaggedClass<TaskUpdatedEvent>()("TaskUpdated", {
127
+ taskId: TaskIdSchema,
128
+ description: Schema.optional(Schema.String),
129
+ completed: Schema.optional(Schema.Number),
130
+ total: Schema.optional(Schema.Number),
131
+ transient: Schema.optional(Schema.Boolean),
132
+ }) {}
133
+
134
+ export class TaskAdvancedEvent extends Schema.TaggedClass<TaskAdvancedEvent>()("TaskAdvanced", {
135
+ taskId: TaskIdSchema,
136
+ amount: Schema.Number,
137
+ }) {}
138
+
139
+ export class TaskCompletedEvent extends Schema.TaggedClass<TaskCompletedEvent>()("TaskCompleted", {
140
+ taskId: TaskIdSchema,
141
+ }) {}
142
+
143
+ export class TaskFailedEvent extends Schema.TaggedClass<TaskFailedEvent>()("TaskFailed", {
144
+ taskId: TaskIdSchema,
145
+ }) {}
146
+
147
+ export class TaskRemovedEvent extends Schema.TaggedClass<TaskRemovedEvent>()("TaskRemoved", {
148
+ taskId: TaskIdSchema,
149
+ }) {}
150
+
151
+ export const ProgressTaskEventSchema = Schema.Union(
152
+ TaskAddedEvent,
153
+ TaskUpdatedEvent,
154
+ TaskAdvancedEvent,
155
+ TaskCompletedEvent,
156
+ TaskFailedEvent,
157
+ TaskRemovedEvent,
158
+ );
159
+
160
+ export type ProgressTaskEvent = typeof ProgressTaskEventSchema.Type;
161
+
162
+ export const decodeProgressTaskEvent = Schema.decodeUnknownSync(ProgressTaskEventSchema);
163
+
164
+ export const isIndeterminateTask = (snapshot: TaskSnapshot) =>
165
+ snapshot.units._tag === "IndeterminateTaskUnits";
166
+
167
+ export const nextSpinnerFrame = (index: number) => (index + 1) % SPINNER_FRAMES.length;