effective-progress 0.4.2 → 0.4.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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  - multiple nested tree-like progress bars
17
17
  - spinner support for “we have no idea how long this takes” work
18
- - clean log rendering alongside progress output, so you can keep using `Console.log` / `Effect.logInfo` without wrecking the UI
18
+ - keep using `Console.log` / `Effect.logInfo` while raw console calls are buffered and replayed through your existing `Console` / logger between frame renders
19
19
  - familiar `.all` and `.forEach` APIs — swap `Effect` for `Progress`, get progress bars basically for free
20
20
  - flicker-free rendering (in theory) by drawing everything in a single terminal frame
21
21
 
@@ -88,11 +88,11 @@ Effect.runPromise(program);
88
88
 
89
89
  ## Configuration
90
90
 
91
- ### Log retention
91
+ ### Console replay
92
92
 
93
- - `maxLogLines` on `RendererConfig` controls in-memory log retention.
94
- - Omitted or set to `0` means no log history is kept in memory.
95
- - `maxLogLines > 0` keeps only the latest `N` log lines in memory.
93
+ - `Progress.task`, `Progress.all`, and `Progress.forEach` buffer `Console.log`/`Console.dir` calls and replay them through the outer `Console`.
94
+ - Calls are replayed between progress frame renders to avoid tearing the TTY frame.
95
+ - Formatting is controlled by the API consumer's logger/console implementation.
96
96
 
97
97
  ### Configuring renderer and progress bars
98
98
 
@@ -111,7 +111,6 @@ import * as Progress from "effective-progress";
111
111
  const configured = program.pipe(
112
112
  Effect.provideService(Progress.RendererConfig, {
113
113
  width: 80,
114
- maxLogLines: 12,
115
114
  nonTtyUpdateStep: 2,
116
115
  }),
117
116
  Effect.provideService(Progress.ProgressBarConfig, {
@@ -180,13 +179,13 @@ yield *
180
179
 
181
180
  ## Manual task control
182
181
 
183
- For manual usage, `task` captures logs implicitly and provides the current `Task` context:
182
+ For manual usage, `task` still provides the current `Task` context, while logs continue through your outer `Console`:
184
183
 
185
184
  ```ts
186
185
  const program = Progress.task(
187
186
  Effect.gen(function* () {
188
187
  const currentTask = yield* Progress.Task;
189
- yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
188
+ yield* Console.log("This log is handled by the outer Console", { taskId: currentTask });
190
189
  yield* Effect.sleep("1 second");
191
190
  }),
192
191
  { description: "Manual task" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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": {
@@ -19,6 +19,9 @@
19
19
  ],
20
20
  "type": "module",
21
21
  "module": "index.ts",
22
+ "engines": {
23
+ "node": ">=20.12.0"
24
+ },
22
25
  "publishConfig": {
23
26
  "access": "public"
24
27
  },
@@ -30,18 +33,18 @@
30
33
  "format:check": "oxfmt --check ."
31
34
  },
32
35
  "dependencies": {
33
- "chalk": "^5.6.2",
34
- "effect": "^3.19.17",
35
36
  "es-toolkit": "^1.44.0",
36
37
  "type-fest": "^5.4.4"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@effect/language-service": "^0.73.1",
40
41
  "@types/bun": "latest",
42
+ "effect": "^3.19.17",
41
43
  "oxfmt": "^0.32.0",
42
- "oxlint": "^1.47.0"
44
+ "oxlint": "^1.47.0",
45
+ "typescript": "^5"
43
46
  },
44
47
  "peerDependencies": {
45
- "typescript": "^5"
48
+ "effect": "^3.19.17"
46
49
  }
47
50
  }
package/src/console.ts CHANGED
@@ -1,85 +1,177 @@
1
- import { Console, Effect } from "effect";
2
- import { formatWithOptions } from "node:util";
1
+ import { Console, Effect, Ref } from "effect";
3
2
 
4
- export const makeProgressConsole = (
5
- progressLog: (...args: ReadonlyArray<unknown>) => Effect.Effect<void, never, never>,
3
+ export type BufferedConsoleMethod =
4
+ | "assert"
5
+ | "debug"
6
+ | "dir"
7
+ | "dirxml"
8
+ | "error"
9
+ | "group"
10
+ | "groupCollapsed"
11
+ | "groupEnd"
12
+ | "info"
13
+ | "log"
14
+ | "table"
15
+ | "trace"
16
+ | "warn";
17
+
18
+ export interface BufferedConsoleCall {
19
+ readonly method: BufferedConsoleMethod;
20
+ readonly args: ReadonlyArray<unknown>;
21
+ readonly unsafe: boolean;
22
+ }
23
+
24
+ export interface ConsoleBridge {
25
+ readonly progressConsole: Console.Console;
26
+ readonly appendLog: (call: BufferedConsoleCall) => Effect.Effect<void, never, never>;
27
+ readonly replayLogs: (
28
+ logs: ReadonlyArray<BufferedConsoleCall>,
29
+ ) => Effect.Effect<void, never, never>;
30
+ readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void, never, never>;
31
+ }
32
+
33
+ const replayBufferedConsoleCall = (
6
34
  outerConsole: Console.Console,
35
+ call: BufferedConsoleCall,
36
+ ): Effect.Effect<void, never, never> => {
37
+ type DirOptions = Parameters<Console.Console["dir"]>[1];
38
+ type GroupOptions = Parameters<Console.Console["group"]>[0];
39
+
40
+ switch (call.method) {
41
+ case "assert": {
42
+ const [condition, ...rest] = call.args;
43
+ return outerConsole.assert(condition as boolean, ...rest);
44
+ }
45
+ case "debug":
46
+ return outerConsole.debug(...call.args);
47
+ case "dir":
48
+ return outerConsole.dir(call.args[0], call.args[1] as DirOptions);
49
+ case "dirxml":
50
+ return outerConsole.dirxml(...call.args);
51
+ case "error":
52
+ return outerConsole.error(...call.args);
53
+ case "group":
54
+ return outerConsole.group(call.args[0] as GroupOptions);
55
+ case "groupCollapsed":
56
+ return outerConsole.group(call.args[0] as GroupOptions);
57
+ case "groupEnd":
58
+ return outerConsole.groupEnd;
59
+ case "info":
60
+ return outerConsole.info(...call.args);
61
+ case "log":
62
+ return outerConsole.log(...call.args);
63
+ case "table":
64
+ return outerConsole.table(call.args[0], call.args[1] as ReadonlyArray<string> | undefined);
65
+ case "trace":
66
+ return outerConsole.trace(...call.args);
67
+ case "warn":
68
+ return outerConsole.warn(...call.args);
69
+ }
70
+ };
71
+
72
+ export const makeProgressConsole = (
73
+ appendLog: (call: BufferedConsoleCall) => Effect.Effect<void, never, never>,
7
74
  ): Console.Console => {
8
- const log = (...args: ReadonlyArray<unknown>) => progressLog(...args);
9
- const unsafeLog = (...args: ReadonlyArray<unknown>) => {
10
- Effect.runFork(progressLog(...args));
75
+ const log = (method: BufferedConsoleMethod, ...args: ReadonlyArray<unknown>) =>
76
+ appendLog({ method, args, unsafe: false });
77
+ const unsafeLog = (method: BufferedConsoleMethod, ...args: ReadonlyArray<unknown>) => {
78
+ Effect.runSync(appendLog({ method, args, unsafe: true }));
11
79
  };
12
80
 
13
- const delegate = (effect: Effect.Effect<void, never, never>) => effect;
14
-
15
81
  return Console.Console.of({
16
82
  [Console.TypeId]: Console.TypeId,
17
- assert(condition, ...args) {
18
- return condition ? Effect.void : log("Assertion failed:", ...args);
19
- },
83
+ assert: (condition, ...args) => log("assert", condition, ...args),
20
84
  clear: Effect.void,
21
85
  count: (_label) => Effect.void,
22
86
  countReset: (_label) => Effect.void,
23
- debug: (...args) => log(...args),
24
- dir: (item, options) => log(formatWithOptions(options ?? {}, "%O", item)),
25
- dirxml: (...args) => log(...args),
26
- error: (...args) => log(...args),
27
- group: (...args) => log(...args),
28
- groupEnd: Effect.void,
29
- info: (...args) => log(...args),
30
- log: (...args) => log(...args),
31
- table: (tabularData, properties) => log(tabularData, properties),
87
+ debug: (...args) => log("debug", ...args),
88
+ dir: (item, options) => log("dir", item, options),
89
+ dirxml: (...args) => log("dirxml", ...args),
90
+ error: (...args) => log("error", ...args),
91
+ group: (...args) => log("group", ...args),
92
+ groupEnd: log("groupEnd"),
93
+ info: (...args) => log("info", ...args),
94
+ log: (...args) => log("log", ...args),
95
+ table: (tabularData, properties) => log("table", tabularData, properties),
32
96
  time: (_label) => Effect.void,
33
97
  timeEnd: (_label) => Effect.void,
34
- timeLog: (_label, ...args) => log(...args),
35
- trace: (...args) => delegate(outerConsole.trace(...args)),
36
- warn: (...args) => log(...args),
98
+ timeLog: (_label, ...args) => log("info", ...args),
99
+ trace: (...args) => log("trace", ...args),
100
+ warn: (...args) => log("warn", ...args),
37
101
  unsafe: {
38
102
  assert(condition, ...args) {
39
- if (!condition) unsafeLog("Assertion failed:", ...args);
103
+ unsafeLog("assert", condition, ...args);
40
104
  },
41
105
  clear() {},
42
106
  count(_label) {},
43
107
  countReset(_label) {},
44
108
  debug(...args) {
45
- unsafeLog(...args);
109
+ unsafeLog("debug", ...args);
46
110
  },
47
111
  dir(item, options) {
48
- unsafeLog(formatWithOptions(options ?? {}, "%O", item));
112
+ unsafeLog("dir", item, options);
49
113
  },
50
114
  dirxml(...args) {
51
- unsafeLog(...args);
115
+ unsafeLog("dirxml", ...args);
52
116
  },
53
117
  error(...args) {
54
- unsafeLog(...args);
118
+ unsafeLog("error", ...args);
55
119
  },
56
120
  group(...args) {
57
- unsafeLog(...args);
121
+ unsafeLog("group", ...args);
58
122
  },
59
123
  groupCollapsed(...args) {
60
- unsafeLog(...args);
124
+ unsafeLog("groupCollapsed", ...args);
125
+ },
126
+ groupEnd() {
127
+ unsafeLog("groupEnd");
61
128
  },
62
- groupEnd() {},
63
129
  info(...args) {
64
- unsafeLog(...args);
130
+ unsafeLog("info", ...args);
65
131
  },
66
132
  log(...args) {
67
- unsafeLog(...args);
133
+ unsafeLog("log", ...args);
68
134
  },
69
135
  table(tabularData, properties) {
70
- unsafeLog(tabularData, properties);
136
+ unsafeLog("table", tabularData, properties);
71
137
  },
72
138
  time(_label) {},
73
139
  timeEnd(_label) {},
74
140
  timeLog(_label, ...args) {
75
- unsafeLog(...args);
141
+ unsafeLog("info", ...args);
76
142
  },
77
143
  trace(...args) {
78
- outerConsole.unsafe.trace(...args);
144
+ unsafeLog("trace", ...args);
79
145
  },
80
146
  warn(...args) {
81
- unsafeLog(...args);
147
+ unsafeLog("warn", ...args);
82
148
  },
83
149
  },
84
150
  });
85
151
  };
152
+
153
+ export const makeConsoleBridge = (
154
+ outerConsole: Console.Console,
155
+ pendingLogsRef: Ref.Ref<ReadonlyArray<BufferedConsoleCall>>,
156
+ markDirty: Effect.Effect<void, never, never>,
157
+ ): ConsoleBridge => {
158
+ const appendLog = (call: BufferedConsoleCall) =>
159
+ call.args.length === 0
160
+ ? Effect.void
161
+ : Ref.update(pendingLogsRef, (logs) => [...logs, call]).pipe(Effect.zipRight(markDirty));
162
+
163
+ const replayLogs = (logs: ReadonlyArray<BufferedConsoleCall>) =>
164
+ Effect.forEach(logs, (call) => replayBufferedConsoleCall(outerConsole, call), {
165
+ discard: true,
166
+ });
167
+
168
+ const log = (...args: ReadonlyArray<unknown>) =>
169
+ args.length === 0 ? Effect.void : appendLog({ method: "log", args, unsafe: false });
170
+
171
+ return {
172
+ progressConsole: makeProgressConsole(appendLog),
173
+ appendLog,
174
+ replayLogs,
175
+ log,
176
+ };
177
+ };
package/src/renderer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import chalk from "chalk";
1
+ import { styleText } from "node:util";
2
2
  import { Clock, Context, Effect, Layer, Ref } from "effect";
3
3
  import { fitRenderedText, visibleWidth } from "./renderer/ansi";
4
4
  import { computeTreeInfo, renderTreePrefix } from "./renderer/tree";
@@ -81,8 +81,13 @@ const formatEta = (snapshot: TaskSnapshot, now: number): string => {
81
81
  const reserveTimeWidth = (formattedDuration: string): string =>
82
82
  formattedDuration.padStart(RESERVED_SECONDS_WIDTH, " ");
83
83
 
84
- const styleIfTTY = (isTTY: boolean, style: (text: string) => string, text: string): string =>
85
- isTTY ? style(text) : text;
84
+ type StyleFormat = Parameters<typeof styleText>[0];
85
+
86
+ const applyStyle = (format: StyleFormat, text: string): string =>
87
+ styleText(format, text, { validateStream: false });
88
+
89
+ const styleIfTTY = (isTTY: boolean, format: StyleFormat, text: string): string =>
90
+ isTTY ? applyStyle(format, text) : text;
86
91
 
87
92
  interface ColumnBaseOptions {
88
93
  readonly id?: string;
@@ -220,19 +225,19 @@ export class BarColumn implements ProgressColumn {
220
225
  const fill = config.fillChar.repeat(filled);
221
226
  const empty = config.emptyChar.repeat(Math.max(0, innerWidth - filled));
222
227
 
223
- const fillStyle =
228
+ const fillStyle: StyleFormat =
224
229
  context.task.status === "failed"
225
- ? chalk.red
230
+ ? "red"
226
231
  : context.task.status === "done"
227
- ? chalk.green
228
- : chalk.blue;
229
- const emptyStyle = context.task.status === "failed" ? chalk.red : chalk.white.dim;
232
+ ? "green"
233
+ : "blue";
234
+ const emptyStyle: StyleFormat = context.task.status === "failed" ? "red" : ["white", "dim"];
230
235
 
231
236
  return [
232
- styleIfTTY(context.isTTY, chalk.white.dim, config.leftBracket),
237
+ styleIfTTY(context.isTTY, ["white", "dim"], config.leftBracket),
233
238
  styleIfTTY(context.isTTY, fillStyle, fill),
234
239
  styleIfTTY(context.isTTY, emptyStyle, empty),
235
- styleIfTTY(context.isTTY, chalk.white.dim, config.rightBracket),
240
+ styleIfTTY(context.isTTY, ["white", "dim"], config.rightBracket),
236
241
  ].join("");
237
242
  }
238
243
 
@@ -299,7 +304,7 @@ export class AmountColumn implements ProgressColumn {
299
304
  if (task.units._tag === "DeterminateTaskUnits") {
300
305
  return styleIfTTY(
301
306
  context.isTTY,
302
- task.status === "failed" ? chalk.red : chalk.whiteBright,
307
+ task.status === "failed" ? "red" : "whiteBright",
303
308
  formatDeterminateUnits(task.units.completed, task.units.total),
304
309
  );
305
310
  }
@@ -308,14 +313,14 @@ export class AmountColumn implements ProgressColumn {
308
313
  const frames = task.config.spinnerFrames;
309
314
  const frameIndex = (task.units.spinnerFrame + context.tick) % frames.length;
310
315
  const frame = frames[frameIndex] ?? frames[0] ?? "";
311
- return styleIfTTY(context.isTTY, chalk.yellow, frame);
316
+ return styleIfTTY(context.isTTY, "yellow", frame);
312
317
  }
313
318
 
314
319
  if (task.status === "done") {
315
- return styleIfTTY(context.isTTY, chalk.green, this.doneSymbol);
320
+ return styleIfTTY(context.isTTY, "green", this.doneSymbol);
316
321
  }
317
322
 
318
- return styleIfTTY(context.isTTY, chalk.red, this.failedSymbol);
323
+ return styleIfTTY(context.isTTY, "red", this.failedSymbol);
319
324
  }
320
325
  }
321
326
 
@@ -353,7 +358,7 @@ export class ElapsedColumn implements ProgressColumn {
353
358
  render(context: ProgressColumnContext): string {
354
359
  const raw = formatElapsed(context.task, context.now);
355
360
  const elapsed = this.padSeconds ? reserveTimeWidth(raw) : raw;
356
- return styleIfTTY(context.isTTY, chalk.gray, elapsed);
361
+ return styleIfTTY(context.isTTY, "gray", elapsed);
357
362
  }
358
363
  }
359
364
 
@@ -421,7 +426,7 @@ export class EtaColumn implements ProgressColumn {
421
426
  }
422
427
 
423
428
  render(context: ProgressColumnContext): string {
424
- return styleIfTTY(context.isTTY, chalk.gray, this.resolveEtaText(context, true));
429
+ return styleIfTTY(context.isTTY, "gray", this.resolveEtaText(context, true));
425
430
  }
426
431
 
427
432
  variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
@@ -438,17 +443,17 @@ export class EtaColumn implements ProgressColumn {
438
443
  if (compact === full) {
439
444
  return [
440
445
  {
441
- render: () => styleIfTTY(context.isTTY, chalk.gray, full),
446
+ render: () => styleIfTTY(context.isTTY, "gray", full),
442
447
  },
443
448
  ];
444
449
  }
445
450
 
446
451
  return [
447
452
  {
448
- render: () => styleIfTTY(context.isTTY, chalk.gray, full),
453
+ render: () => styleIfTTY(context.isTTY, "gray", full),
449
454
  },
450
455
  {
451
- render: () => styleIfTTY(context.isTTY, chalk.gray, compact),
456
+ render: () => styleIfTTY(context.isTTY, "gray", compact),
452
457
  },
453
458
  ];
454
459
  }
@@ -1002,31 +1007,20 @@ const renderTaskFrame = (
1002
1007
  };
1003
1008
 
1004
1009
  export interface FrameRendererService {
1005
- readonly run: (
1010
+ readonly run: <LogEntry>(
1006
1011
  storeRef: Ref.Ref<TaskStore>,
1007
- logsRef: Ref.Ref<ReadonlyArray<string>>,
1008
- pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
1012
+ pendingLogsRef: Ref.Ref<ReadonlyArray<LogEntry>>,
1013
+ replayLogs: (logs: ReadonlyArray<LogEntry>) => Effect.Effect<void, never, never>,
1009
1014
  dirtyRef: Ref.Ref<boolean>,
1010
1015
  terminal: ProgressTerminalService,
1011
1016
  isTTY: boolean,
1012
1017
  rendererConfig: RendererConfigShape,
1013
- maxRetainedLogLines: number,
1014
1018
  ) => Effect.Effect<void>;
1015
1019
  }
1016
1020
 
1017
1021
  const makeDefaultFrameRenderer = (): FrameRendererService => ({
1018
- run: (
1019
- storeRef,
1020
- logsRef,
1021
- pendingLogsRef,
1022
- dirtyRef,
1023
- terminal,
1024
- isTTY,
1025
- rendererConfig,
1026
- maxRetainedLogLines,
1027
- ) =>
1022
+ run: (storeRef, pendingLogsRef, replayLogs, dirtyRef, terminal, isTTY, rendererConfig) =>
1028
1023
  Effect.gen(function* () {
1029
- const retainLogHistory = maxRetainedLogLines > 0;
1030
1024
  const compiledColumns = normalizeColumns(rendererConfig.columns);
1031
1025
  let previousLineCount = 0;
1032
1026
  let nonTTYTaskSignatureById = new Map<number, string>();
@@ -1152,33 +1146,27 @@ const makeDefaultFrameRenderer = (): FrameRendererService => ({
1152
1146
  for (let i = 1; i < previousLineCount; i++) {
1153
1147
  frame += MOVE_UP_ONE + CLEAR_LINE;
1154
1148
  }
1155
- }
1156
-
1157
- if (retainLogHistory) {
1158
- const historyLogs = yield* Ref.get(logsRef);
1159
- const lines = yield* clipTTYFrameLines([...historyLogs, ...renderedFrame.lines]);
1160
- if (lines.length > 0) {
1161
- frame += lines.join("\n");
1162
- }
1163
- previousLineCount = lines.length;
1164
- } else {
1165
- if (drainedLogs.length > 0) {
1166
- frame += `${drainedLogs.join("\n")}\n`;
1167
- }
1168
- if (renderedFrame.lines.length > 0) {
1169
- frame += renderedFrame.lines.join("\n");
1170
- }
1171
- previousLineCount = renderedFrame.lines.length;
1149
+ previousLineCount = 0;
1172
1150
  }
1173
1151
 
1174
1152
  if (frame) {
1175
1153
  yield* terminal.writeStderr(frame);
1176
1154
  }
1155
+
1156
+ if (drainedLogs.length > 0) {
1157
+ yield* replayLogs(drainedLogs);
1158
+ }
1159
+
1160
+ const lines = yield* clipTTYFrameLines(renderedFrame.lines);
1161
+ if (lines.length > 0) {
1162
+ yield* terminal.writeStderr(lines.join("\n"));
1163
+ }
1164
+ previousLineCount = lines.length;
1177
1165
  return;
1178
1166
  }
1179
1167
 
1180
1168
  if (drainedLogs.length > 0) {
1181
- yield* terminal.writeStderr(`${drainedLogs.join("\n")}\n`);
1169
+ yield* replayLogs(drainedLogs);
1182
1170
  }
1183
1171
 
1184
1172
  const orderedForNonTTY = orderedTasks.map((task) => ({
@@ -1208,7 +1196,7 @@ const makeDefaultFrameRenderer = (): FrameRendererService => ({
1208
1196
  if (dirty || hasActiveSpinners || hasPendingLogs) {
1209
1197
  yield* renderFrame("tick");
1210
1198
  }
1211
- } else if (dirty || hasActiveSpinners) {
1199
+ } else if (dirty || hasActiveSpinners || hasPendingLogs) {
1212
1200
  yield* renderFrame("tick");
1213
1201
  }
1214
1202
 
@@ -1219,6 +1207,10 @@ const makeDefaultFrameRenderer = (): FrameRendererService => ({
1219
1207
  Effect.ensuring(
1220
1208
  Effect.gen(function* () {
1221
1209
  if (!rendererActive) {
1210
+ const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
1211
+ if (drainedLogs.length > 0) {
1212
+ yield* replayLogs(drainedLogs);
1213
+ }
1222
1214
  return;
1223
1215
  }
1224
1216
 
package/src/runtime.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import { mergeWith } from "es-toolkit/object";
4
- import { formatWithOptions } from "node:util";
5
4
  import type { PartialDeep } from "type-fest";
6
- import { makeProgressConsole } from "./console";
5
+ import { type BufferedConsoleCall, makeConsoleBridge } from "./console";
7
6
  import { Columns, FrameRenderer } from "./renderer";
8
7
  import { ProgressTerminal } from "./terminal";
9
8
  import type {
@@ -157,35 +156,40 @@ const makeProgressService = Effect.gen(function* () {
157
156
 
158
157
  const terminal = yield* ProgressTerminal;
159
158
  const frameRenderer = yield* FrameRenderer;
159
+ const outerConsole = yield* Effect.console;
160
160
  const isTTY = yield* terminal.isTTY;
161
- const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
162
161
 
163
162
  const nextTaskIdRef = yield* Ref.make(0);
164
163
  const storeRef = yield* Ref.make<TaskStore>({
165
164
  tasks: new Map<TaskId, TaskSnapshot>(),
166
165
  renderOrder: [],
167
166
  });
168
- const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
169
- const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
167
+ const pendingLogsRef = yield* Ref.make<ReadonlyArray<BufferedConsoleCall>>([]);
170
168
  const dirtyRef = yield* Ref.make(true);
171
169
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
172
170
  const scope = yield* Effect.scope;
173
171
 
172
+ const markDirty = Ref.set(dirtyRef, true);
173
+ const { replayLogs, progressConsole, log } = makeConsoleBridge(
174
+ outerConsole,
175
+ pendingLogsRef,
176
+ markDirty,
177
+ );
178
+
174
179
  yield* Effect.forkIn(
175
180
  frameRenderer.run(
176
181
  storeRef,
177
- logsRef,
178
182
  pendingLogsRef,
183
+ replayLogs,
179
184
  dirtyRef,
180
185
  terminal,
181
186
  isTTY,
182
187
  rendererConfig,
183
- maxRetainedLogLines,
184
188
  ),
185
189
  scope,
186
190
  );
187
-
188
- const markDirty = Ref.set(dirtyRef, true);
191
+ // Let the renderer fiber start so queued logs are reliably flushed on scope teardown.
192
+ yield* Effect.sleep("0 millis");
189
193
 
190
194
  const addTask = (options: AddTaskOptions) =>
191
195
  Effect.gen(function* () {
@@ -377,36 +381,6 @@ const makeProgressService = Effect.gen(function* () {
377
381
  yield* markDirty;
378
382
  });
379
383
 
380
- const appendLog = (args: ReadonlyArray<unknown>) =>
381
- Effect.gen(function* () {
382
- if (args.length === 0) {
383
- return;
384
- }
385
-
386
- const message = formatWithOptions(
387
- {
388
- colors: isTTY,
389
- depth: 6,
390
- },
391
- ...args,
392
- );
393
-
394
- yield* Ref.update(pendingLogsRef, (logs) => [...logs, message]);
395
- if (maxRetainedLogLines > 0) {
396
- yield* Ref.update(logsRef, (logs) => {
397
- const next = [...logs, message];
398
- if (next.length <= maxRetainedLogLines) {
399
- return next;
400
- }
401
- return next.slice(next.length - maxRetainedLogLines);
402
- });
403
- }
404
-
405
- yield* markDirty;
406
- });
407
-
408
- const log = (...args: ReadonlyArray<unknown>) => appendLog(args);
409
-
410
384
  const getTask = (taskId: TaskId) =>
411
385
  Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
412
386
 
@@ -416,7 +390,6 @@ const makeProgressService = Effect.gen(function* () {
416
390
  2,
417
391
  <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
418
392
  Effect.gen(function* () {
419
- const outerConsole = yield* Effect.console;
420
393
  const inheritedParentId = yield* FiberRef.get(currentParentRef);
421
394
  const resolvedParentId =
422
395
  options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
@@ -428,10 +401,7 @@ const makeProgressService = Effect.gen(function* () {
428
401
  });
429
402
 
430
403
  return yield* Effect.locally(
431
- Effect.withConsole(
432
- Effect.provideService(effect, Task, taskId),
433
- makeProgressConsole(log, outerConsole),
434
- ),
404
+ Effect.withConsole(Effect.provideService(effect, Task, taskId), progressConsole),
435
405
  currentParentRef,
436
406
  Option.some(taskId),
437
407
  );
package/src/types.ts CHANGED
@@ -5,7 +5,6 @@ import type { ProgressColumn } from "./renderer";
5
5
  export const RendererConfigSchema = Schema.Struct({
6
6
  disableUserInput: Schema.Boolean,
7
7
  renderIntervalMillis: Schema.Number,
8
- maxLogLines: Schema.optional(Schema.Number),
9
8
  nonTtyUpdateStep: Schema.Number,
10
9
  width: Schema.Union(Schema.Number, Schema.Literal("fullwidth")),
11
10
  columnGap: Schema.Number,
@@ -14,7 +13,6 @@ export const RendererConfigSchema = Schema.Struct({
14
13
  export type RendererConfigShape = {
15
14
  readonly disableUserInput: boolean;
16
15
  readonly renderIntervalMillis: number;
17
- readonly maxLogLines?: number;
18
16
  readonly nonTtyUpdateStep: number;
19
17
  readonly width: number | "fullwidth";
20
18
  readonly columnGap: number;
@@ -46,7 +44,6 @@ export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarC
46
44
  export const defaultRendererConfig: RendererConfigShape = {
47
45
  disableUserInput: true,
48
46
  renderIntervalMillis: 100, // 10 FPS
49
- maxLogLines: 0,
50
47
  nonTtyUpdateStep: 5,
51
48
  width: 120,
52
49
  columnGap: 1,