effective-progress 0.1.0 → 0.1.3

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/src/renderer.ts CHANGED
@@ -1,58 +1,59 @@
1
- import { Effect, Ref } from "effect";
2
- import chalk from "chalk";
3
- import type { ProgressBarConfigShape } from "./types";
1
+ import { Effect, Ref, Schema } from "effect";
4
2
  import {
5
- DeterminateTaskUnits,
6
- TaskId,
7
- TaskSnapshot,
8
- } from "./types";
3
+ type CompiledProgressBarColors,
4
+ compileProgressBarColors,
5
+ ProgressBarColorsSchema,
6
+ } from "./colors";
7
+ import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
8
+ import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
9
9
 
10
10
  const HIDE_CURSOR = "\x1b[?25l";
11
11
  const SHOW_CURSOR = "\x1b[?25h";
12
12
  const CLEAR_LINE = "\x1b[2K";
13
13
  const MOVE_UP_ONE = "\x1b[1A";
14
- const RENDER_INTERVAL = "80 millis";
14
+ const encodeProgressBarColorsKey = Schema.encodeSync(Schema.parseJson(ProgressBarColorsSchema));
15
15
 
16
- export interface LogEntry {
17
- readonly id: number;
18
- readonly message: string;
19
- }
20
-
21
- const renderDeterminate = (units: DeterminateTaskUnits, config: ProgressBarConfigShape): string => {
16
+ const renderDeterminate = (
17
+ units: DeterminateTaskUnits,
18
+ progressbar: ProgressBarConfigShape,
19
+ colors: CompiledProgressBarColors,
20
+ ): string => {
22
21
  const safeTotal = units.total <= 0 ? 1 : units.total;
23
22
  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))}`;
23
+ const filled = Math.round(ratio * progressbar.barWidth);
24
+ const bar = `${colors.fill(progressbar.fillChar.repeat(filled))}${colors.empty(progressbar.emptyChar.repeat(progressbar.barWidth - filled))}`;
26
25
  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 + "%")}`;
26
+ return `${colors.brackets(progressbar.leftBracket)}${bar}${colors.brackets(progressbar.rightBracket)} ${units.completed}/${units.total} ${colors.percent(percent + "%")}`;
28
27
  };
29
28
 
30
29
  const buildTaskLine = (
31
30
  snapshot: TaskSnapshot,
32
31
  depth: number,
33
32
  tick: number,
34
- config: ProgressBarConfigShape,
33
+ colors: CompiledProgressBarColors,
35
34
  ): string => {
35
+ const progressbar = snapshot.progressbar;
36
36
  const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
37
37
 
38
38
  if (snapshot.status === "failed") {
39
- return `${prefix}${chalk.red("[failed]")}`;
39
+ return `${prefix}${colors.failed("[failed]")}`;
40
40
  }
41
41
 
42
42
  if (snapshot.status === "done") {
43
43
  if (snapshot.units._tag === "DeterminateTaskUnits") {
44
- return `${prefix}${chalk.green("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
44
+ return `${prefix}${colors.done("[done]")} ${snapshot.units.completed}/${snapshot.units.total}`;
45
45
  }
46
- return `${prefix}${chalk.green("[done]")}`;
46
+ return `${prefix}${colors.done("[done]")}`;
47
47
  }
48
48
 
49
49
  if (snapshot.units._tag === "DeterminateTaskUnits") {
50
- return prefix + renderDeterminate(snapshot.units, config);
50
+ return prefix + renderDeterminate(snapshot.units, progressbar, colors);
51
51
  }
52
52
 
53
- const frames = config.spinnerFrames;
53
+ const frames = progressbar.spinnerFrames;
54
54
  const frameIndex = (snapshot.units.spinnerFrame + tick) % frames.length;
55
- return `${prefix}${chalk.yellow(frames[frameIndex])}`;
55
+ const frame = frames[frameIndex] ?? frames[0]!;
56
+ return `${prefix}${colors.spinner(frame)}`;
56
57
  };
57
58
 
58
59
  const orderTasksForRender = (
@@ -80,86 +81,127 @@ const orderTasksForRender = (
80
81
 
81
82
  export const runProgressServiceRenderer = (
82
83
  tasksRef: Ref.Ref<Map<TaskId, TaskSnapshot>>,
83
- logsRef: Ref.Ref<ReadonlyArray<LogEntry>>,
84
+ logsRef: Ref.Ref<ReadonlyArray<string>>,
85
+ pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
84
86
  dirtyRef: Ref.Ref<boolean>,
85
- config: ProgressBarConfigShape,
87
+ rendererConfig: RendererConfigShape,
88
+ maxRetainedLogLines: number,
89
+ rendererLatch: Effect.Latch,
86
90
  ) => {
87
- const isTTY = config.isTTY;
91
+ const isTTY = rendererConfig.isTTY;
92
+ const retainLogHistory = maxRetainedLogLines > 0;
93
+ const colorCache = new Map<string, CompiledProgressBarColors>();
88
94
  let previousLineCount = 0;
89
- let nonTTYLastLogId = 0;
95
+ let previousTaskLineCount = 0;
90
96
  let nonTTYTaskSignatureById = new Map<number, string>();
91
97
  let tick = 0;
98
+ let rendererActive = false;
92
99
  let teardownInput: (() => void) | undefined;
93
100
 
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;
101
+ const getCompiledColors = (progressbar: ProgressBarConfigShape): CompiledProgressBarColors => {
102
+ const key = encodeProgressBarColorsKey(progressbar.colors);
103
+ const cached = colorCache.get(key);
104
+ if (cached) {
105
+ return cached;
106
+ }
107
+
108
+ const compiled = compileProgressBarColors(progressbar.colors);
109
+ colorCache.set(key, compiled);
110
+ return compiled;
111
+ };
112
+
113
+ const clearTTYLines = (lineCount: number) => {
114
+ if (lineCount <= 0) {
115
+ return;
116
+ }
117
+
118
+ let output = "\r" + CLEAR_LINE;
119
+ for (let i = 1; i < lineCount; i++) {
120
+ output += MOVE_UP_ONE + CLEAR_LINE;
121
+ }
122
+ process.stderr.write(output + "\r");
123
+ };
124
+
125
+ const renderNonTTYTaskUpdates = (
126
+ ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>,
127
+ taskLines: ReadonlyArray<string>,
128
+ ) => {
129
+ const nextTaskSignatureById = new Map<number, string>();
130
+ const changedTaskLines: Array<string> = [];
131
+ const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
132
+
133
+ for (let i = 0; i < ordered.length; i++) {
134
+ const taskId = ordered[i]!.snapshot.id as number;
135
+ const snapshot = ordered[i]!.snapshot;
136
+ const line = taskLines[i]!;
137
+ const signature =
138
+ snapshot.units._tag === "DeterminateTaskUnits"
139
+ ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
140
+ : `${snapshot.status}:${snapshot.description}`;
141
+
142
+ nextTaskSignatureById.set(taskId, signature);
143
+ if (nonTTYTaskSignatureById.get(taskId) !== signature) {
144
+ changedTaskLines.push(line);
99
145
  }
100
- process.stderr.write(output + "\r");
101
- previousLineCount = 0;
102
- };
146
+ }
103
147
 
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];
148
+ if (changedTaskLines.length > 0) {
149
+ process.stderr.write(changedTaskLines.join("\n") + "\n");
150
+ }
118
151
 
119
- if (isTTY) {
120
- clearTTY();
152
+ nonTTYTaskSignatureById = nextTaskSignatureById;
153
+ };
154
+
155
+ const renderFrame = (mode: "tick" | "final") =>
156
+ Effect.gen(function* () {
157
+ const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
158
+ const snapshots = Array.from((yield* Ref.get(tasksRef)).values()).filter(
159
+ (task) => !(task.transient && task.status !== "running"),
160
+ );
161
+ const ordered = orderTasksForRender(snapshots);
162
+ const frameTick = mode === "final" ? tick + 1 : tick;
163
+ const taskLines = ordered.map(({ snapshot, depth }) => {
164
+ const lineTick = isTTY ? frameTick : 0;
165
+ return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.progressbar));
166
+ });
167
+
168
+ if (isTTY) {
169
+ if (retainLogHistory) {
170
+ const historyLogs = yield* Ref.get(logsRef);
171
+ const lines = [...historyLogs, ...taskLines];
172
+ clearTTYLines(previousLineCount);
121
173
  if (lines.length > 0) {
122
174
  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
175
  }
176
+ previousLineCount = lines.length;
177
+ return;
178
+ }
131
179
 
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
- }
180
+ clearTTYLines(previousTaskLineCount);
181
+ if (drainedLogs.length > 0) {
182
+ process.stderr.write(drainedLogs.join("\n") + "\n");
183
+ }
184
+ if (taskLines.length > 0) {
185
+ process.stderr.write(taskLines.join("\n"));
186
+ }
187
+ previousTaskLineCount = taskLines.length;
188
+ return;
189
+ }
150
190
 
151
- if (changedTaskLines.length > 0) {
152
- process.stderr.write(changedTaskLines.join("\n") + "\n");
153
- }
191
+ if (drainedLogs.length > 0) {
192
+ process.stderr.write(drainedLogs.join("\n") + "\n");
193
+ }
194
+ renderNonTTYTaskUpdates(ordered, taskLines);
195
+ });
154
196
 
155
- nonTTYTaskSignatureById = nextTaskSignatureById;
156
- }
157
- });
197
+ return Effect.gen(function* () {
198
+ yield* rendererLatch.await;
199
+ rendererActive = true;
158
200
 
159
201
  if (isTTY) {
160
202
  process.stderr.write(HIDE_CURSOR);
161
203
 
162
- if (config.disableUserInput && process.stdin.isTTY) {
204
+ if (rendererConfig.disableUserInput && process.stdin.isTTY) {
163
205
  const stdin = process.stdin;
164
206
  const wasRaw = Boolean(stdin.isRaw);
165
207
  stdin.resume();
@@ -200,66 +242,17 @@ export const runProgressServiceRenderer = (
200
242
  }
201
243
 
202
244
  tick += 1;
203
- yield* Effect.sleep(RENDER_INTERVAL);
245
+ yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
204
246
  }
205
247
  }).pipe(
206
248
  Effect.ensuring(
207
249
  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;
250
+ if (!rendererActive) {
251
+ return;
261
252
  }
262
253
 
254
+ yield* renderFrame("final");
255
+
263
256
  if (isTTY) {
264
257
  teardownInput?.();
265
258
  process.stderr.write("\n" + SHOW_CURSOR);
package/src/runtime.ts CHANGED
@@ -1,43 +1,41 @@
1
- import { Effect, Exit, FiberRef, Option, Ref } from "effect";
1
+ import { Console, Effect, Exit, FiberRef, Option, Ref } from "effect";
2
+ import { mergeWith } from "es-toolkit/object";
2
3
  import { formatWithOptions } from "node:util";
4
+ import type { PartialDeep } from "type-fest";
5
+ import { makeProgressConsole } from "./console";
3
6
  import { runProgressServiceRenderer } from "./renderer";
4
- import type {
5
- AddTaskOptions,
6
- ProgressService,
7
- TrackOptions,
8
- UpdateTaskOptions,
9
- } from "./types";
7
+ import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
10
8
  import {
9
+ decodeProgressBarConfigSync,
10
+ decodeRendererConfigSync,
11
11
  defaultProgressBarConfig,
12
+ defaultRendererConfig,
12
13
  DeterminateTaskUnits,
13
14
  IndeterminateTaskUnits,
15
+ Progress,
14
16
  ProgressBarConfig,
17
+ RendererConfig,
15
18
  TaskId,
16
19
  TaskSnapshot,
17
20
  } from "./types";
21
+ import { inferTotal } from "./utils";
18
22
 
19
23
  const DIRTY_DEBOUNCE_INTERVAL = "10 millis";
20
24
 
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
- };
25
+ const mergeConfig = <T extends Record<PropertyKey, any>>(
26
+ base: T,
27
+ override: PartialDeep<T> | undefined,
28
+ ): T =>
29
+ mergeWith(
30
+ structuredClone(base),
31
+ (override ?? {}) as Record<PropertyKey, any>,
32
+ (_targetValue, sourceValue) => {
33
+ if (Array.isArray(sourceValue)) {
34
+ return sourceValue;
35
+ }
36
+ return undefined;
37
+ },
38
+ ) as T;
41
39
 
42
40
  const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): TaskSnapshot => {
43
41
  const currentUnits = snapshot.units;
@@ -78,22 +76,51 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
78
76
  status: snapshot.status,
79
77
  transient: options.transient ?? snapshot.transient,
80
78
  units,
79
+ progressbar: snapshot.progressbar,
81
80
  });
82
81
  };
83
82
 
84
83
  export const makeProgressService = Effect.gen(function* () {
85
- const configOption = yield* Effect.serviceOption(ProgressBarConfig);
86
- const config = Option.getOrElse(configOption, () => defaultProgressBarConfig);
84
+ const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
85
+ const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
86
+
87
+ const rendererConfig = decodeRendererConfigSync(
88
+ mergeConfig(
89
+ defaultRendererConfig,
90
+ Option.isSome(rendererConfigOption) ? rendererConfigOption.value : undefined,
91
+ ),
92
+ );
93
+ const progressBarConfig = decodeProgressBarConfigSync(
94
+ mergeConfig(
95
+ defaultProgressBarConfig,
96
+ Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
97
+ ),
98
+ );
99
+ const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
87
100
 
88
101
  const nextTaskIdRef = yield* Ref.make(0);
89
102
  const tasksRef = yield* Ref.make(new Map<TaskId, TaskSnapshot>());
90
- const logsRef = yield* Ref.make<ReadonlyArray<{ id: number; message: string }>>([]);
103
+ const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
104
+ const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
91
105
  const dirtyRef = yield* Ref.make(true);
92
106
  const dirtyScheduledRef = yield* Ref.make(false);
93
- const nextLogIdRef = yield* Ref.make(0);
107
+ const rendererStartedRef = yield* Ref.make(false);
108
+ const rendererLatch = yield* Effect.makeLatch(false);
94
109
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
95
-
96
- yield* Effect.forkScoped(runProgressServiceRenderer(tasksRef, logsRef, dirtyRef, config));
110
+ const scope = yield* Effect.scope;
111
+
112
+ yield* Effect.forkIn(
113
+ runProgressServiceRenderer(
114
+ tasksRef,
115
+ logsRef,
116
+ pendingLogsRef,
117
+ dirtyRef,
118
+ rendererConfig,
119
+ maxRetainedLogLines,
120
+ rendererLatch,
121
+ ),
122
+ scope,
123
+ );
97
124
 
98
125
  const markDirty = Effect.gen(function* () {
99
126
  const shouldSchedule = yield* Ref.modify(dirtyScheduledRef, (scheduled) =>
@@ -123,6 +150,19 @@ export const makeProgressService = Effect.gen(function* () {
123
150
  options.total === undefined || options.total <= 0
124
151
  ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
125
152
  : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
153
+ const tasks = yield* Ref.get(tasksRef);
154
+ const parentSnapshot =
155
+ Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
156
+ const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
157
+ const resolvedProgressBarConfig = decodeProgressBarConfigSync(
158
+ mergeConfig(inheritedProgressBarConfig, options.progressbar),
159
+ );
160
+ const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
161
+ started ? [false, true] : [true, true],
162
+ );
163
+ if (shouldOpenRenderer) {
164
+ yield* rendererLatch.open;
165
+ }
126
166
 
127
167
  const snapshot = new TaskSnapshot({
128
168
  id: taskId,
@@ -131,6 +171,7 @@ export const makeProgressService = Effect.gen(function* () {
131
171
  status: "running",
132
172
  transient: options.transient ?? false,
133
173
  units,
174
+ progressbar: resolvedProgressBarConfig,
134
175
  });
135
176
 
136
177
  yield* Ref.update(tasksRef, (tasks) => {
@@ -170,8 +211,7 @@ export const makeProgressService = Effect.gen(function* () {
170
211
  total: snapshot.units.total,
171
212
  })
172
213
  : new IndeterminateTaskUnits({
173
- spinnerFrame:
174
- (snapshot.units.spinnerFrame + amount) % Math.max(1, config.spinnerFrames.length),
214
+ spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount),
175
215
  });
176
216
 
177
217
  next.set(
@@ -183,6 +223,7 @@ export const makeProgressService = Effect.gen(function* () {
183
223
  status: snapshot.status,
184
224
  transient: snapshot.transient,
185
225
  units,
226
+ progressbar: snapshot.progressbar,
186
227
  }),
187
228
  );
188
229
 
@@ -217,6 +258,7 @@ export const makeProgressService = Effect.gen(function* () {
217
258
  total: snapshot.units.total,
218
259
  })
219
260
  : snapshot.units,
261
+ progressbar: snapshot.progressbar,
220
262
  }),
221
263
  );
222
264
  return next;
@@ -244,6 +286,7 @@ export const makeProgressService = Effect.gen(function* () {
244
286
  status: "failed",
245
287
  transient: snapshot.transient,
246
288
  units: snapshot.units,
289
+ progressbar: snapshot.progressbar,
247
290
  }),
248
291
  );
249
292
  return next;
@@ -251,29 +294,45 @@ export const makeProgressService = Effect.gen(function* () {
251
294
 
252
295
  const log = (...args: ReadonlyArray<unknown>) =>
253
296
  Effect.gen(function* () {
254
- const id = yield* Ref.updateAndGet(nextLogIdRef, (current) => current + 1);
297
+ if (args.length === 0) {
298
+ return;
299
+ }
300
+
301
+ const shouldOpenRenderer = yield* Ref.modify(rendererStartedRef, (started) =>
302
+ started ? [false, true] : [true, true],
303
+ );
304
+ if (shouldOpenRenderer) {
305
+ yield* rendererLatch.open;
306
+ }
307
+
255
308
  const message = formatWithOptions(
256
309
  {
257
- colors: config.isTTY,
310
+ colors: rendererConfig.isTTY,
258
311
  depth: 6,
259
312
  },
260
313
  ...args,
261
314
  );
262
315
 
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
- });
316
+ yield* Ref.update(pendingLogsRef, (logs) => [...logs, message]);
317
+ if (maxRetainedLogLines > 0) {
318
+ yield* Ref.update(logsRef, (logs) => {
319
+ const next = [...logs, message];
320
+ if (next.length <= maxRetainedLogLines) {
321
+ return next;
322
+ }
323
+ return next.slice(next.length - maxRetainedLogLines);
324
+ });
325
+ }
326
+
274
327
  yield* markDirty;
275
328
  });
276
329
 
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
+ });
335
+
277
336
  const getTask = (taskId: TaskId) =>
278
337
  Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
279
338
 
@@ -313,10 +372,10 @@ export const makeProgressService = Effect.gen(function* () {
313
372
  description: options.description,
314
373
  total: options.total ?? inferTotal(iterable),
315
374
  transient: options.transient,
375
+ progressbar: options.progressbar,
316
376
  },
317
377
  (taskId) => {
318
- const items = Array.from(iterable);
319
- return Effect.forEach(items, (item, index) =>
378
+ return Effect.forEach(iterable, (item, index) =>
320
379
  Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
321
380
  );
322
381
  },
@@ -329,11 +388,12 @@ export const makeProgressService = Effect.gen(function* () {
329
388
  completeTask,
330
389
  failTask,
331
390
  log,
391
+ withCapturedLogs,
332
392
  getTask,
333
393
  listTasks,
334
394
  withTask,
335
395
  trackIterable,
336
396
  };
337
397
 
338
- return service;
398
+ return Progress.of(service);
339
399
  });