effective-progress 0.4.0 → 0.4.2

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