effective-progress 0.4.1 → 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/src/renderer.ts CHANGED
@@ -1,239 +1,519 @@
1
- import { Clock, Context, Duration, Effect, Layer, Ref } from "effect";
2
- import { Theme, type ThemeRole, type ThemeService } from "./theme";
1
+ import { styleText } from "node:util";
2
+ import { Clock, Context, Effect, Layer, Ref } from "effect";
3
+ import { fitRenderedText, visibleWidth } from "./renderer/ansi";
4
+ import { computeTreeInfo, renderTreePrefix } from "./renderer/tree";
5
+ import { Track } from "./renderer/types";
6
+ import type {
7
+ CellWrapMode,
8
+ ColumnTrack,
9
+ ProgressColumn,
10
+ ProgressColumnContext,
11
+ ProgressColumnVariant,
12
+ } from "./renderer/types";
3
13
  import type { ProgressTerminalService } from "./terminal";
4
- import type { RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
14
+ import type { ProgressBarConfigShape, RendererConfigShape, TaskSnapshot, TaskStore } from "./types";
15
+ export { Track };
16
+ export type {
17
+ CellWrapMode,
18
+ ColumnTrack,
19
+ ProgressColumn,
20
+ ProgressColumnContext,
21
+ ProgressColumnVariant,
22
+ TaskTreeInfo,
23
+ } from "./renderer/types";
5
24
 
6
25
  const HIDE_CURSOR = "\x1b[?25l";
7
26
  const SHOW_CURSOR = "\x1b[?25h";
8
27
  const CLEAR_LINE = "\x1b[2K";
9
28
  const MOVE_UP_ONE = "\x1b[1A";
29
+ const RESERVED_SECONDS_WIDTH = 3; // width of "59s"
30
+ const PREVIEW_RENDER_WIDTH = 10_000;
31
+ const TREE_PREFIX_COLLAPSE_BAR_WIDTH = 10;
10
32
 
11
33
  const clamp = (value: number, minimum: number, maximum: number): number =>
12
34
  Math.min(Math.max(value, minimum), maximum);
13
35
 
14
- // Use code-point length instead of UTF-16 code-unit length so surrogate pairs
15
- // (for example emoji) count as a single visible character.
16
36
  const textWidth = (text: string): number => Array.from(text).length;
17
37
 
18
- const segmentWidth = (segments: ReadonlyArray<Segment>): number =>
19
- segments.reduce((width, segment) => width + textWidth(segment.text), 0);
38
+ const formatDurationSeconds = (seconds: number): string => {
39
+ const value = Math.max(0, Math.floor(seconds));
40
+ if (value < 60) {
41
+ return `${value}s`;
42
+ }
43
+ if (value < 3600) {
44
+ const mins = Math.floor(value / 60);
45
+ const secs = value % 60;
46
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
47
+ }
48
+
49
+ const hours = Math.floor(value / 3600);
50
+ const mins = Math.floor((value % 3600) / 60);
51
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
52
+ };
53
+
54
+ const formatDeterminateUnits = (completed: number, total: number): string => {
55
+ const totalText = `${total}`;
56
+ const completedText = `${completed}`.padStart(totalText.length, " ");
57
+ return `${completedText}/${totalText}`;
58
+ };
20
59
 
21
60
  const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
22
61
  const elapsedMillis = Math.max(0, (snapshot.completedAt ?? now) - snapshot.startedAt);
23
- const duration =
24
- snapshot.status === "running"
25
- ? Duration.seconds(Math.floor(elapsedMillis / 1000))
26
- : Duration.millis(elapsedMillis);
27
- return `${Duration.format(duration)}`;
62
+ return formatDurationSeconds(elapsedMillis / 1000);
28
63
  };
29
64
 
30
65
  const formatEta = (snapshot: TaskSnapshot, now: number): string => {
31
- if (snapshot.status !== "running") {
32
- return "ETA: --";
33
- }
34
-
35
- if (snapshot.units._tag !== "DeterminateTaskUnits") {
36
- return "ETA: --";
66
+ if (snapshot.status !== "running" || snapshot.units._tag !== "DeterminateTaskUnits") {
67
+ return "";
37
68
  }
38
69
 
39
70
  const { completed, total } = snapshot.units;
40
71
  const remaining = total - completed;
41
72
  if (completed <= 0 || remaining <= 0) {
42
- return "ETA: --";
73
+ return "";
43
74
  }
44
75
 
45
76
  const elapsedMillis = Math.max(1, now - snapshot.startedAt);
46
77
  const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
47
- const duration = Duration.seconds(Math.floor(etaMillis / 1000));
48
- return `ETA: ${Duration.format(duration)}`;
78
+ return `ETA: ${formatDurationSeconds(etaMillis / 1000)}`;
49
79
  };
50
80
 
51
- /** Defines how a cell claims width during the shrink/fit stage. */
52
- export type ColumnTrack =
53
- | {
54
- readonly _tag: "Auto";
55
- }
56
- | {
57
- readonly _tag: "Fixed";
58
- readonly width: number;
59
- }
60
- | {
61
- readonly _tag: "Fraction";
62
- readonly weight: number;
63
- };
64
-
65
- export const Track = {
66
- auto: (): ColumnTrack => ({ _tag: "Auto" }),
67
- fixed: (width: number): ColumnTrack => ({
68
- _tag: "Fixed",
69
- width: Math.max(0, Math.floor(width)),
70
- }),
71
- fr: (weight = 1): ColumnTrack => ({
72
- _tag: "Fraction",
73
- weight: Math.max(0.001, weight),
74
- }),
75
- } as const;
81
+ const reserveTimeWidth = (formattedDuration: string): string =>
82
+ formattedDuration.padStart(RESERVED_SECONDS_WIDTH, " ");
76
83
 
77
- /** Tree relationship metadata for rendering connectors on each row. */
78
- export interface TaskTreeInfo {
79
- readonly depth: number;
80
- readonly hasNextSibling: boolean;
81
- readonly hasChildren: boolean;
82
- readonly ancestorHasNextSibling: ReadonlyArray<boolean>;
83
- }
84
-
85
- /** Task snapshot plus render-time context captured before the build stage. */
86
- export interface OrderedTaskModel {
87
- readonly snapshot: TaskSnapshot;
88
- readonly depth: number;
89
- readonly theme: ThemeService;
90
- }
84
+ type StyleFormat = Parameters<typeof styleText>[0];
91
85
 
92
- /** Unstyled text token emitted by build and consumed by color stage. */
93
- export interface Segment {
94
- readonly text: string;
95
- readonly role: ThemeRole;
96
- }
86
+ const applyStyle = (format: StyleFormat, text: string): string =>
87
+ styleText(format, text, { validateStream: false });
97
88
 
98
- /** How a cell behaves when width is smaller than its intrinsic content. */
99
- export type CellWrapMode = "truncate" | "no-wrap-ellipsis";
89
+ const styleIfTTY = (isTTY: boolean, format: StyleFormat, text: string): string =>
90
+ isTTY ? applyStyle(format, text) : text;
100
91
 
101
- /** Logical, uncolored cell description produced by the build stage. */
102
- export interface CellModel {
103
- readonly id: string;
92
+ interface ColumnBaseOptions {
93
+ readonly id?: string;
104
94
  readonly track?: ColumnTrack;
105
95
  readonly minWidth?: number;
106
96
  readonly maxWidth?: number;
107
- readonly wrapMode?: CellWrapMode;
108
97
  readonly collapsePriority?: number;
109
- readonly intrinsicWidth?: number;
110
- readonly segments: ReadonlyArray<Segment>;
111
- readonly renderAtWidth?: (width: number) => ReadonlyArray<Segment>;
98
+ readonly wrapMode?: CellWrapMode;
112
99
  }
113
100
 
114
- /** A logical line composed of cells prior to width fitting and coloring. */
115
- export interface LogicalRow {
116
- readonly cells: ReadonlyArray<CellModel>;
117
- readonly gap?: number;
118
- readonly lineVariant?: "lead" | "continuation";
119
- }
101
+ const normalizeNumber = (value: number | undefined, fallback: number): number =>
102
+ value === undefined ? fallback : Math.max(0, Math.floor(value));
120
103
 
121
- /** All rows produced for one task entry (single-line or multi-line). */
122
- export interface TaskBlockModel {
123
- readonly taskId: number;
124
- readonly depth: number;
125
- readonly theme: ThemeService;
126
- readonly rows: ReadonlyArray<LogicalRow>;
104
+ export interface DescriptionColumnOptions extends ColumnBaseOptions {}
105
+
106
+ export class DescriptionColumn implements ProgressColumn {
107
+ readonly id: string;
108
+ readonly track: ColumnTrack;
109
+ readonly minWidth: number;
110
+ readonly maxWidth?: number;
111
+ readonly collapsePriority: number;
112
+ readonly wrapMode: CellWrapMode;
113
+
114
+ constructor(options: DescriptionColumnOptions = {}) {
115
+ this.id = options.id ?? "description";
116
+ this.track = options.track ?? Track.fr(1);
117
+ this.minWidth = normalizeNumber(options.minWidth, 8);
118
+ this.maxWidth = options.maxWidth;
119
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 100);
120
+ this.wrapMode = options.wrapMode ?? "ellipsis";
121
+ }
122
+
123
+ static Default(): DescriptionColumn {
124
+ return new DescriptionColumn();
125
+ }
126
+
127
+ static make(options: DescriptionColumnOptions = {}): DescriptionColumn {
128
+ return new DescriptionColumn(options);
129
+ }
130
+
131
+ render(context: ProgressColumnContext): string {
132
+ const prefix = renderTreePrefix(context.tree);
133
+ return `${prefix}${context.task.description}`;
134
+ }
135
+
136
+ variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
137
+ const full: ProgressColumnVariant = {
138
+ render: () => `${renderTreePrefix(context.tree)}${context.task.description}`,
139
+ };
140
+
141
+ if (context.tree.depth <= 0) {
142
+ return [full];
143
+ }
144
+
145
+ return [
146
+ full,
147
+ {
148
+ render: () => context.task.description,
149
+ },
150
+ ];
151
+ }
127
152
  }
128
153
 
129
- /** Full uncolored frame model for all currently rendered tasks. */
130
- export interface FrameModel {
131
- readonly taskBlocks: ReadonlyArray<TaskBlockModel>;
154
+ export interface BarColumnOptions extends ColumnBaseOptions {
155
+ readonly barWidth?: number;
156
+ readonly fillChar?: string;
157
+ readonly emptyChar?: string;
158
+ readonly leftBracket?: string;
159
+ readonly rightBracket?: string;
132
160
  }
133
161
 
134
- /** Cell after shrink/fit with concrete width and fitted segments. */
135
- export interface FittedCell {
162
+ const resolveBarConfig = (
163
+ snapshot: TaskSnapshot,
164
+ options: BarColumnOptions,
165
+ ): ProgressBarConfigShape => ({
166
+ spinnerFrames: snapshot.config.spinnerFrames,
167
+ barWidth: options.barWidth ?? snapshot.config.barWidth,
168
+ fillChar: options.fillChar ?? snapshot.config.fillChar,
169
+ emptyChar: options.emptyChar ?? snapshot.config.emptyChar,
170
+ leftBracket: options.leftBracket ?? snapshot.config.leftBracket,
171
+ rightBracket: options.rightBracket ?? snapshot.config.rightBracket,
172
+ });
173
+
174
+ export class BarColumn implements ProgressColumn {
136
175
  readonly id: string;
137
- readonly width: number;
138
- readonly segments: ReadonlyArray<Segment>;
139
- }
176
+ readonly track: ColumnTrack;
177
+ readonly minWidth: number;
178
+ readonly maxWidth?: number;
179
+ readonly collapsePriority: number;
180
+ readonly wrapMode: CellWrapMode;
181
+ readonly options: BarColumnOptions;
182
+
183
+ constructor(options: BarColumnOptions = {}) {
184
+ this.id = options.id ?? "bar";
185
+ this.track = options.track ?? Track.auto();
186
+ this.minWidth = normalizeNumber(options.minWidth, 1);
187
+ this.maxWidth = options.maxWidth;
188
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 10);
189
+ this.wrapMode = options.wrapMode ?? "truncate";
190
+ this.options = options;
191
+ }
140
192
 
141
- /** Row after shrink/fit with concrete per-cell widths. */
142
- export interface FittedRow {
143
- readonly depth: number;
144
- readonly theme: ThemeService;
145
- readonly gap: number;
146
- readonly cells: ReadonlyArray<FittedCell>;
147
- }
193
+ static Default(): BarColumn {
194
+ return new BarColumn();
195
+ }
148
196
 
149
- /** Fitted rows for a single task block. */
150
- export interface FittedTaskBlock {
151
- readonly taskId: number;
152
- readonly rows: ReadonlyArray<FittedRow>;
153
- }
197
+ static make(options: BarColumnOptions = {}): BarColumn {
198
+ return new BarColumn(options);
199
+ }
154
200
 
155
- /** Full frame after fit but before ANSI styling. */
156
- export interface FittedFrameModel {
157
- readonly taskBlocks: ReadonlyArray<FittedTaskBlock>;
158
- }
201
+ measure(context: ProgressColumnContext): number {
202
+ if (context.task.units._tag !== "DeterminateTaskUnits") {
203
+ return 0;
204
+ }
159
205
 
160
- /** Inputs required by the default build stage implementation. */
161
- export interface BuildStageBuildOptions {
162
- readonly orderedTasks: ReadonlyArray<OrderedTaskModel>;
163
- readonly rendererConfig: RendererConfigShape;
164
- readonly now: number;
165
- readonly tick: number;
166
- }
206
+ const config = resolveBarConfig(context.task, this.options);
207
+ const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
208
+ return Math.max(this.minWidth, bracketWidth + Math.max(1, config.barWidth));
209
+ }
210
+
211
+ render(context: ProgressColumnContext, width: number): string {
212
+ if (context.task.units._tag !== "DeterminateTaskUnits") {
213
+ return "";
214
+ }
167
215
 
168
- /** Public extension point: convert task snapshots to a logical frame model. */
169
- export interface BuildStageService {
170
- readonly buildFrame: (options: BuildStageBuildOptions) => FrameModel;
216
+ const config = resolveBarConfig(context.task, this.options);
217
+ const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
218
+ const totalWidth = Math.max(1, Math.floor(width));
219
+ const innerWidth = Math.max(1, totalWidth - bracketWidth);
220
+ const safeTotal = Math.max(1, context.task.units.total);
221
+ const ratio =
222
+ context.task.status === "done" ? 1 : clamp(context.task.units.completed / safeTotal, 0, 1);
223
+ const filled = Math.round(ratio * innerWidth);
224
+
225
+ const fill = config.fillChar.repeat(filled);
226
+ const empty = config.emptyChar.repeat(Math.max(0, innerWidth - filled));
227
+
228
+ const fillStyle: StyleFormat =
229
+ context.task.status === "failed"
230
+ ? "red"
231
+ : context.task.status === "done"
232
+ ? "green"
233
+ : "blue";
234
+ const emptyStyle: StyleFormat = context.task.status === "failed" ? "red" : ["white", "dim"];
235
+
236
+ return [
237
+ styleIfTTY(context.isTTY, ["white", "dim"], config.leftBracket),
238
+ styleIfTTY(context.isTTY, fillStyle, fill),
239
+ styleIfTTY(context.isTTY, emptyStyle, empty),
240
+ styleIfTTY(context.isTTY, ["white", "dim"], config.rightBracket),
241
+ ].join("");
242
+ }
243
+
244
+ variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
245
+ if (context.task.units._tag !== "DeterminateTaskUnits") {
246
+ return [];
247
+ }
248
+
249
+ const config = resolveBarConfig(context.task, this.options);
250
+ const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
251
+ const fullWidth = Math.max(this.minWidth, bracketWidth + Math.max(1, config.barWidth));
252
+ const compactInnerWidths = [20, TREE_PREFIX_COLLAPSE_BAR_WIDTH]
253
+ .map((innerWidth) => Math.max(1, Math.floor(innerWidth)))
254
+ .filter((innerWidth) => innerWidth < Math.max(1, config.barWidth));
255
+ const compactWidths = compactInnerWidths.map((innerWidth) =>
256
+ Math.max(this.minWidth, bracketWidth + innerWidth),
257
+ );
258
+ const uniqueWidths = [...new Set([fullWidth, ...compactWidths])];
259
+
260
+ return uniqueWidths.map((variantWidth) => ({
261
+ measure: () => variantWidth,
262
+ render: (variantContext, width) => this.render(variantContext, width),
263
+ }));
264
+ }
171
265
  }
172
266
 
173
- /** Width constraints applied by the shrink stage. */
174
- export interface ShrinkWidthConstraints {
175
- readonly terminalColumns: number | undefined;
176
- readonly maxTaskWidth: number | undefined;
267
+ export interface AmountColumnOptions extends ColumnBaseOptions {
268
+ readonly doneSymbol?: string;
269
+ readonly failedSymbol?: string;
177
270
  }
178
271
 
179
- /** Inputs for shrink/fit stage execution. */
180
- export interface ShrinkStageFitOptions {
181
- readonly frame: FrameModel;
182
- readonly width: ShrinkWidthConstraints;
272
+ export class AmountColumn implements ProgressColumn {
273
+ readonly id: string;
274
+ readonly track: ColumnTrack;
275
+ readonly minWidth: number;
276
+ readonly maxWidth?: number;
277
+ readonly collapsePriority: number;
278
+ readonly wrapMode: CellWrapMode;
279
+ readonly doneSymbol: string;
280
+ readonly failedSymbol: string;
281
+
282
+ constructor(options: AmountColumnOptions = {}) {
283
+ this.id = options.id ?? "amount";
284
+ this.track = options.track ?? Track.auto();
285
+ this.minWidth = normalizeNumber(options.minWidth, 1);
286
+ this.maxWidth = options.maxWidth;
287
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 30);
288
+ this.wrapMode = options.wrapMode ?? "truncate";
289
+ this.doneSymbol = options.doneSymbol ?? "✓";
290
+ this.failedSymbol = options.failedSymbol ?? "✗";
291
+ }
292
+
293
+ static Default(): AmountColumn {
294
+ return new AmountColumn();
295
+ }
296
+
297
+ static make(options: AmountColumnOptions = {}): AmountColumn {
298
+ return new AmountColumn(options);
299
+ }
300
+
301
+ render(context: ProgressColumnContext): string {
302
+ const { task } = context;
303
+
304
+ if (task.units._tag === "DeterminateTaskUnits") {
305
+ return styleIfTTY(
306
+ context.isTTY,
307
+ task.status === "failed" ? "red" : "whiteBright",
308
+ formatDeterminateUnits(task.units.completed, task.units.total),
309
+ );
310
+ }
311
+
312
+ if (task.status === "running") {
313
+ const frames = task.config.spinnerFrames;
314
+ const frameIndex = (task.units.spinnerFrame + context.tick) % frames.length;
315
+ const frame = frames[frameIndex] ?? frames[0] ?? "";
316
+ return styleIfTTY(context.isTTY, "yellow", frame);
317
+ }
318
+
319
+ if (task.status === "done") {
320
+ return styleIfTTY(context.isTTY, "green", this.doneSymbol);
321
+ }
322
+
323
+ return styleIfTTY(context.isTTY, "red", this.failedSymbol);
324
+ }
183
325
  }
184
326
 
185
- /** Public extension point: resolve concrete widths and truncate content. */
186
- export interface ShrinkStageService {
187
- readonly fitFrame: (options: ShrinkStageFitOptions) => FittedFrameModel;
327
+ export interface ElapsedColumnOptions extends ColumnBaseOptions {
328
+ readonly padSeconds?: boolean;
188
329
  }
189
330
 
190
- /** Inputs for the color/materialization stage. */
191
- export interface ColorStageColorOptions {
192
- readonly frame: FittedFrameModel;
331
+ export class ElapsedColumn implements ProgressColumn {
332
+ readonly id: string;
333
+ readonly track: ColumnTrack;
334
+ readonly minWidth: number;
335
+ readonly maxWidth?: number;
336
+ readonly collapsePriority: number;
337
+ readonly wrapMode: CellWrapMode;
338
+ readonly padSeconds: boolean;
339
+
340
+ constructor(options: ElapsedColumnOptions = {}) {
341
+ this.id = options.id ?? "elapsed";
342
+ this.track = options.track ?? Track.auto();
343
+ this.minWidth = normalizeNumber(options.minWidth, 1);
344
+ this.maxWidth = options.maxWidth;
345
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 40);
346
+ this.wrapMode = options.wrapMode ?? "truncate";
347
+ this.padSeconds = options.padSeconds ?? true;
348
+ }
349
+
350
+ static Default(): ElapsedColumn {
351
+ return new ElapsedColumn();
352
+ }
353
+
354
+ static make(options: ElapsedColumnOptions = {}): ElapsedColumn {
355
+ return new ElapsedColumn(options);
356
+ }
357
+
358
+ render(context: ProgressColumnContext): string {
359
+ const raw = formatElapsed(context.task, context.now);
360
+ const elapsed = this.padSeconds ? reserveTimeWidth(raw) : raw;
361
+ return styleIfTTY(context.isTTY, "gray", elapsed);
362
+ }
193
363
  }
194
364
 
195
- /** Public extension point: style a fitted frame into terminal lines. */
196
- export interface ColorStageService {
197
- readonly colorFrame: (options: ColorStageColorOptions) => ReadonlyArray<string>;
365
+ export interface EtaColumnOptions extends ColumnBaseOptions {
366
+ readonly label?: string;
367
+ readonly pendingText?: string;
198
368
  }
199
369
 
200
- /** Inputs for the top-level frame renderer loop. */
201
- export interface FrameRendererRunOptions {
202
- readonly storeRef: Ref.Ref<TaskStore>;
203
- readonly logsRef: Ref.Ref<ReadonlyArray<string>>;
204
- readonly pendingLogsRef: Ref.Ref<ReadonlyArray<string>>;
205
- readonly dirtyRef: Ref.Ref<boolean>;
206
- readonly terminal: ProgressTerminalService;
207
- readonly isTTY: boolean;
208
- readonly rendererConfig: RendererConfigShape;
209
- readonly maxRetainedLogLines: number;
370
+ export class EtaColumn implements ProgressColumn {
371
+ readonly id: string;
372
+ readonly track: ColumnTrack;
373
+ readonly minWidth: number;
374
+ readonly maxWidth?: number;
375
+ readonly collapsePriority: number;
376
+ readonly wrapMode: CellWrapMode;
377
+ readonly label: string;
378
+ readonly pendingText: string;
379
+
380
+ constructor(options: EtaColumnOptions = {}) {
381
+ this.id = options.id ?? "eta";
382
+ this.track = options.track ?? Track.auto();
383
+ this.minWidth = normalizeNumber(options.minWidth, 0);
384
+ this.maxWidth = options.maxWidth;
385
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 20);
386
+ this.wrapMode = options.wrapMode ?? "truncate";
387
+ this.label = options.label ?? "ETA";
388
+ this.pendingText = options.pendingText ?? `${this.label}: `;
389
+ }
390
+
391
+ static Default(): EtaColumn {
392
+ return new EtaColumn();
393
+ }
394
+
395
+ static make(options: EtaColumnOptions = {}): EtaColumn {
396
+ return new EtaColumn(options);
397
+ }
398
+
399
+ private resolveEtaText(context: ProgressColumnContext, includeLabel: boolean): string {
400
+ const value = formatEta(context.task, context.now);
401
+ if (value.length === 0) {
402
+ if (context.task.status === "running" && context.task.units._tag === "DeterminateTaskUnits") {
403
+ if (includeLabel) {
404
+ return this.pendingText;
405
+ }
406
+ const pendingPrefix = `${this.label}: `;
407
+ return this.pendingText.startsWith(pendingPrefix)
408
+ ? this.pendingText.slice(pendingPrefix.length)
409
+ : this.pendingText.replace(/^ETA:\s*/, "");
410
+ }
411
+ return "";
412
+ }
413
+
414
+ if (includeLabel) {
415
+ if (this.label === "ETA") {
416
+ return value;
417
+ }
418
+ return value.replace(/^ETA/, this.label);
419
+ }
420
+
421
+ const labeled = this.label === "ETA" ? value : value.replace(/^ETA/, this.label);
422
+ const prefix = `${this.label}: `;
423
+ return labeled.startsWith(prefix)
424
+ ? labeled.slice(prefix.length)
425
+ : labeled.replace(/^ETA:\s*/, "");
426
+ }
427
+
428
+ render(context: ProgressColumnContext): string {
429
+ return styleIfTTY(context.isTTY, "gray", this.resolveEtaText(context, true));
430
+ }
431
+
432
+ variants(context: ProgressColumnContext): ReadonlyArray<ProgressColumnVariant> {
433
+ const full = this.resolveEtaText(context, true);
434
+ if (full.length === 0) {
435
+ return [
436
+ {
437
+ render: () => "",
438
+ },
439
+ ];
440
+ }
441
+
442
+ const compact = this.resolveEtaText(context, false);
443
+ if (compact === full) {
444
+ return [
445
+ {
446
+ render: () => styleIfTTY(context.isTTY, "gray", full),
447
+ },
448
+ ];
449
+ }
450
+
451
+ return [
452
+ {
453
+ render: () => styleIfTTY(context.isTTY, "gray", full),
454
+ },
455
+ {
456
+ render: () => styleIfTTY(context.isTTY, "gray", compact),
457
+ },
458
+ ];
459
+ }
210
460
  }
211
461
 
212
- /** Service that owns the render loop and writes frames to the terminal. */
213
- export interface FrameRendererService {
214
- readonly run: (options: FrameRendererRunOptions) => Effect.Effect<void>;
462
+ export interface LiteralColumnOptions extends ColumnBaseOptions {
463
+ readonly text: string;
215
464
  }
216
465
 
217
- const createSegment = (text: string, role: ThemeRole): Segment => ({ text, role });
466
+ export class LiteralColumn implements ProgressColumn {
467
+ readonly id: string;
468
+ readonly track: ColumnTrack;
469
+ readonly minWidth: number;
470
+ readonly maxWidth?: number;
471
+ readonly collapsePriority: number;
472
+ readonly wrapMode: CellWrapMode;
473
+ readonly text: string;
474
+
475
+ constructor(options: LiteralColumnOptions) {
476
+ this.id = options.id ?? `literal:${options.text}`;
477
+ this.track = options.track ?? Track.auto();
478
+ this.minWidth = normalizeNumber(options.minWidth, 0);
479
+ this.maxWidth = options.maxWidth;
480
+ this.collapsePriority = normalizeNumber(options.collapsePriority, 0);
481
+ this.wrapMode = options.wrapMode ?? "truncate";
482
+ this.text = options.text;
483
+ }
218
484
 
219
- const getCellIntrinsicWidth = (cell: CellModel): number => {
220
- if (cell.intrinsicWidth !== undefined) {
221
- return Math.max(0, Math.floor(cell.intrinsicWidth));
485
+ static Default(text = "•"): LiteralColumn {
486
+ return new LiteralColumn({ text });
222
487
  }
223
488
 
224
- return segmentWidth(cell.segments);
225
- };
489
+ static make(text: string, options: Omit<LiteralColumnOptions, "text"> = {}): LiteralColumn {
490
+ return new LiteralColumn({ ...options, text });
491
+ }
492
+
493
+ render(): string {
494
+ return this.text;
495
+ }
496
+ }
497
+
498
+ export const Columns = {
499
+ defaults: (): ReadonlyArray<ProgressColumn> => [
500
+ DescriptionColumn.Default(),
501
+ BarColumn.Default(),
502
+ AmountColumn.Default(),
503
+ ElapsedColumn.Default(),
504
+ EtaColumn.Default(),
505
+ ],
506
+ } as const;
507
+
508
+ const resolveTrack = (column: ProgressColumn): ColumnTrack => column.track ?? Track.auto();
226
509
 
227
- const getCellBounds = (cell: CellModel): { min: number; max?: number } => {
228
- const min = Math.max(0, Math.floor(cell.minWidth ?? 0));
229
- const max = cell.maxWidth === undefined ? undefined : Math.max(min, Math.floor(cell.maxWidth));
510
+ const resolveColumnBounds = (column: ProgressColumn): { min: number; max?: number } => {
511
+ const min = Math.max(0, Math.floor(column.minWidth ?? 0));
512
+ const max =
513
+ column.maxWidth === undefined ? undefined : Math.max(min, Math.floor(column.maxWidth));
230
514
  return { min, max };
231
515
  };
232
516
 
233
- const resolveTrack = (cell: CellModel): ColumnTrack => cell.track ?? Track.auto();
234
-
235
- // Deterministic integer ratio distribution. We ceil each step and subtract
236
- // from the remaining pool so the final sum always matches the target exactly.
237
517
  const ratioDistribute = (
238
518
  total: number,
239
519
  ratios: ReadonlyArray<number>,
@@ -261,35 +541,37 @@ const ratioDistribute = (
261
541
  return amounts;
262
542
  };
263
543
 
264
- const resolveTotalWidth = (width: ShrinkWidthConstraints): number | undefined => {
265
- const maxTaskWidth =
266
- width.maxTaskWidth === undefined ? undefined : Math.max(1, Math.floor(width.maxTaskWidth));
544
+ const resolveTotalWidth = (
545
+ terminalColumns: number | undefined,
546
+ width: number | "fullwidth",
547
+ ): number | undefined => {
548
+ if (width === "fullwidth") {
549
+ if (terminalColumns === undefined) {
550
+ return undefined;
551
+ }
267
552
 
268
- if (width.terminalColumns === undefined) {
269
- return maxTaskWidth;
553
+ return Math.max(1, Math.floor(terminalColumns));
270
554
  }
271
555
 
272
- const terminalColumns = Math.max(1, Math.floor(width.terminalColumns));
273
- if (maxTaskWidth === undefined) {
274
- return terminalColumns;
556
+ const resolvedConfiguredWidth = Math.max(1, Math.floor(width));
557
+
558
+ if (terminalColumns === undefined) {
559
+ return resolvedConfiguredWidth;
275
560
  }
276
561
 
277
- return Math.min(terminalColumns, maxTaskWidth);
562
+ return Math.min(Math.max(1, Math.floor(terminalColumns)), resolvedConfiguredWidth);
278
563
  };
279
564
 
280
565
  const shrinkByPriority = (
281
566
  widths: Array<number>,
282
567
  minWidths: ReadonlyArray<number>,
283
- cells: ReadonlyArray<CellModel>,
568
+ columns: ReadonlyArray<ProgressColumn>,
284
569
  overflow: number,
285
570
  ): number => {
286
- // First collapse pass: shrink columns by explicit collapse priority.
287
- // Columns with no-wrap-ellipsis are still shrinkable; the wrap mode only
288
- // decides whether we hard-truncate or append an ellipsis when fitted.
289
- const shrinkable = cells
290
- .map((cell, index) => ({
571
+ const shrinkable = columns
572
+ .map((column, index) => ({
291
573
  index,
292
- priority: cell.collapsePriority ?? Number.MAX_SAFE_INTEGER,
574
+ priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
293
575
  }))
294
576
  .sort((a, b) => a.priority - b.priority);
295
577
 
@@ -320,8 +602,6 @@ const shrinkProportionally = (
320
602
  minWidths: ReadonlyArray<number>,
321
603
  overflow: number,
322
604
  ): number => {
323
- // Last-resort collapse pass: if priority-based shrinking is not enough,
324
- // reduce all remaining shrinkable columns proportionally.
325
605
  let remainingOverflow = overflow;
326
606
 
327
607
  while (remainingOverflow > 0) {
@@ -371,37 +651,39 @@ const shrinkProportionally = (
371
651
  return remainingOverflow;
372
652
  };
373
653
 
374
- const resolveRowWidths = (
375
- row: LogicalRow,
654
+ const resolveColumnWidths = (
655
+ columns: ReadonlyArray<ProgressColumn>,
656
+ intrinsicWidths: ReadonlyArray<number>,
376
657
  totalWidth: number | undefined,
377
- ): ReadonlyArray<number> => {
378
- // 1) Resolve base widths from tracks + intrinsic size.
379
- // 2) Distribute extra room to fraction columns.
380
- // 3) If overflowing: priority-based collapse, then proportional fallback.
381
- const gap = Math.max(0, Math.floor(row.gap ?? 1));
382
- const cells = row.cells;
383
-
384
- if (cells.length === 0) {
385
- return [];
658
+ gap: number,
659
+ ): {
660
+ readonly widths: ReadonlyArray<number>;
661
+ readonly overflowBeforeShrink: number;
662
+ } => {
663
+ if (columns.length === 0) {
664
+ return {
665
+ widths: [],
666
+ overflowBeforeShrink: 0,
667
+ };
386
668
  }
387
669
 
388
- const minWidths = cells.map((cell) => getCellBounds(cell).min);
389
- const maxWidths = cells.map((cell) => getCellBounds(cell).max);
390
- const widths: Array<number> = Array.from({ length: cells.length }, () => 0);
670
+ const minWidths = columns.map((column) => resolveColumnBounds(column).min);
671
+ const maxWidths = columns.map((column) => resolveColumnBounds(column).max);
672
+ const widths: Array<number> = Array.from({ length: columns.length }, () => 0);
391
673
 
392
- for (let i = 0; i < cells.length; i++) {
393
- const cell = cells[i]!;
394
- const track = resolveTrack(cell);
674
+ for (let i = 0; i < columns.length; i++) {
675
+ const column = columns[i]!;
676
+ const track = resolveTrack(column);
395
677
  const minWidth = minWidths[i] ?? 0;
396
678
  const maxWidth = maxWidths[i];
397
- const intrinsic = getCellIntrinsicWidth(cell);
679
+ const intrinsic = Math.max(0, Math.floor(intrinsicWidths[i] ?? 0));
398
680
 
399
681
  const baseWidth = (() => {
400
682
  switch (track._tag) {
401
683
  case "Fixed":
402
684
  return Math.max(minWidth, track.width);
403
685
  case "Fraction":
404
- return minWidth;
686
+ return totalWidth === undefined ? Math.max(minWidth, intrinsic) : minWidth;
405
687
  case "Auto":
406
688
  return Math.max(minWidth, intrinsic);
407
689
  }
@@ -412,20 +694,24 @@ const resolveRowWidths = (
412
694
  }
413
695
 
414
696
  if (totalWidth === undefined) {
415
- return widths;
697
+ return {
698
+ widths,
699
+ overflowBeforeShrink: 0,
700
+ };
416
701
  }
417
702
 
418
- const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, cells.length - 1));
703
+ const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, columns.length - 1));
419
704
 
420
705
  const fractionColumns: Array<{ index: number; weight: number }> = [];
421
- for (let index = 0; index < cells.length; index++) {
422
- const track = resolveTrack(cells[index]!);
706
+ for (let index = 0; index < columns.length; index++) {
707
+ const track = resolveTrack(columns[index]!);
423
708
  if (track._tag === "Fraction") {
424
709
  fractionColumns.push({ index, weight: track.weight });
425
710
  }
426
711
  }
427
712
 
428
713
  let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
714
+ const overflowBeforeShrink = Math.max(0, -remaining);
429
715
 
430
716
  if (remaining > 0 && fractionColumns.length > 0) {
431
717
  const distributed = ratioDistribute(
@@ -444,518 +730,298 @@ const resolveRowWidths = (
444
730
 
445
731
  if (remaining < 0) {
446
732
  let overflow = -remaining;
447
- overflow = shrinkByPriority(widths, minWidths, cells, overflow);
733
+ overflow = shrinkByPriority(widths, minWidths, columns, overflow);
448
734
  if (overflow > 0) {
449
735
  overflow = shrinkProportionally(widths, minWidths, overflow);
450
736
  }
451
737
  }
452
738
 
453
- return widths.map((width, index) => {
454
- const min = minWidths[index] ?? 0;
455
- const max = maxWidths[index];
739
+ return {
740
+ widths: widths.map((width, index) => {
741
+ const min = minWidths[index] ?? 0;
742
+ const max = maxWidths[index];
456
743
 
457
- if (max === undefined) {
458
- return Math.max(min, width);
459
- }
744
+ if (max === undefined) {
745
+ return Math.max(min, width);
746
+ }
460
747
 
461
- return clamp(Math.max(min, width), min, max);
462
- });
748
+ return clamp(Math.max(min, width), min, max);
749
+ }),
750
+ overflowBeforeShrink,
751
+ };
463
752
  };
464
753
 
465
- interface CharacterToken {
466
- readonly char: string;
467
- readonly role: ThemeRole;
468
- }
754
+ const normalizeColumns = (
755
+ columns: ReadonlyArray<ProgressColumn | string>,
756
+ ): ReadonlyArray<ProgressColumn> => {
757
+ const resolved = columns.length > 0 ? columns : Columns.defaults();
469
758
 
470
- const toCharacterTokens = (segments: ReadonlyArray<Segment>): Array<CharacterToken> => {
471
- const tokens: Array<CharacterToken> = [];
759
+ return resolved.map((entry, index) => {
760
+ const normalized = typeof entry === "string" ? LiteralColumn.make(entry) : entry;
472
761
 
473
- for (const segment of segments) {
474
- for (const char of Array.from(segment.text)) {
475
- tokens.push({ char, role: segment.role });
762
+ if (
763
+ typeof normalized !== "object" ||
764
+ normalized === null ||
765
+ typeof normalized.render !== "function"
766
+ ) {
767
+ throw new Error(`Invalid progress column at index ${index}`);
476
768
  }
477
- }
478
-
479
- return tokens;
480
- };
481
769
 
482
- const fromCharacterTokens = (tokens: ReadonlyArray<CharacterToken>): ReadonlyArray<Segment> => {
483
- if (tokens.length === 0) {
484
- return [];
485
- }
486
-
487
- const segments: Array<Segment> = [];
488
- let currentRole = tokens[0]!.role;
489
- let buffer = "";
490
-
491
- for (const token of tokens) {
492
- if (token.role !== currentRole) {
493
- segments.push(createSegment(buffer, currentRole));
494
- buffer = token.char;
495
- currentRole = token.role;
496
- continue;
770
+ if (typeof normalized.id !== "string" || normalized.id.length === 0) {
771
+ throw new Error(`Progress column at index ${index} is missing a valid id`);
497
772
  }
498
773
 
499
- buffer += token.char;
500
- }
501
-
502
- if (buffer.length > 0) {
503
- segments.push(createSegment(buffer, currentRole));
504
- }
505
-
506
- return segments;
774
+ return normalized;
775
+ });
507
776
  };
508
777
 
509
- const fitSegments = (
510
- segments: ReadonlyArray<Segment>,
511
- width: number,
512
- wrapMode: CellWrapMode,
513
- ): ReadonlyArray<Segment> => {
514
- // Segment fitting is role-preserving: truncate by character tokens,
515
- // optionally append ellipsis, then pad with plain-space tokens.
516
- const target = Math.max(0, Math.floor(width));
517
- if (target <= 0) {
518
- return [];
519
- }
520
-
521
- const chars = toCharacterTokens(segments);
522
-
523
- const truncatedChars = (() => {
524
- if (chars.length <= target) {
525
- return chars;
526
- }
527
-
528
- if (wrapMode === "no-wrap-ellipsis") {
529
- if (target === 1) {
530
- return [{ char: "…", role: chars[0]?.role ?? "plain" }];
531
- }
532
-
533
- const keep = chars.slice(0, Math.max(0, target - 1));
534
- const ellipsisRole = keep[keep.length - 1]?.role ?? chars[0]?.role ?? "plain";
535
- return [...keep, { char: "…", role: ellipsisRole }];
536
- }
778
+ interface OrderedTaskModel {
779
+ readonly snapshot: TaskSnapshot;
780
+ readonly depth: number;
781
+ }
537
782
 
538
- return chars.slice(0, target);
539
- })();
783
+ interface RenderedFrame {
784
+ readonly lines: ReadonlyArray<string>;
785
+ readonly lineByTaskId: ReadonlyMap<number, string>;
786
+ }
540
787
 
541
- const truncatedSegments = fromCharacterTokens(truncatedChars);
542
- const visibleWidth = segmentWidth(truncatedSegments);
788
+ const fallbackVariantForColumn = (column: ProgressColumn): ProgressColumnVariant => ({
789
+ measure: column.measure === undefined ? undefined : (context) => column.measure?.(context) ?? 0,
790
+ render: (context, width) => column.render(context, width),
791
+ });
543
792
 
544
- if (visibleWidth >= target) {
545
- return truncatedSegments;
793
+ const resolveVariantForLevel = (
794
+ column: ProgressColumn,
795
+ context: ProgressColumnContext,
796
+ level: number,
797
+ ): ProgressColumnVariant => {
798
+ const variants = column.variants?.(context);
799
+ if (variants === undefined || variants.length === 0) {
800
+ return fallbackVariantForColumn(column);
546
801
  }
547
802
 
548
- return [...truncatedSegments, createSegment(" ".repeat(target - visibleWidth), "plain")];
803
+ return variants[Math.min(level, variants.length - 1)]!;
549
804
  };
550
805
 
551
- const treeAncestorPrefix = (tree: TaskTreeInfo): string =>
552
- tree.ancestorHasNextSibling.map((hasNext) => (hasNext ? "│ " : " ")).join("");
553
-
554
- const renderTreePrefix = (tree: TaskTreeInfo, variant: "lead" | "continuation"): string => {
555
- if (tree.depth <= 0) {
556
- return "";
557
- }
558
-
559
- const ancestor = treeAncestorPrefix(tree);
560
-
561
- if (variant === "lead") {
562
- return `${ancestor}${tree.hasNextSibling ? "├─ " : "└─ "}`;
563
- }
564
-
565
- const trunk = `${ancestor}${tree.hasNextSibling ? "│ " : " "}`;
566
- if (tree.hasChildren) {
567
- return `${trunk}│ `;
806
+ const isDescriptionColumn = (column: ProgressColumn): boolean =>
807
+ column instanceof DescriptionColumn || column.id === "description";
808
+
809
+ const isEtaColumn = (column: ProgressColumn): boolean =>
810
+ column instanceof EtaColumn || column.id === "eta";
811
+
812
+ const isBarColumn = (column: ProgressColumn): boolean =>
813
+ column instanceof BarColumn || column.id === "bar";
814
+
815
+ const renderTaskFrame = (
816
+ orderedTasks: ReadonlyArray<OrderedTaskModel>,
817
+ columns: ReadonlyArray<ProgressColumn>,
818
+ rendererConfig: RendererConfigShape,
819
+ now: number,
820
+ tick: number,
821
+ terminalColumns: number | undefined,
822
+ isTTY: boolean,
823
+ ): RenderedFrame => {
824
+ if (orderedTasks.length === 0) {
825
+ return {
826
+ lines: [],
827
+ lineByTaskId: new Map(),
828
+ };
568
829
  }
569
830
 
570
- return trunk;
571
- };
572
-
573
- const computeTreeInfo = (ordered: ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }>) => {
574
- // Precompute sibling/ancestor relationships so connector rendering is
575
- // deterministic and independent from column layout decisions.
576
- const hasNextSiblingByIndex: Array<boolean> = Array.from({ length: ordered.length }, () => false);
831
+ const orderedWithTree = computeTreeInfo(
832
+ orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
833
+ );
577
834
 
578
- for (let i = 0; i < ordered.length; i++) {
579
- const depth = ordered[i]!.depth;
580
- for (let j = i + 1; j < ordered.length; j++) {
581
- const candidateDepth = ordered[j]!.depth;
582
- if (candidateDepth < depth) {
583
- break;
584
- }
585
- if (candidateDepth === depth) {
586
- hasNextSiblingByIndex[i] = true;
587
- break;
835
+ const contexts = orderedWithTree.map<ProgressColumnContext>((entry, index) => ({
836
+ task: orderedTasks[index]!.snapshot,
837
+ depth: orderedTasks[index]!.depth,
838
+ tree: entry.tree,
839
+ now,
840
+ tick,
841
+ isTTY,
842
+ }));
843
+
844
+ const gap = Math.max(0, Math.floor(rendererConfig.columnGap ?? 1));
845
+ const totalWidth = resolveTotalWidth(terminalColumns, rendererConfig.width);
846
+ const maxVariantLevelByColumn = columns.map((column) => {
847
+ let maxLevel = 0;
848
+ for (const context of contexts) {
849
+ const variants = column.variants?.(context);
850
+ if (variants !== undefined && variants.length > 0) {
851
+ maxLevel = Math.max(maxLevel, variants.length - 1);
588
852
  }
589
853
  }
590
- }
591
-
592
- const ancestorStateByDepth: Array<boolean> = [];
593
-
594
- return ordered.map((entry, index) => {
595
- const depth = entry.depth;
596
- ancestorStateByDepth.length = depth;
597
-
598
- const hasChildren =
599
- index + 1 < ordered.length &&
600
- ordered[index + 1] !== undefined &&
601
- ordered[index + 1]!.depth > depth;
602
-
603
- const tree: TaskTreeInfo = {
604
- depth,
605
- hasNextSibling: hasNextSiblingByIndex[index] ?? false,
606
- hasChildren,
607
- ancestorHasNextSibling: [...ancestorStateByDepth],
608
- };
609
-
610
- ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
611
-
612
- return {
613
- ...entry,
614
- tree,
615
- };
854
+ return maxLevel;
616
855
  });
617
- };
618
-
619
- const treeCell = (tree: TaskTreeInfo, variant: "lead" | "continuation"): CellModel => ({
620
- id: "tree",
621
- track: Track.auto(),
622
- wrapMode: "truncate",
623
- collapsePriority: 100,
624
- minWidth: 0,
625
- segments: [createSegment(renderTreePrefix(tree, variant), "treeConnector")],
626
- });
627
-
628
- const textCell = (text: string): CellModel => ({
629
- id: "text",
630
- track: Track.auto(),
631
- wrapMode: "no-wrap-ellipsis",
632
- collapsePriority: 50,
633
- minWidth: 4,
634
- segments: [createSegment(text, "text")],
635
- });
636
-
637
- const spinnerCell = (snapshot: TaskSnapshot, tick: number): CellModel => {
638
- const frames = snapshot.config.spinnerFrames;
639
- const frameIndex =
640
- snapshot.units._tag === "IndeterminateTaskUnits"
641
- ? (snapshot.units.spinnerFrame + tick) % frames.length
642
- : 0;
643
- const frame = frames[frameIndex] ?? frames[0] ?? "";
644
-
645
- return {
646
- id: "spinner",
647
- track: Track.auto(),
648
- wrapMode: "truncate",
649
- collapsePriority: 70,
650
- minWidth: 1,
651
- segments: [createSegment(frame, "spinner")],
652
- };
653
- };
654
-
655
- const barCell = (snapshot: TaskSnapshot): CellModel => {
656
- if (snapshot.units._tag !== "DeterminateTaskUnits") {
657
- return {
658
- id: "bar",
659
- segments: [],
660
- };
661
- }
662
-
663
- const { config } = snapshot;
664
- const units = snapshot.units;
665
- const bracketWidth = textWidth(config.leftBracket) + textWidth(config.rightBracket);
666
- const preferredInnerWidth = Math.max(1, config.barWidth);
667
- const intrinsicWidth = bracketWidth + preferredInnerWidth;
668
-
669
- return {
670
- id: "bar",
671
- track: Track.auto(),
672
- minWidth: Math.max(1, bracketWidth + 1),
673
- wrapMode: "truncate",
674
- collapsePriority: 10,
675
- intrinsicWidth,
676
- segments: [],
677
- renderAtWidth: (width) => {
678
- const targetWidth = Math.max(1, Math.floor(width));
679
- const safeTotal = units.total <= 0 ? 1 : units.total;
680
- const ratio = Math.min(1, Math.max(0, units.completed / safeTotal));
681
-
682
- const resolvedInnerWidth = Math.max(1, targetWidth - bracketWidth);
683
- const clampedRatio = snapshot.status === "done" ? 1 : ratio;
684
- const filled = Math.round(clampedRatio * resolvedInnerWidth);
685
-
686
- const fillRole: ThemeRole =
687
- snapshot.status === "failed"
688
- ? "statusFailed"
689
- : snapshot.status === "done"
690
- ? "statusDone"
691
- : "barFill";
692
-
693
- const emptyRole: ThemeRole = snapshot.status === "failed" ? "statusFailed" : "barEmpty";
694
-
695
- return [
696
- createSegment(config.leftBracket, "barBracket"),
697
- createSegment(config.fillChar.repeat(filled), fillRole),
698
- createSegment(config.emptyChar.repeat(Math.max(0, resolvedInnerWidth - filled)), emptyRole),
699
- createSegment(config.rightBracket, "barBracket"),
700
- ];
701
- },
702
- };
703
- };
704
-
705
- const unitsCell = (snapshot: TaskSnapshot): CellModel => ({
706
- id: "units",
707
- track: Track.auto(),
708
- minWidth: 3,
709
- wrapMode: "truncate",
710
- collapsePriority: 40,
711
- segments:
712
- snapshot.units._tag === "DeterminateTaskUnits"
713
- ? [createSegment(`${snapshot.units.completed}/${snapshot.units.total}`, "units")]
714
- : [],
715
- });
716
-
717
- const etaCell = (snapshot: TaskSnapshot, now: number): CellModel => ({
718
- id: "eta",
719
- track: Track.auto(),
720
- minWidth: 4,
721
- wrapMode: "truncate",
722
- collapsePriority: 20,
723
- segments: [createSegment(formatEta(snapshot, now), "eta")],
724
- });
856
+ const variantLevelByColumn = columns.map(() => 0);
857
+
858
+ const resolveLayoutForVariants = (levels: ReadonlyArray<number>) => {
859
+ const intrinsicByColumn: Array<number> = Array.from({ length: columns.length }, () => 0);
860
+ const hasContentByColumn: Array<boolean> = Array.from({ length: columns.length }, () => false);
861
+
862
+ for (let col = 0; col < columns.length; col++) {
863
+ const column = columns[col]!;
864
+ const level = levels[col] ?? 0;
865
+
866
+ for (const context of contexts) {
867
+ const variant = resolveVariantForLevel(column, context, level);
868
+ const preview = `${variant.render(context, PREVIEW_RENDER_WIDTH)}`;
869
+ const previewWidth = visibleWidth(preview);
870
+ if (previewWidth > 0) {
871
+ hasContentByColumn[col] = true;
872
+ }
725
873
 
726
- const elapsedCell = (snapshot: TaskSnapshot, now: number): CellModel => ({
727
- id: "elapsed",
728
- track: Track.auto(),
729
- minWidth: 4,
730
- wrapMode: "truncate",
731
- collapsePriority: 30,
732
- segments: [createSegment(formatElapsed(snapshot, now), "elapsed")],
733
- });
874
+ if (typeof variant.measure === "function") {
875
+ const measured = Number(variant.measure(context));
876
+ if (Number.isFinite(measured)) {
877
+ intrinsicByColumn[col] = Math.max(
878
+ intrinsicByColumn[col] ?? 0,
879
+ Math.max(0, Math.floor(measured)),
880
+ );
881
+ }
882
+ } else {
883
+ intrinsicByColumn[col] = Math.max(intrinsicByColumn[col] ?? 0, previewWidth);
884
+ }
885
+ }
886
+ }
734
887
 
735
- const statusCell = (snapshot: TaskSnapshot): CellModel => {
736
- if (snapshot.status === "done") {
737
- return {
738
- id: "status",
739
- track: Track.auto(),
740
- minWidth: 6,
741
- wrapMode: "truncate",
742
- collapsePriority: 60,
743
- segments: [createSegment("done", "statusDone")],
744
- };
745
- }
888
+ const activeColumnIndexes = columns
889
+ .map((_column, index) => index)
890
+ .filter((index) => hasContentByColumn[index] ?? false);
891
+ const activeColumns = activeColumnIndexes.map((index) => columns[index]!);
892
+ const intrinsicWidths = activeColumnIndexes.map((index) => intrinsicByColumn[index] ?? 0);
893
+ const widthResolution = resolveColumnWidths(activeColumns, intrinsicWidths, totalWidth, gap);
746
894
 
747
- if (snapshot.status === "failed") {
748
895
  return {
749
- id: "status",
750
- track: Track.auto(),
751
- minWidth: 8,
752
- wrapMode: "truncate",
753
- collapsePriority: 60,
754
- segments: [createSegment("[failed]", "statusFailed")],
896
+ intrinsicByColumn,
897
+ activeColumnIndexes,
898
+ activeColumns,
899
+ widths: widthResolution.widths,
900
+ overflowBeforeShrink: widthResolution.overflowBeforeShrink,
755
901
  };
756
- }
757
-
758
- return {
759
- id: "status",
760
- segments: [],
761
902
  };
762
- };
763
903
 
764
- const defaultBuildStageService: BuildStageService = {
765
- buildFrame: ({ orderedTasks, rendererConfig, now, tick }) => {
766
- // Build stage emits semantic rows only. No ANSI and no width trimming here.
767
- const orderedWithTree = computeTreeInfo(
768
- orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
769
- );
770
-
771
- const taskBlocks: Array<TaskBlockModel> = orderedWithTree.map((entry, index) => {
772
- const orderedEntry = orderedTasks[index]!;
773
- const snapshot = orderedEntry.snapshot;
774
- const tree = entry.tree;
775
- const isDeterminate = snapshot.units._tag === "DeterminateTaskUnits";
776
- const showTwoLineDeterminate =
777
- isDeterminate && rendererConfig.determinateTaskLayout === "two-lines";
904
+ let layout = resolveLayoutForVariants(variantLevelByColumn);
778
905
 
779
- const runningOrDoneDeterminate =
780
- isDeterminate && (snapshot.status === "running" || snapshot.status === "done");
906
+ if (totalWidth !== undefined) {
907
+ while (true) {
908
+ const hasCompressedColumns = layout.activeColumnIndexes.some((columnIndex, activeIndex) => {
909
+ const assignedWidth = layout.widths[activeIndex] ?? 0;
910
+ const intrinsicWidth = layout.intrinsicByColumn[columnIndex] ?? 0;
911
+ return assignedWidth < intrinsicWidth;
912
+ });
781
913
 
782
- const determinateFailure = isDeterminate && snapshot.status === "failed";
914
+ if (layout.overflowBeforeShrink <= 0 && !hasCompressedColumns) {
915
+ break;
916
+ }
783
917
 
784
- const rows: Array<LogicalRow> = [];
918
+ const hasPendingEtaCompaction = columns.some(
919
+ (column, index) =>
920
+ isEtaColumn(column) &&
921
+ (variantLevelByColumn[index] ?? 0) < (maxVariantLevelByColumn[index] ?? 0),
922
+ );
923
+ const activeBarWidths = layout.activeColumnIndexes.flatMap((columnIndex, activeIndex) =>
924
+ isBarColumn(columns[columnIndex]!) ? [layout.widths[activeIndex] ?? 0] : [],
925
+ );
926
+ const barsAreTightEnoughForTreeCollapse = activeBarWidths.every(
927
+ (width) => width <= TREE_PREFIX_COLLAPSE_BAR_WIDTH,
928
+ );
785
929
 
786
- if (runningOrDoneDeterminate) {
787
- if (showTwoLineDeterminate) {
788
- rows.push({
789
- lineVariant: "lead",
790
- cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
791
- });
930
+ const candidates = columns
931
+ .map((_column, index) => index)
932
+ .filter(
933
+ (index) => (variantLevelByColumn[index] ?? 0) < (maxVariantLevelByColumn[index] ?? 0),
934
+ )
935
+ .map((index) => {
936
+ const column = columns[index]!;
937
+ if (
938
+ isDescriptionColumn(column) &&
939
+ (hasPendingEtaCompaction || !barsAreTightEnoughForTreeCollapse)
940
+ ) {
941
+ return undefined;
942
+ }
792
943
 
793
- rows.push({
794
- lineVariant: "continuation",
795
- cells: [
796
- treeCell(tree, "continuation"),
797
- barCell(snapshot),
798
- unitsCell(snapshot),
799
- snapshot.status === "running" ? etaCell(snapshot, now) : elapsedCell(snapshot, now),
800
- ],
801
- });
802
- } else {
803
- rows.push({
804
- lineVariant: "lead",
805
- cells: [
806
- treeCell(tree, "lead"),
807
- textCell(snapshot.description),
808
- barCell(snapshot),
809
- unitsCell(snapshot),
810
- snapshot.status === "running" ? etaCell(snapshot, now) : elapsedCell(snapshot, now),
811
- ],
812
- });
813
- }
814
- } else if (determinateFailure) {
815
- if (showTwoLineDeterminate) {
816
- rows.push({
817
- lineVariant: "lead",
818
- cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
819
- });
820
- rows.push({
821
- lineVariant: "continuation",
822
- cells: [
823
- treeCell(tree, "continuation"),
824
- barCell(snapshot),
825
- unitsCell(snapshot),
826
- statusCell(snapshot),
827
- elapsedCell(snapshot, now),
828
- ],
829
- });
830
- } else {
831
- rows.push({
832
- lineVariant: "lead",
833
- cells: [
834
- treeCell(tree, "lead"),
835
- textCell(snapshot.description),
836
- barCell(snapshot),
837
- unitsCell(snapshot),
838
- statusCell(snapshot),
839
- elapsedCell(snapshot, now),
840
- ],
841
- });
842
- }
843
- } else if (snapshot.status === "running") {
844
- rows.push({
845
- lineVariant: "lead",
846
- cells: [
847
- treeCell(tree, "lead"),
848
- spinnerCell(snapshot, tick),
849
- textCell(snapshot.description),
850
- elapsedCell(snapshot, now),
851
- ],
852
- });
853
- } else {
854
- rows.push({
855
- lineVariant: "lead",
856
- cells: [
857
- treeCell(tree, "lead"),
858
- textCell(snapshot.description),
859
- statusCell(snapshot),
860
- elapsedCell(snapshot, now),
861
- ],
862
- });
944
+ const currentIntrinsic = layout.intrinsicByColumn[index] ?? 0;
945
+ const trialLevels = [...variantLevelByColumn];
946
+ trialLevels[index] = (trialLevels[index] ?? 0) + 1;
947
+ const nextLayout = resolveLayoutForVariants(trialLevels);
948
+ const nextIntrinsic = nextLayout.intrinsicByColumn[index] ?? 0;
949
+ return {
950
+ index,
951
+ reduction: Math.max(0, currentIntrinsic - nextIntrinsic),
952
+ priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
953
+ };
954
+ })
955
+ .filter(
956
+ (candidate): candidate is { index: number; reduction: number; priority: number } =>
957
+ candidate !== undefined,
958
+ )
959
+ .sort((a, b) => b.reduction - a.reduction || a.priority - b.priority || a.index - b.index);
960
+
961
+ const best = candidates[0];
962
+ if (best === undefined) {
963
+ break;
863
964
  }
864
965
 
865
- return {
866
- taskId: snapshot.id as number,
867
- depth: orderedEntry.depth,
868
- theme: orderedEntry.theme,
869
- rows,
870
- };
871
- });
966
+ variantLevelByColumn[best.index] = (variantLevelByColumn[best.index] ?? 0) + 1;
967
+ layout = resolveLayoutForVariants(variantLevelByColumn);
968
+ }
969
+ }
872
970
 
971
+ if (layout.activeColumnIndexes.length === 0) {
972
+ const emptyLines = orderedTasks.map(() => "");
873
973
  return {
874
- taskBlocks,
974
+ lines: emptyLines,
975
+ lineByTaskId: new Map(orderedTasks.map((entry) => [entry.snapshot.id as number, ""])),
875
976
  };
876
- },
877
- };
878
-
879
- const defaultShrinkStageService: ShrinkStageService = {
880
- fitFrame: ({ frame, width }) => {
881
- // Convert logical rows into concrete widths and fitted segments.
882
- const totalWidth = resolveTotalWidth(width);
883
-
884
- return {
885
- taskBlocks: frame.taskBlocks.map((block) => ({
886
- taskId: block.taskId,
887
- rows: block.rows.map((row) => {
888
- const gap = Math.max(0, Math.floor(row.gap ?? 1));
889
- const widths = resolveRowWidths(row, totalWidth);
977
+ }
890
978
 
891
- return {
892
- depth: block.depth,
893
- theme: block.theme,
894
- gap,
895
- cells: row.cells.map((cell, index) => {
896
- const widthForCell = widths[index] ?? 0;
897
- const rendered = cell.renderAtWidth?.(widthForCell) ?? cell.segments;
898
- const wrapMode = cell.wrapMode ?? "truncate";
899
- const fittedSegments = fitSegments(rendered, widthForCell, wrapMode);
900
-
901
- return {
902
- id: cell.id,
903
- width: widthForCell,
904
- segments: fittedSegments,
905
- };
906
- }),
907
- };
908
- }),
909
- })),
910
- };
911
- },
912
- };
979
+ const gapText = " ".repeat(gap);
980
+ const lines = contexts.map((context) =>
981
+ layout.activeColumns
982
+ .map((column, index) => {
983
+ const originalColumnIndex = layout.activeColumnIndexes[index]!;
984
+ const width = layout.widths[index] ?? 0;
985
+ const variant = resolveVariantForLevel(
986
+ column,
987
+ context,
988
+ variantLevelByColumn[originalColumnIndex] ?? 0,
989
+ );
990
+ const raw = `${variant.render(context, width)}`;
991
+ const wrapMode = columns[originalColumnIndex]!.wrapMode ?? "truncate";
992
+ return fitRenderedText(raw, width, wrapMode, isTTY);
993
+ })
994
+ .join(gapText)
995
+ .trimEnd(),
996
+ );
913
997
 
914
- const styleSegment = (segment: Segment, depth: number, theme: ThemeService): string => {
915
- const byDepth = theme.depthPalette?.(depth, segment.role);
916
- if (byDepth !== undefined) {
917
- return byDepth(segment.text);
998
+ const lineByTaskId = new Map<number, string>();
999
+ for (let i = 0; i < orderedTasks.length; i++) {
1000
+ lineByTaskId.set(orderedTasks[i]!.snapshot.id as number, lines[i] ?? "");
918
1001
  }
919
1002
 
920
- const style = theme.styles[segment.role] ?? theme.styles.plain;
921
- return style(segment.text);
1003
+ return {
1004
+ lines,
1005
+ lineByTaskId,
1006
+ };
922
1007
  };
923
1008
 
924
- const defaultColorStageService: ColorStageService = {
925
- colorFrame: ({ frame }) =>
926
- frame.taskBlocks.flatMap((block) =>
927
- block.rows.map((row) => {
928
- const gap = " ".repeat(row.gap);
929
- return row.cells
930
- .map((cell) =>
931
- cell.segments.map((segment) => styleSegment(segment, row.depth, row.theme)).join(""),
932
- )
933
- .join(gap)
934
- .trimEnd();
935
- }),
936
- ),
937
- };
1009
+ export interface FrameRendererService {
1010
+ readonly run: <LogEntry>(
1011
+ storeRef: Ref.Ref<TaskStore>,
1012
+ pendingLogsRef: Ref.Ref<ReadonlyArray<LogEntry>>,
1013
+ replayLogs: (logs: ReadonlyArray<LogEntry>) => Effect.Effect<void, never, never>,
1014
+ dirtyRef: Ref.Ref<boolean>,
1015
+ terminal: ProgressTerminalService,
1016
+ isTTY: boolean,
1017
+ rendererConfig: RendererConfigShape,
1018
+ ) => Effect.Effect<void>;
1019
+ }
938
1020
 
939
- const makeDefaultFrameRenderer = (
940
- buildStage: BuildStageService,
941
- shrinkStage: ShrinkStageService,
942
- colorStage: ColorStageService,
943
- fallbackTheme: ThemeService,
944
- ): FrameRendererService => ({
945
- run: ({
946
- storeRef,
947
- logsRef,
948
- pendingLogsRef,
949
- dirtyRef,
950
- terminal,
951
- isTTY,
952
- rendererConfig,
953
- maxRetainedLogLines,
954
- }) =>
1021
+ const makeDefaultFrameRenderer = (): FrameRendererService => ({
1022
+ run: (storeRef, pendingLogsRef, replayLogs, dirtyRef, terminal, isTTY, rendererConfig) =>
955
1023
  Effect.gen(function* () {
956
- // The frame renderer owns terminal state (cursor/session), drives tick
957
- // updates, and coordinates build -> shrink -> color for each frame.
958
- const retainLogHistory = maxRetainedLogLines > 0;
1024
+ const compiledColumns = normalizeColumns(rendererConfig.columns);
959
1025
  let previousLineCount = 0;
960
1026
  let nonTTYTaskSignatureById = new Map<number, string>();
961
1027
  let tick = 0;
@@ -999,7 +1065,7 @@ const makeDefaultFrameRenderer = (
999
1065
  return;
1000
1066
  }
1001
1067
 
1002
- yield* terminal.writeStderr("\n" + SHOW_CURSOR);
1068
+ yield* terminal.writeStderr(`\n${SHOW_CURSOR}`);
1003
1069
  previousLineCount = 0;
1004
1070
  sessionActive = false;
1005
1071
  });
@@ -1007,7 +1073,7 @@ const makeDefaultFrameRenderer = (
1007
1073
  const renderNonTTYTaskUpdates = (
1008
1074
  ordered: ReadonlyArray<{
1009
1075
  snapshot: TaskSnapshot;
1010
- lines: ReadonlyArray<string>;
1076
+ line: string;
1011
1077
  }>,
1012
1078
  ) => {
1013
1079
  const nextTaskSignatureById = new Map<number, string>();
@@ -1024,13 +1090,16 @@ const makeDefaultFrameRenderer = (
1024
1090
 
1025
1091
  nextTaskSignatureById.set(taskId, signature);
1026
1092
  if (nonTTYTaskSignatureById.get(taskId) !== signature) {
1027
- changedTaskLines.push(...ordered[i]!.lines);
1093
+ const line = ordered[i]!.line;
1094
+ if (line.length > 0) {
1095
+ changedTaskLines.push(line);
1096
+ }
1028
1097
  }
1029
1098
  }
1030
1099
 
1031
1100
  return Effect.gen(function* () {
1032
1101
  if (changedTaskLines.length > 0) {
1033
- yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
1102
+ yield* terminal.writeStderr(`${changedTaskLines.join("\n")}\n`);
1034
1103
  }
1035
1104
 
1036
1105
  nonTTYTaskSignatureById = nextTaskSignatureById;
@@ -1039,7 +1108,6 @@ const makeDefaultFrameRenderer = (
1039
1108
 
1040
1109
  const renderFrame = (mode: "tick" | "final") =>
1041
1110
  Effect.gen(function* () {
1042
- // Materialize one frame snapshot from current state.
1043
1111
  const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
1044
1112
  const store = yield* Ref.get(storeRef);
1045
1113
  const orderedTasks = store.renderOrder.flatMap((row) => {
@@ -1052,7 +1120,6 @@ const makeDefaultFrameRenderer = (
1052
1120
  {
1053
1121
  snapshot,
1054
1122
  depth: row.depth,
1055
- theme: store.themes.get(snapshot.id) ?? fallbackTheme,
1056
1123
  },
1057
1124
  ];
1058
1125
  });
@@ -1060,80 +1127,56 @@ const makeDefaultFrameRenderer = (
1060
1127
  const now = yield* Clock.currentTimeMillis;
1061
1128
  const frameTick = mode === "final" ? tick + 1 : tick;
1062
1129
  const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
1063
- const maxTaskWidth = rendererConfig.maxTaskWidth;
1064
1130
 
1065
- const frameModel = buildStage.buildFrame({
1131
+ const renderedFrame = renderTaskFrame(
1066
1132
  orderedTasks,
1133
+ compiledColumns,
1067
1134
  rendererConfig,
1068
1135
  now,
1069
- tick: isTTY ? frameTick : 0,
1070
- });
1071
-
1072
- const fittedFrame = shrinkStage.fitFrame({
1073
- frame: frameModel,
1074
- width: {
1075
- terminalColumns,
1076
- maxTaskWidth,
1077
- },
1078
- });
1079
-
1080
- const taskLines = colorStage.colorFrame({ frame: fittedFrame });
1081
-
1082
- const taskLineMap = new Map<number, ReadonlyArray<string>>();
1083
- let lineCursor = 0;
1084
- for (const block of fittedFrame.taskBlocks) {
1085
- const lineCount = block.rows.length;
1086
- taskLineMap.set(block.taskId, taskLines.slice(lineCursor, lineCursor + lineCount));
1087
- lineCursor += lineCount;
1088
- }
1136
+ isTTY ? frameTick : 0,
1137
+ terminalColumns,
1138
+ isTTY,
1139
+ );
1089
1140
 
1090
1141
  if (isTTY) {
1091
1142
  let frame = "";
1092
1143
 
1093
1144
  if (previousLineCount > 0) {
1094
- frame += "\r" + CLEAR_LINE;
1145
+ frame += `\r${CLEAR_LINE}`;
1095
1146
  for (let i = 1; i < previousLineCount; i++) {
1096
1147
  frame += MOVE_UP_ONE + CLEAR_LINE;
1097
1148
  }
1098
- }
1099
-
1100
- if (retainLogHistory) {
1101
- const historyLogs = yield* Ref.get(logsRef);
1102
- const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
1103
- if (lines.length > 0) {
1104
- frame += lines.join("\n");
1105
- }
1106
- previousLineCount = lines.length;
1107
- } else {
1108
- if (drainedLogs.length > 0) {
1109
- frame += drainedLogs.join("\n") + "\n";
1110
- }
1111
- if (taskLines.length > 0) {
1112
- frame += taskLines.join("\n");
1113
- }
1114
- previousLineCount = taskLines.length;
1149
+ previousLineCount = 0;
1115
1150
  }
1116
1151
 
1117
1152
  if (frame) {
1118
1153
  yield* terminal.writeStderr(frame);
1119
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;
1120
1165
  return;
1121
1166
  }
1122
1167
 
1123
1168
  if (drainedLogs.length > 0) {
1124
- yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
1169
+ yield* replayLogs(drainedLogs);
1125
1170
  }
1126
1171
 
1127
1172
  const orderedForNonTTY = orderedTasks.map((task) => ({
1128
1173
  snapshot: task.snapshot,
1129
- lines: taskLineMap.get(task.snapshot.id as number) ?? [],
1174
+ line: renderedFrame.lineByTaskId.get(task.snapshot.id as number) ?? "",
1130
1175
  }));
1131
1176
  yield* renderNonTTYTaskUpdates(orderedForNonTTY);
1132
1177
  });
1133
1178
 
1134
1179
  const renderLoop = Effect.gen(function* () {
1135
- // Tick loop: render eagerly in TTY mode for spinner animation, and
1136
- // render opportunistically in non-TTY mode based on signatures/dirty bit.
1137
1180
  rendererActive = true;
1138
1181
  if (isTTY) {
1139
1182
  yield* startTTYSession;
@@ -1153,7 +1196,7 @@ const makeDefaultFrameRenderer = (
1153
1196
  if (dirty || hasActiveSpinners || hasPendingLogs) {
1154
1197
  yield* renderFrame("tick");
1155
1198
  }
1156
- } else if (dirty || hasActiveSpinners) {
1199
+ } else if (dirty || hasActiveSpinners || hasPendingLogs) {
1157
1200
  yield* renderFrame("tick");
1158
1201
  }
1159
1202
 
@@ -1164,6 +1207,10 @@ const makeDefaultFrameRenderer = (
1164
1207
  Effect.ensuring(
1165
1208
  Effect.gen(function* () {
1166
1209
  if (!rendererActive) {
1210
+ const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
1211
+ if (drainedLogs.length > 0) {
1212
+ yield* replayLogs(drainedLogs);
1213
+ }
1167
1214
  return;
1168
1215
  }
1169
1216
 
@@ -1188,39 +1235,12 @@ const makeDefaultFrameRenderer = (
1188
1235
  }),
1189
1236
  });
1190
1237
 
1191
- export class BuildStage extends Context.Tag("stromseng.dev/effective-progress/BuildStage")<
1192
- BuildStage,
1193
- BuildStageService
1194
- >() {
1195
- static readonly Default = Layer.succeed(BuildStage, BuildStage.of(defaultBuildStageService));
1196
- }
1197
-
1198
- export class ShrinkStage extends Context.Tag("stromseng.dev/effective-progress/ShrinkStage")<
1199
- ShrinkStage,
1200
- ShrinkStageService
1201
- >() {
1202
- static readonly Default = Layer.succeed(ShrinkStage, ShrinkStage.of(defaultShrinkStageService));
1203
- }
1204
-
1205
- export class ColorStage extends Context.Tag("stromseng.dev/effective-progress/ColorStage")<
1206
- ColorStage,
1207
- ColorStageService
1208
- >() {
1209
- static readonly Default = Layer.succeed(ColorStage, ColorStage.of(defaultColorStageService));
1210
- }
1211
-
1212
1238
  export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
1213
1239
  FrameRenderer,
1214
1240
  FrameRendererService
1215
1241
  >() {
1216
- static readonly Default = Layer.effect(
1242
+ static readonly Default = Layer.succeed(
1217
1243
  FrameRenderer,
1218
- Effect.gen(function* () {
1219
- const buildStage = yield* BuildStage;
1220
- const shrinkStage = yield* ShrinkStage;
1221
- const colorStage = yield* ColorStage;
1222
- const theme = yield* Theme;
1223
- return FrameRenderer.of(makeDefaultFrameRenderer(buildStage, shrinkStage, colorStage, theme));
1224
- }),
1244
+ FrameRenderer.of(makeDefaultFrameRenderer()),
1225
1245
  );
1226
1246
  }