effective-progress 0.4.2 → 0.5.0

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 DELETED
@@ -1,1254 +0,0 @@
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";
13
- import type { ProgressTerminalService } from "./terminal";
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";
24
-
25
- const HIDE_CURSOR = "\x1b[?25l";
26
- const SHOW_CURSOR = "\x1b[?25h";
27
- const CLEAR_LINE = "\x1b[2K";
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;
32
-
33
- const clamp = (value: number, minimum: number, maximum: number): number =>
34
- Math.min(Math.max(value, minimum), maximum);
35
-
36
- const textWidth = (text: string): number => Array.from(text).length;
37
-
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
- };
59
-
60
- const formatElapsed = (snapshot: TaskSnapshot, now: number): string => {
61
- const elapsedMillis = Math.max(0, (snapshot.completedAt ?? now) - snapshot.startedAt);
62
- return formatDurationSeconds(elapsedMillis / 1000);
63
- };
64
-
65
- const formatEta = (snapshot: TaskSnapshot, now: number): string => {
66
- if (snapshot.status !== "running" || snapshot.units._tag !== "DeterminateTaskUnits") {
67
- return "";
68
- }
69
-
70
- const { completed, total } = snapshot.units;
71
- const remaining = total - completed;
72
- if (completed <= 0 || remaining <= 0) {
73
- return "";
74
- }
75
-
76
- const elapsedMillis = Math.max(1, now - snapshot.startedAt);
77
- const etaMillis = Math.max(0, Math.floor((elapsedMillis / completed) * remaining));
78
- return `ETA: ${formatDurationSeconds(etaMillis / 1000)}`;
79
- };
80
-
81
- const reserveTimeWidth = (formattedDuration: string): string =>
82
- formattedDuration.padStart(RESERVED_SECONDS_WIDTH, " ");
83
-
84
- const styleIfTTY = (isTTY: boolean, style: (text: string) => string, text: string): string =>
85
- isTTY ? style(text) : text;
86
-
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;
94
- }
95
-
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
- }
147
- }
148
-
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;
155
- }
156
-
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
- });
168
-
169
- export class BarColumn implements ProgressColumn {
170
- readonly id: string;
171
- readonly track: ColumnTrack;
172
- readonly minWidth: number;
173
- readonly maxWidth?: number;
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
- }
187
-
188
- static Default(): BarColumn {
189
- return new BarColumn();
190
- }
191
-
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
- }
260
- }
261
-
262
- export interface AmountColumnOptions extends ColumnBaseOptions {
263
- readonly doneSymbol?: string;
264
- readonly failedSymbol?: string;
265
- }
266
-
267
- export class AmountColumn implements ProgressColumn {
268
- readonly id: string;
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
- }
287
-
288
- static Default(): AmountColumn {
289
- return new AmountColumn();
290
- }
291
-
292
- static make(options: AmountColumnOptions = {}): AmountColumn {
293
- return new AmountColumn(options);
294
- }
295
-
296
- render(context: ProgressColumnContext): string {
297
- const { task } = context;
298
-
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
- }
306
-
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
- }
313
-
314
- if (task.status === "done") {
315
- return styleIfTTY(context.isTTY, chalk.green, this.doneSymbol);
316
- }
317
-
318
- return styleIfTTY(context.isTTY, chalk.red, this.failedSymbol);
319
- }
320
- }
321
-
322
- export interface ElapsedColumnOptions extends ColumnBaseOptions {
323
- readonly padSeconds?: boolean;
324
- }
325
-
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
- }
358
- }
359
-
360
- export interface EtaColumnOptions extends ColumnBaseOptions {
361
- readonly label?: string;
362
- readonly pendingText?: string;
363
- }
364
-
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
- }
455
- }
456
-
457
- export interface LiteralColumnOptions extends ColumnBaseOptions {
458
- readonly text: string;
459
- }
460
-
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;
469
-
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;
478
- }
479
-
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;
502
-
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));
509
- return { min, max };
510
- };
511
-
512
- const ratioDistribute = (
513
- total: number,
514
- ratios: ReadonlyArray<number>,
515
- minimums: ReadonlyArray<number>,
516
- ): ReadonlyArray<number> => {
517
- const amounts = [...minimums];
518
- const totalMinimum = amounts.reduce((sum, value) => sum + value, 0);
519
- const distributable = Math.max(0, total - totalMinimum);
520
-
521
- let remaining = distributable;
522
- let totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);
523
-
524
- for (let i = 0; i < ratios.length; i++) {
525
- if (remaining <= 0) {
526
- break;
527
- }
528
-
529
- const ratio = ratios[i] ?? 0;
530
- const share = totalRatio > 0 ? Math.ceil((ratio * remaining) / totalRatio) : 0;
531
- amounts[i] = (amounts[i] ?? 0) + share;
532
- remaining -= share;
533
- totalRatio -= ratio;
534
- }
535
-
536
- return amounts;
537
- };
538
-
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
- }
547
-
548
- return Math.max(1, Math.floor(terminalColumns));
549
- }
550
-
551
- const resolvedConfiguredWidth = Math.max(1, Math.floor(width));
552
-
553
- if (terminalColumns === undefined) {
554
- return resolvedConfiguredWidth;
555
- }
556
-
557
- return Math.min(Math.max(1, Math.floor(terminalColumns)), resolvedConfiguredWidth);
558
- };
559
-
560
- const shrinkByPriority = (
561
- widths: Array<number>,
562
- minWidths: ReadonlyArray<number>,
563
- columns: ReadonlyArray<ProgressColumn>,
564
- overflow: number,
565
- ): number => {
566
- const shrinkable = columns
567
- .map((column, index) => ({
568
- index,
569
- priority: column.collapsePriority ?? Number.MAX_SAFE_INTEGER,
570
- }))
571
- .sort((a, b) => a.priority - b.priority);
572
-
573
- let remainingOverflow = overflow;
574
-
575
- for (const entry of shrinkable) {
576
- if (remainingOverflow <= 0) {
577
- break;
578
- }
579
-
580
- const current = widths[entry.index] ?? 0;
581
- const min = minWidths[entry.index] ?? 0;
582
- const available = Math.max(0, current - min);
583
- if (available <= 0) {
584
- continue;
585
- }
586
-
587
- const reduceBy = Math.min(available, remainingOverflow);
588
- widths[entry.index] = current - reduceBy;
589
- remainingOverflow -= reduceBy;
590
- }
591
-
592
- return remainingOverflow;
593
- };
594
-
595
- const shrinkProportionally = (
596
- widths: Array<number>,
597
- minWidths: ReadonlyArray<number>,
598
- overflow: number,
599
- ): number => {
600
- let remainingOverflow = overflow;
601
-
602
- while (remainingOverflow > 0) {
603
- const entries = widths
604
- .map((width, index) => ({
605
- index,
606
- width,
607
- min: minWidths[index] ?? 0,
608
- available: Math.max(0, width - (minWidths[index] ?? 0)),
609
- }))
610
- .filter((entry) => entry.available > 0);
611
-
612
- if (entries.length === 0) {
613
- break;
614
- }
615
-
616
- const distribution = ratioDistribute(
617
- remainingOverflow,
618
- entries.map((entry) => Math.max(1, entry.width)),
619
- entries.map(() => 0),
620
- );
621
-
622
- let reduced = 0;
623
-
624
- for (let i = 0; i < entries.length; i++) {
625
- const entry = entries[i]!;
626
- const target = distribution[i] ?? 0;
627
- if (target <= 0) {
628
- continue;
629
- }
630
-
631
- const reduceBy = Math.min(entry.available, target);
632
- if (reduceBy <= 0) {
633
- continue;
634
- }
635
-
636
- widths[entry.index] = (widths[entry.index] ?? 0) - reduceBy;
637
- remainingOverflow -= reduceBy;
638
- reduced += reduceBy;
639
- }
640
-
641
- if (reduced <= 0) {
642
- break;
643
- }
644
- }
645
-
646
- return remainingOverflow;
647
- };
648
-
649
- const resolveColumnWidths = (
650
- columns: ReadonlyArray<ProgressColumn>,
651
- intrinsicWidths: ReadonlyArray<number>,
652
- totalWidth: number | undefined,
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
- };
663
- }
664
-
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);
668
-
669
- for (let i = 0; i < columns.length; i++) {
670
- const column = columns[i]!;
671
- const track = resolveTrack(column);
672
- const minWidth = minWidths[i] ?? 0;
673
- const maxWidth = maxWidths[i];
674
- const intrinsic = Math.max(0, Math.floor(intrinsicWidths[i] ?? 0));
675
-
676
- const baseWidth = (() => {
677
- switch (track._tag) {
678
- case "Fixed":
679
- return Math.max(minWidth, track.width);
680
- case "Fraction":
681
- return totalWidth === undefined ? Math.max(minWidth, intrinsic) : minWidth;
682
- case "Auto":
683
- return Math.max(minWidth, intrinsic);
684
- }
685
- })();
686
-
687
- widths[i] =
688
- maxWidth === undefined ? baseWidth : clamp(baseWidth, minWidth, Math.max(minWidth, maxWidth));
689
- }
690
-
691
- if (totalWidth === undefined) {
692
- return {
693
- widths,
694
- overflowBeforeShrink: 0,
695
- };
696
- }
697
-
698
- const usableWidth = Math.max(1, totalWidth - gap * Math.max(0, columns.length - 1));
699
-
700
- const fractionColumns: Array<{ index: number; weight: number }> = [];
701
- for (let index = 0; index < columns.length; index++) {
702
- const track = resolveTrack(columns[index]!);
703
- if (track._tag === "Fraction") {
704
- fractionColumns.push({ index, weight: track.weight });
705
- }
706
- }
707
-
708
- let remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
709
- const overflowBeforeShrink = Math.max(0, -remaining);
710
-
711
- if (remaining > 0 && fractionColumns.length > 0) {
712
- const distributed = ratioDistribute(
713
- remaining,
714
- fractionColumns.map((entry) => entry.weight),
715
- fractionColumns.map(() => 0),
716
- );
717
-
718
- for (let i = 0; i < fractionColumns.length; i++) {
719
- const entry = fractionColumns[i]!;
720
- widths[entry.index] = (widths[entry.index] ?? 0) + (distributed[i] ?? 0);
721
- }
722
-
723
- remaining = usableWidth - widths.reduce((sum, width) => sum + width, 0);
724
- }
725
-
726
- if (remaining < 0) {
727
- let overflow = -remaining;
728
- overflow = shrinkByPriority(widths, minWidths, columns, overflow);
729
- if (overflow > 0) {
730
- overflow = shrinkProportionally(widths, minWidths, overflow);
731
- }
732
- }
733
-
734
- return {
735
- widths: widths.map((width, index) => {
736
- const min = minWidths[index] ?? 0;
737
- const max = maxWidths[index];
738
-
739
- if (max === undefined) {
740
- return Math.max(min, width);
741
- }
742
-
743
- return clamp(Math.max(min, width), min, max);
744
- }),
745
- overflowBeforeShrink,
746
- };
747
- };
748
-
749
- const normalizeColumns = (
750
- columns: ReadonlyArray<ProgressColumn | string>,
751
- ): ReadonlyArray<ProgressColumn> => {
752
- const resolved = columns.length > 0 ? columns : Columns.defaults();
753
-
754
- return resolved.map((entry, index) => {
755
- const normalized = typeof entry === "string" ? LiteralColumn.make(entry) : entry;
756
-
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}`);
763
- }
764
-
765
- if (typeof normalized.id !== "string" || normalized.id.length === 0) {
766
- throw new Error(`Progress column at index ${index} is missing a valid id`);
767
- }
768
-
769
- return normalized;
770
- });
771
- };
772
-
773
- interface OrderedTaskModel {
774
- readonly snapshot: TaskSnapshot;
775
- readonly depth: number;
776
- }
777
-
778
- interface RenderedFrame {
779
- readonly lines: ReadonlyArray<string>;
780
- readonly lineByTaskId: ReadonlyMap<number, string>;
781
- }
782
-
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
- });
787
-
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);
796
- }
797
-
798
- return variants[Math.min(level, variants.length - 1)]!;
799
- };
800
-
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
- };
824
- }
825
-
826
- const orderedWithTree = computeTreeInfo(
827
- orderedTasks.map((entry) => ({ snapshot: entry.snapshot, depth: entry.depth })),
828
- );
829
-
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);
847
- }
848
- }
849
- return maxLevel;
850
- });
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
- }
868
-
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
- }
882
-
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);
889
-
890
- return {
891
- intrinsicByColumn,
892
- activeColumnIndexes,
893
- activeColumns,
894
- widths: widthResolution.widths,
895
- overflowBeforeShrink: widthResolution.overflowBeforeShrink,
896
- };
897
- };
898
-
899
- let layout = resolveLayoutForVariants(variantLevelByColumn);
900
-
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
- });
908
-
909
- if (layout.overflowBeforeShrink <= 0 && !hasCompressedColumns) {
910
- break;
911
- }
912
-
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
- );
924
-
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
- }
938
-
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;
959
- }
960
-
961
- variantLevelByColumn[best.index] = (variantLevelByColumn[best.index] ?? 0) + 1;
962
- layout = resolveLayoutForVariants(variantLevelByColumn);
963
- }
964
- }
965
-
966
- if (layout.activeColumnIndexes.length === 0) {
967
- const emptyLines = orderedTasks.map(() => "");
968
- return {
969
- lines: emptyLines,
970
- lineByTaskId: new Map(orderedTasks.map((entry) => [entry.snapshot.id as number, ""])),
971
- };
972
- }
973
-
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
- );
992
-
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] ?? "");
996
- }
997
-
998
- return {
999
- lines,
1000
- lineByTaskId,
1001
- };
1002
- };
1003
-
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
- }
1016
-
1017
- const makeDefaultFrameRenderer = (): FrameRendererService => ({
1018
- run: (
1019
- storeRef,
1020
- logsRef,
1021
- pendingLogsRef,
1022
- dirtyRef,
1023
- terminal,
1024
- isTTY,
1025
- rendererConfig,
1026
- maxRetainedLogLines,
1027
- ) =>
1028
- Effect.gen(function* () {
1029
- const retainLogHistory = maxRetainedLogLines > 0;
1030
- const compiledColumns = normalizeColumns(rendererConfig.columns);
1031
- let previousLineCount = 0;
1032
- let nonTTYTaskSignatureById = new Map<number, string>();
1033
- let tick = 0;
1034
- let rendererActive = false;
1035
- let sessionActive = false;
1036
-
1037
- const clipTTYFrameLines = (lines: ReadonlyArray<string>) =>
1038
- Effect.gen(function* () {
1039
- const terminalRows = yield* terminal.stderrRows;
1040
- if (terminalRows === undefined) {
1041
- return lines;
1042
- }
1043
-
1044
- const visibleLineLimit = Math.max(1, terminalRows - 1);
1045
- if (lines.length <= visibleLineLimit) {
1046
- return lines;
1047
- }
1048
-
1049
- if (visibleLineLimit === 1) {
1050
- return [`... ${lines.length} lines hidden`];
1051
- }
1052
-
1053
- const hiddenLineCount = lines.length - visibleLineLimit + 1;
1054
- return [
1055
- `... ${hiddenLineCount} lines hidden (showing latest lines)`,
1056
- ...lines.slice(lines.length - (visibleLineLimit - 1)),
1057
- ];
1058
- });
1059
-
1060
- const startTTYSession = Effect.gen(function* () {
1061
- if (!isTTY || sessionActive) {
1062
- return;
1063
- }
1064
-
1065
- yield* terminal.writeStderr(HIDE_CURSOR);
1066
- sessionActive = true;
1067
- });
1068
-
1069
- const stopTTYSession = Effect.gen(function* () {
1070
- if (!isTTY || !sessionActive) {
1071
- return;
1072
- }
1073
-
1074
- yield* terminal.writeStderr(`\n${SHOW_CURSOR}`);
1075
- previousLineCount = 0;
1076
- sessionActive = false;
1077
- });
1078
-
1079
- const renderNonTTYTaskUpdates = (
1080
- ordered: ReadonlyArray<{
1081
- snapshot: TaskSnapshot;
1082
- line: string;
1083
- }>,
1084
- ) => {
1085
- const nextTaskSignatureById = new Map<number, string>();
1086
- const changedTaskLines: Array<string> = [];
1087
- const nonTtyUpdateStep = Math.max(1, Math.floor(rendererConfig.nonTtyUpdateStep));
1088
-
1089
- for (let i = 0; i < ordered.length; i++) {
1090
- const taskId = ordered[i]!.snapshot.id as number;
1091
- const snapshot = ordered[i]!.snapshot;
1092
- const signature =
1093
- snapshot.units._tag === "DeterminateTaskUnits"
1094
- ? `${snapshot.status}:${snapshot.description}:${Math.floor(snapshot.units.completed / nonTtyUpdateStep)}:${snapshot.units.total}`
1095
- : `${snapshot.status}:${snapshot.description}`;
1096
-
1097
- nextTaskSignatureById.set(taskId, signature);
1098
- if (nonTTYTaskSignatureById.get(taskId) !== signature) {
1099
- const line = ordered[i]!.line;
1100
- if (line.length > 0) {
1101
- changedTaskLines.push(line);
1102
- }
1103
- }
1104
- }
1105
-
1106
- return Effect.gen(function* () {
1107
- if (changedTaskLines.length > 0) {
1108
- yield* terminal.writeStderr(`${changedTaskLines.join("\n")}\n`);
1109
- }
1110
-
1111
- nonTTYTaskSignatureById = nextTaskSignatureById;
1112
- });
1113
- };
1114
-
1115
- const renderFrame = (mode: "tick" | "final") =>
1116
- Effect.gen(function* () {
1117
- const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
1118
- const store = yield* Ref.get(storeRef);
1119
- const orderedTasks = store.renderOrder.flatMap((row) => {
1120
- const snapshot = store.tasks.get(row.id);
1121
- if (!snapshot || (snapshot.transient && snapshot.status !== "running")) {
1122
- return [];
1123
- }
1124
-
1125
- return [
1126
- {
1127
- snapshot,
1128
- depth: row.depth,
1129
- },
1130
- ];
1131
- });
1132
-
1133
- const now = yield* Clock.currentTimeMillis;
1134
- const frameTick = mode === "final" ? tick + 1 : tick;
1135
- const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
1136
-
1137
- const renderedFrame = renderTaskFrame(
1138
- orderedTasks,
1139
- compiledColumns,
1140
- rendererConfig,
1141
- now,
1142
- isTTY ? frameTick : 0,
1143
- terminalColumns,
1144
- isTTY,
1145
- );
1146
-
1147
- if (isTTY) {
1148
- let frame = "";
1149
-
1150
- if (previousLineCount > 0) {
1151
- frame += `\r${CLEAR_LINE}`;
1152
- for (let i = 1; i < previousLineCount; i++) {
1153
- frame += MOVE_UP_ONE + CLEAR_LINE;
1154
- }
1155
- }
1156
-
1157
- if (retainLogHistory) {
1158
- const historyLogs = yield* Ref.get(logsRef);
1159
- const lines = yield* clipTTYFrameLines([...historyLogs, ...renderedFrame.lines]);
1160
- if (lines.length > 0) {
1161
- frame += lines.join("\n");
1162
- }
1163
- previousLineCount = lines.length;
1164
- } else {
1165
- if (drainedLogs.length > 0) {
1166
- frame += `${drainedLogs.join("\n")}\n`;
1167
- }
1168
- if (renderedFrame.lines.length > 0) {
1169
- frame += renderedFrame.lines.join("\n");
1170
- }
1171
- previousLineCount = renderedFrame.lines.length;
1172
- }
1173
-
1174
- if (frame) {
1175
- yield* terminal.writeStderr(frame);
1176
- }
1177
- return;
1178
- }
1179
-
1180
- if (drainedLogs.length > 0) {
1181
- yield* terminal.writeStderr(`${drainedLogs.join("\n")}\n`);
1182
- }
1183
-
1184
- const orderedForNonTTY = orderedTasks.map((task) => ({
1185
- snapshot: task.snapshot,
1186
- line: renderedFrame.lineByTaskId.get(task.snapshot.id as number) ?? "",
1187
- }));
1188
- yield* renderNonTTYTaskUpdates(orderedForNonTTY);
1189
- });
1190
-
1191
- const renderLoop = Effect.gen(function* () {
1192
- rendererActive = true;
1193
- if (isTTY) {
1194
- yield* startTTYSession;
1195
- }
1196
-
1197
- while (true) {
1198
- const dirty = yield* Ref.getAndSet(dirtyRef, false);
1199
- const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
1200
- (task) => !(task.transient && task.status !== "running"),
1201
- );
1202
- const hasActiveSpinners = tasks.some(
1203
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
1204
- );
1205
- const hasPendingLogs = (yield* Ref.get(pendingLogsRef)).length > 0;
1206
-
1207
- if (isTTY) {
1208
- if (dirty || hasActiveSpinners || hasPendingLogs) {
1209
- yield* renderFrame("tick");
1210
- }
1211
- } else if (dirty || hasActiveSpinners) {
1212
- yield* renderFrame("tick");
1213
- }
1214
-
1215
- tick += 1;
1216
- yield* Effect.sleep(Math.max(1, Math.floor(rendererConfig.renderIntervalMillis)));
1217
- }
1218
- }).pipe(
1219
- Effect.ensuring(
1220
- Effect.gen(function* () {
1221
- if (!rendererActive) {
1222
- return;
1223
- }
1224
-
1225
- if (isTTY) {
1226
- if (sessionActive) {
1227
- yield* renderFrame("final");
1228
- yield* stopTTYSession;
1229
- }
1230
- return;
1231
- }
1232
-
1233
- yield* renderFrame("final");
1234
- }),
1235
- ),
1236
- );
1237
-
1238
- if (isTTY && rendererConfig.disableUserInput) {
1239
- return yield* terminal.withRawInputCapture(renderLoop);
1240
- }
1241
-
1242
- return yield* renderLoop;
1243
- }),
1244
- });
1245
-
1246
- export class FrameRenderer extends Context.Tag("stromseng.dev/effective-progress/FrameRenderer")<
1247
- FrameRenderer,
1248
- FrameRendererService
1249
- >() {
1250
- static readonly Default = Layer.succeed(
1251
- FrameRenderer,
1252
- FrameRenderer.of(makeDefaultFrameRenderer()),
1253
- );
1254
- }