effective-progress 0.4.1 → 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,35 +536,37 @@ 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: 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) => ({
566
+ const shrinkable = columns
567
+ .map((column, index) => ({
291
568
  index,
292
- priority: cell.collapsePriority ?? Number.MAX_SAFE_INTEGER,
569
+ priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
293
570
  }))
294
571
  .sort((a, b) => a.priority - b.priority);
295
572
 
@@ -320,8 +597,6 @@ const shrinkProportionally = (
320
597
  minWidths: ReadonlyArray<number>,
321
598
  overflow: number,
322
599
  ): number => {
323
- // Last-resort collapse pass: if priority-based shrinking is not enough,
324
- // reduce all remaining shrinkable columns proportionally.
325
600
  let remainingOverflow = overflow;
326
601
 
327
602
  while (remainingOverflow > 0) {
@@ -371,37 +646,39 @@ const shrinkProportionally = (
371
646
  return remainingOverflow;
372
647
  };
373
648
 
374
- const resolveRowWidths = (
375
- row: LogicalRow,
649
+ const resolveColumnWidths = (
650
+ columns: ReadonlyArray<ProgressColumn>,
651
+ intrinsicWidths: ReadonlyArray<number>,
376
652
  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 [];
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
+ };
386
663
  }
387
664
 
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);
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);
391
668
 
392
- for (let i = 0; i < cells.length; i++) {
393
- const cell = cells[i]!;
394
- const track = resolveTrack(cell);
669
+ for (let i = 0; i < columns.length; i++) {
670
+ const column = columns[i]!;
671
+ const track = resolveTrack(column);
395
672
  const minWidth = minWidths[i] ?? 0;
396
673
  const maxWidth = maxWidths[i];
397
- const intrinsic = getCellIntrinsicWidth(cell);
674
+ const intrinsic = Math.max(0, Math.floor(intrinsicWidths[i] ?? 0));
398
675
 
399
676
  const baseWidth = (() => {
400
677
  switch (track._tag) {
401
678
  case "Fixed":
402
679
  return Math.max(minWidth, track.width);
403
680
  case "Fraction":
404
- return minWidth;
681
+ return totalWidth === undefined ? Math.max(minWidth, intrinsic) : minWidth;
405
682
  case "Auto":
406
683
  return Math.max(minWidth, intrinsic);
407
684
  }
@@ -412,20 +689,24 @@ const resolveRowWidths = (
412
689
  }
413
690
 
414
691
  if (totalWidth === undefined) {
415
- return widths;
692
+ return {
693
+ widths,
694
+ overflowBeforeShrink: 0,
695
+ };
416
696
  }
417
697
 
418
- 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));
419
699
 
420
700
  const fractionColumns: Array<{ index: number; weight: number }> = [];
421
- for (let index = 0; index < cells.length; index++) {
422
- const track = resolveTrack(cells[index]!);
701
+ for (let index = 0; index < columns.length; index++) {
702
+ const track = resolveTrack(columns[index]!);
423
703
  if (track._tag === "Fraction") {
424
704
  fractionColumns.push({ index, weight: track.weight });
425
705
  }
426
706
  }
427
707
 
428
708
  let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
709
+ const overflowBeforeShrink = Math.max(0, -remaining);
429
710
 
430
711
  if (remaining > 0 && fractionColumns.length > 0) {
431
712
  const distributed = ratioDistribute(
@@ -444,505 +725,297 @@ const resolveRowWidths = (
444
725
 
445
726
  if (remaining < 0) {
446
727
  let overflow = -remaining;
447
- overflow = shrinkByPriority(widths, minWidths, cells, overflow);
728
+ overflow = shrinkByPriority(widths, minWidths, columns, overflow);
448
729
  if (overflow > 0) {
449
730
  overflow = shrinkProportionally(widths, minWidths, overflow);
450
731
  }
451
732
  }
452
733
 
453
- return widths.map((width, index) => {
454
- const min = minWidths[index] ?? 0;
455
- const max = maxWidths[index];
734
+ return {
735
+ widths: widths.map((width, index) => {
736
+ const min = minWidths[index] ?? 0;
737
+ const max = maxWidths[index];
456
738
 
457
- if (max === undefined) {
458
- return Math.max(min, width);
459
- }
739
+ if (max === undefined) {
740
+ return Math.max(min, width);
741
+ }
460
742
 
461
- return clamp(Math.max(min, width), min, max);
462
- });
743
+ return clamp(Math.max(min, width), min, max);
744
+ }),
745
+ overflowBeforeShrink,
746
+ };
463
747
  };
464
748
 
465
- interface CharacterToken {
466
- readonly char: string;
467
- readonly role: ThemeRole;
468
- }
749
+ const normalizeColumns = (
750
+ columns: ReadonlyArray<ProgressColumn | string>,
751
+ ): ReadonlyArray<ProgressColumn> => {
752
+ const resolved = columns.length > 0 ? columns : Columns.defaults();
469
753
 
470
- const toCharacterTokens = (segments: ReadonlyArray<Segment>): Array<CharacterToken> => {
471
- const tokens: Array<CharacterToken> = [];
754
+ return resolved.map((entry, index) => {
755
+ const normalized = typeof entry === "string" ? LiteralColumn.make(entry) : entry;
472
756
 
473
- for (const segment of segments) {
474
- for (const char of Array.from(segment.text)) {
475
- 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}`);
476
763
  }
477
- }
478
764
 
479
- return tokens;
480
- };
481
-
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;
765
+ if (typeof normalized.id !== "string" || normalized.id.length === 0) {
766
+ throw new Error(`Progress column at index ${index} is missing a valid id`);
497
767
  }
498
768
 
499
- buffer += token.char;
500
- }
501
-
502
- if (buffer.length > 0) {
503
- segments.push(createSegment(buffer, currentRole));
504
- }
505
-
506
- return segments;
769
+ return normalized;
770
+ });
507
771
  };
508
772
 
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
- }
773
+ interface OrderedTaskModel {
774
+ readonly snapshot: TaskSnapshot;
775
+ readonly depth: number;
776
+ }
537
777
 
538
- return chars.slice(0, target);
539
- })();
778
+ interface RenderedFrame {
779
+ readonly lines: ReadonlyArray<string>;
780
+ readonly lineByTaskId: ReadonlyMap<number, string>;
781
+ }
540
782
 
541
- const truncatedSegments = fromCharacterTokens(truncatedChars);
542
- 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
+ });
543
787
 
544
- if (visibleWidth >= target) {
545
- 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);
546
796
  }
547
797
 
548
- return [...truncatedSegments, createSegment(" ".repeat(target - visibleWidth), "plain")];
798
+ return variants[Math.min(level, variants.length - 1)]!;
549
799
  };
550
800
 
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}│ `;
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
+ };
568
824
  }
569
825
 
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);
826
+ const orderedWithTree = computeTreeInfo(
827
+ orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
828
+ );
577
829
 
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;
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);
588
847
  }
589
848
  }
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
- };
849
+ return maxLevel;
616
850
  });
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
- });
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
+ }
725
868
 
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
- });
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
+ }
734
882
 
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
- }
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);
746
889
 
747
- if (snapshot.status === "failed") {
748
890
  return {
749
- id: "status",
750
- track: Track.auto(),
751
- minWidth: 8,
752
- wrapMode: "truncate",
753
- collapsePriority: 60,
754
- segments: [createSegment("[failed]", "statusFailed")],
891
+ intrinsicByColumn,
892
+ activeColumnIndexes,
893
+ activeColumns,
894
+ widths: widthResolution.widths,
895
+ overflowBeforeShrink: widthResolution.overflowBeforeShrink,
755
896
  };
756
- }
757
-
758
- return {
759
- id: "status",
760
- segments: [],
761
897
  };
762
- };
763
898
 
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
- );
899
+ let layout = resolveLayoutForVariants(variantLevelByColumn);
770
900
 
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";
778
-
779
- const runningOrDoneDeterminate =
780
- 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
+ });
781
908
 
782
- const determinateFailure = isDeterminate && snapshot.status === "failed";
909
+ if (layout.overflowBeforeShrink <= 0 && !hasCompressedColumns) {
910
+ break;
911
+ }
783
912
 
784
- 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
+ );
785
924
 
786
- if (runningOrDoneDeterminate) {
787
- if (showTwoLineDeterminate) {
788
- rows.push({
789
- lineVariant: "lead",
790
- cells: [treeCell(tree, "lead"), textCell(snapshot.description)],
791
- });
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
+ }
792
938
 
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
- });
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;
863
959
  }
864
960
 
865
- return {
866
- taskId: snapshot.id as number,
867
- depth: orderedEntry.depth,
868
- theme: orderedEntry.theme,
869
- rows,
870
- };
871
- });
961
+ variantLevelByColumn[best.index] = (variantLevelByColumn[best.index] ?? 0) + 1;
962
+ layout = resolveLayoutForVariants(variantLevelByColumn);
963
+ }
964
+ }
872
965
 
966
+ if (layout.activeColumnIndexes.length === 0) {
967
+ const emptyLines = orderedTasks.map(() => "");
873
968
  return {
874
- taskBlocks,
969
+ lines: emptyLines,
970
+ lineByTaskId: new Map(orderedTasks.map((entry) => [entry.snapshot.id as number, ""])),
875
971
  };
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);
972
+ }
890
973
 
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
- };
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
+ );
913
992
 
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);
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] ?? "");
918
996
  }
919
997
 
920
- const style = theme.styles[segment.role] ?? theme.styles.plain;
921
- return style(segment.text);
998
+ return {
999
+ lines,
1000
+ lineByTaskId,
1001
+ };
922
1002
  };
923
1003
 
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
- };
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
+ }
938
1016
 
939
- const makeDefaultFrameRenderer = (
940
- buildStage: BuildStageService,
941
- shrinkStage: ShrinkStageService,
942
- colorStage: ColorStageService,
943
- fallbackTheme: ThemeService,
944
- ): FrameRendererService => ({
945
- run: ({
1017
+ const makeDefaultFrameRenderer = (): FrameRendererService => ({
1018
+ run: (
946
1019
  storeRef,
947
1020
  logsRef,
948
1021
  pendingLogsRef,
@@ -951,11 +1024,10 @@ const makeDefaultFrameRenderer = (
951
1024
  isTTY,
952
1025
  rendererConfig,
953
1026
  maxRetainedLogLines,
954
- }) =>
1027
+ ) =>
955
1028
  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
1029
  const retainLogHistory = maxRetainedLogLines > 0;
1030
+ const compiledColumns = normalizeColumns(rendererConfig.columns);
959
1031
  let previousLineCount = 0;
960
1032
  let nonTTYTaskSignatureById = new Map<number, string>();
961
1033
  let tick = 0;
@@ -999,7 +1071,7 @@ const makeDefaultFrameRenderer = (
999
1071
  return;
1000
1072
  }
1001
1073
 
1002
- yield* terminal.writeStderr("\n" + SHOW_CURSOR);
1074
+ yield* terminal.writeStderr(`\n${SHOW_CURSOR}`);
1003
1075
  previousLineCount = 0;
1004
1076
  sessionActive = false;
1005
1077
  });
@@ -1007,7 +1079,7 @@ const makeDefaultFrameRenderer = (
1007
1079
  const renderNonTTYTaskUpdates = (
1008
1080
  ordered: ReadonlyArray<{
1009
1081
  snapshot: TaskSnapshot;
1010
- lines: ReadonlyArray<string>;
1082
+ line: string;
1011
1083
  }>,
1012
1084
  ) => {
1013
1085
  const nextTaskSignatureById = new Map<number, string>();
@@ -1024,13 +1096,16 @@ const makeDefaultFrameRenderer = (
1024
1096
 
1025
1097
  nextTaskSignatureById.set(taskId, signature);
1026
1098
  if (nonTTYTaskSignatureById.get(taskId) !== signature) {
1027
- changedTaskLines.push(...ordered[i]!.lines);
1099
+ const line = ordered[i]!.line;
1100
+ if (line.length > 0) {
1101
+ changedTaskLines.push(line);
1102
+ }
1028
1103
  }
1029
1104
  }
1030
1105
 
1031
1106
  return Effect.gen(function* () {
1032
1107
  if (changedTaskLines.length > 0) {
1033
- yield* terminal.writeStderr(changedTaskLines.join("\n") + "\n");
1108
+ yield* terminal.writeStderr(`${changedTaskLines.join("\n")}\n`);
1034
1109
  }
1035
1110
 
1036
1111
  nonTTYTaskSignatureById = nextTaskSignatureById;
@@ -1039,7 +1114,6 @@ const makeDefaultFrameRenderer = (
1039
1114
 
1040
1115
  const renderFrame = (mode: "tick" | "final") =>
1041
1116
  Effect.gen(function* () {
1042
- // Materialize one frame snapshot from current state.
1043
1117
  const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
1044
1118
  const store = yield* Ref.get(storeRef);
1045
1119
  const orderedTasks = store.renderOrder.flatMap((row) => {
@@ -1052,7 +1126,6 @@ const makeDefaultFrameRenderer = (
1052
1126
  {
1053
1127
  snapshot,
1054
1128
  depth: row.depth,
1055
- theme: store.themes.get(snapshot.id) ?? fallbackTheme,
1056
1129
  },
1057
1130
  ];
1058
1131
  });
@@ -1060,38 +1133,22 @@ const makeDefaultFrameRenderer = (
1060
1133
  const now = yield* Clock.currentTimeMillis;
1061
1134
  const frameTick = mode === "final" ? tick + 1 : tick;
1062
1135
  const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
1063
- const maxTaskWidth = rendererConfig.maxTaskWidth;
1064
1136
 
1065
- const frameModel = buildStage.buildFrame({
1137
+ const renderedFrame = renderTaskFrame(
1066
1138
  orderedTasks,
1139
+ compiledColumns,
1067
1140
  rendererConfig,
1068
1141
  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
- }
1142
+ isTTY ? frameTick : 0,
1143
+ terminalColumns,
1144
+ isTTY,
1145
+ );
1089
1146
 
1090
1147
  if (isTTY) {
1091
1148
  let frame = "";
1092
1149
 
1093
1150
  if (previousLineCount > 0) {
1094
- frame += "\r" + CLEAR_LINE;
1151
+ frame += `\r${CLEAR_LINE}`;
1095
1152
  for (let i = 1; i < previousLineCount; i++) {
1096
1153
  frame += MOVE_UP_ONE + CLEAR_LINE;
1097
1154
  }
@@ -1099,19 +1156,19 @@ const makeDefaultFrameRenderer = (
1099
1156
 
1100
1157
  if (retainLogHistory) {
1101
1158
  const historyLogs = yield* Ref.get(logsRef);
1102
- const lines = yield* clipTTYFrameLines([...historyLogs, ...taskLines]);
1159
+ const lines = yield* clipTTYFrameLines([...historyLogs, ...renderedFrame.lines]);
1103
1160
  if (lines.length > 0) {
1104
1161
  frame += lines.join("\n");
1105
1162
  }
1106
1163
  previousLineCount = lines.length;
1107
1164
  } else {
1108
1165
  if (drainedLogs.length > 0) {
1109
- frame += drainedLogs.join("\n") + "\n";
1166
+ frame += `${drainedLogs.join("\n")}\n`;
1110
1167
  }
1111
- if (taskLines.length > 0) {
1112
- frame += taskLines.join("\n");
1168
+ if (renderedFrame.lines.length > 0) {
1169
+ frame += renderedFrame.lines.join("\n");
1113
1170
  }
1114
- previousLineCount = taskLines.length;
1171
+ previousLineCount = renderedFrame.lines.length;
1115
1172
  }
1116
1173
 
1117
1174
  if (frame) {
@@ -1121,19 +1178,17 @@ const makeDefaultFrameRenderer = (
1121
1178
  }
1122
1179
 
1123
1180
  if (drainedLogs.length > 0) {
1124
- yield* terminal.writeStderr(drainedLogs.join("\n") + "\n");
1181
+ yield* terminal.writeStderr(`${drainedLogs.join("\n")}\n`);
1125
1182
  }
1126
1183
 
1127
1184
  const orderedForNonTTY = orderedTasks.map((task) => ({
1128
1185
  snapshot: task.snapshot,
1129
- lines: taskLineMap.get(task.snapshot.id as number) ?? [],
1186
+ line: renderedFrame.lineByTaskId.get(task.snapshot.id as number) ?? "",
1130
1187
  }));
1131
1188
  yield* renderNonTTYTaskUpdates(orderedForNonTTY);
1132
1189
  });
1133
1190
 
1134
1191
  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
1192
  rendererActive = true;
1138
1193
  if (isTTY) {
1139
1194
  yield* startTTYSession;
@@ -1188,39 +1243,12 @@ const makeDefaultFrameRenderer = (
1188
1243
  }),
1189
1244
  });
1190
1245
 
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
1246
  export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
1213
1247
  FrameRenderer,
1214
1248
  FrameRendererService
1215
1249
  >() {
1216
- static readonly Default = Layer.effect(
1250
+ static readonly Default = Layer.succeed(
1217
1251
  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
- }),
1252
+ FrameRenderer.of(makeDefaultFrameRenderer()),
1225
1253
  );
1226
1254
  }